Webhooks
Get textbee SMS events on your server in real time: create a webhook subscription, verify the X-Signature header, and handle retries and pauses.
Updated
A webhook sends an HTTP POST to your server when an SMS event happens, for example when your phone receives a message or a sent message is delivered. You create a subscription with a URL, a signing secret and a list of events. textbee signs each request so your server can prove it came from textbee.
How webhooks work
- You create a webhook subscription in the dashboard or with the API.
- An event happens on your account, for example the phone receives an SMS.
- textbee builds a JSON payload for the event and signs it with the subscription's secret.
- textbee sends a POST request with the payload to your URL.
- Your endpoint verifies the signature and returns a 2xx status.
- If the request fails, textbee retries it later.
An account can have several subscriptions: for example one for production, one for staging and one for a CRM. Each subscription has its own URL, secret and events. When two subscriptions select the same event, each one gets its own delivery.
Create a subscription in the dashboard
You need a public URL that accepts HTTP POST requests and returns a 2xx status.
- Open the dashboard and go to Webhooks.
- Click Add Webhook or Create Webhook.
- (Optional) Give the webhook a short name so you can tell your endpoints apart.
- Enter your webhook URL. Use HTTPS in production. Private and loopback hosts (for example
localhost,127.0.0.1and private network ranges) are rejected. - Select the events you want. The events summary below lists them.
- Set a signing secret of at least 20 characters.
- Save. textbee sends a POST request to your URL each time a selected event happens.
Create a subscription with the API
The webhooks endpoint creates a subscription from code.
POST/api/v1/webhooks, open in the API reference
This request creates a subscription for received messages and delivery reports:
curl -X POST https://api.textbee.dev/api/v1/webhooks \
-H "x-api-key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Production inbox",
"deliveryUrl": "https://example.com/textbee/webhook",
"signingSecret": "3f9a1c7e5b2d8f4a6c0e9b1d7a3f5c8e",
"events": ["MESSAGE_RECEIVED", "MESSAGE_DELIVERED", "MESSAGE_FAILED"]
}'| Field | Required | Description |
|---|---|---|
deliveryUrl | Yes | URL that receives the POST requests. http or https, publicly reachable. |
signingSecret | Yes | Shared secret, at least 20 characters. textbee signs every delivery with it. |
events | Yes | One or more event names. |
name | No | Label shown in the dashboard, up to 64 characters. |
Generate a random secret, for example with openssl rand -hex 32, and keep it on your server only.
The other routes manage subscriptions and read the delivery log:
| Method and path | What it does |
|---|---|
GET /webhooks | Lists your subscriptions. Deleted ones are excluded. |
GET /webhooks/{webhookId} | Returns one subscription with its delivery counters. |
PATCH /webhooks/{webhookId} | Changes only the fields you send: name, deliveryUrl, signingSecret, events, isActive. |
DELETE /webhooks/{webhookId} | Stops all deliveries for the subscription. Its delivery log stays readable. |
GET /webhooks/notifications | Lists delivery attempts, newest first. |
The API reference has the full request and response schemas.
Events
| Event | Fires when |
|---|---|
MESSAGE_RECEIVED | The phone uploads an incoming SMS. |
MESSAGE_SENT | The phone reports that the carrier accepted an outgoing SMS. |
MESSAGE_DELIVERED | The carrier reports delivery to the recipient's handset. |
MESSAGE_FAILED | The phone reports that an outgoing SMS failed. |
UNKNOWN_STATE | The phone reports a status that textbee cannot map. |
SMS_STATUS_UPDATED | Reserved. You can select it, but textbee does not emit it today. |
The Webhook events reference has a full payload and a field table for each event.
The delivery request
Each delivery is one HTTP request:
- Method:
POST - Body: a flat JSON object. Every event has the same six envelope fields, and each event adds its own fields.
- Headers:
Content-Type: application/jsonandX-Signature, the signature of the body.
This is a MESSAGE_RECEIVED body:
{
"smsId": "66f7c1e2a4d5e6f7a8b9c0d1",
"message": "YES, please confirm my booking",
"deviceId": "664a9b8cd0e1f2a3b4c5d6e7",
"webhookSubscriptionId": "664a9b8cd0e1f2a3b4c5d6e8",
"webhookEvent": "MESSAGE_RECEIVED",
"idempotencyKey": "8f7e6d5c-4b3a-4c1d-8e9f-8a7b6c5d4e3f",
"sender": "+12015550123",
"receivedAt": "2026-09-28T09:14:03.000Z"
}Return a 2xx status as fast as you can. Put slow work, such as calls to other systems or outgoing replies, in a background job. A slow response times out and counts as a failed attempt.
Verify the signature
The X-Signature header holds a signature of the request body. Check it before you trust the body.
The algorithm:
- Read the raw request body as bytes. Do not parse it and serialize it again first.
- Compute HMAC-SHA256 of the raw body. Use the signing secret of the subscription that the request is for.
- Encode the result as lowercase hex.
- Compare it with the
X-Signatureheader value in constant time, for example withcrypto.timingSafeEqualin Node orhmac.compare_digestin Python. - If the values differ, return 401 and ignore the request.
This sample verifies the signature, skips duplicates by idempotencyKey and handles MESSAGE_RECEIVED:
# Post a signed test event to your own endpoint, the way textbee does.
BODY='{"smsId":"66b1f2c3a4d5e6f7a8b9c0d1","message":"Hello","deviceId":"664a9b8cd0e1f2a3b4c5d6e7","webhookSubscriptionId":"664a9b8cd0e1f2a3b4c5d6e8","webhookEvent":"MESSAGE_RECEIVED","idempotencyKey":"8f7e6d5c-4b3a-2c1d-0e9f-8a7b6c5d4e3f","sender":"+12015550123","receivedAt":"2026-09-05T10:00:00.000Z"}'
SIGNATURE=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$TEXTBEE_WEBHOOK_SECRET" | sed 's/^.* //')
curl -sS -o /dev/null -w '%{http_code}\n' -X POST "${WEBHOOK_URL:-http://localhost:3000/}" \
-H "Content-Type: application/json" \
-H "X-Signature: $SIGNATURE" \
-d "$BODY"The SDKs have a helper for the same check: verifyWebhookSignature in the JavaScript SDK and verify_webhook_signature in the Python SDK.
Retries and pausing
A delivery succeeds when your endpoint returns a 2xx status. Anything else is a failed attempt.
| Result of an attempt | errorType | What textbee does |
|---|---|---|
| 2xx | none | Marks the delivery delivered. |
| 5xx, timeout or network error | retryable | Retries later. The delay grows from minutes to hours to days. |
| 3xx or 4xx | non-retryable | Retries a few minutes later. After the 3rd failed attempt, stops retrying and marks the delivery failed. |
textbee makes up to 10 attempts for one delivery after a 5xx, timeout or network error, and up to 3 attempts after a 3xx or 4xx. It stops retrying a delivery 30 days after the event. textbee does not follow redirects, so a 3xx counts as a failed attempt. Do not return a 4xx status for a temporary problem on your side. Return a 5xx status so textbee tries again.
Each delivery has a computedStatus:
computedStatus | Meaning |
|---|---|
pending | No attempt yet. |
retrying | At least one attempt failed. textbee will try again. |
delivered | Your endpoint returned a 2xx status. |
failed | textbee gave up: 10 attempts failed, or 3 attempts failed with a non-retryable error. |
A subscription whose deliveries keep failing is paused. textbee sets isActive to false, adds a note to the subscription with the failure counts, and sends you an email. A paused subscription gets no deliveries. Fix your endpoint, then re-enable the subscription in the dashboard, or send PATCH /webhooks/{webhookId} with {"isActive": true}.
The delivery log
The dashboard shows every delivery and its exact payload under Webhooks > Deliveries. The same data is available from GET /webhooks/notifications. Filter it with status, eventType, deviceId, webhookSubscriptionId, and start with end.
Fields worth knowing on each delivery record:
| Field | Meaning |
|---|---|
computedStatus | pending, retrying, delivered or failed |
httpStatusCode | Status code your endpoint returned on the last attempt |
responseBody | First 1000 characters of your endpoint's response |
errorType | retryable or non-retryable |
deliveryAttemptCount | Attempts made so far |
nextDeliveryAttemptAt | When the next retry is due |
payload | The exact JSON body textbee sent |
Idempotency
Your endpoint can get the same delivery more than once. For example, your server saves the message but the response times out, so textbee retries. Every attempt of one delivery carries the same idempotencyKey.
- Store each
idempotencyKeyyou process, for example in a table with a unique index. - When a request arrives, check the key first.
- If you already processed it, return 200 and do nothing else.
Bulk sends
A bulk send produces one delivery per recipient message. A send to 500 recipients with MESSAGE_DELIVERED selected can produce up to 500 deliveries. Make sure your endpoint can accept that volume. Use smsBatchId in the payload to group events by batch.
Local development
textbee rejects private and loopback URLs, so it cannot reach localhost. To test on your computer, run a tunnel service that gives your local port a public HTTPS URL, and use that URL as the deliveryUrl. Delete the test subscription when you finish.
The curl tab of the webhook sample above posts a signed test event to your own endpoint. Use it to test the signature check without sending an SMS.
Troubleshooting
No deliveries arrive
- The subscription has the event selected. For incoming SMS, that is
MESSAGE_RECEIVED. - The subscription is active. Check for a pause note in the dashboard.
- The endpoint returns a 2xx status. Check
httpStatusCodeandresponseBodyin the delivery log. - For incoming SMS, Receive SMS is on in the app. See Receiving SMS.
The signature does not match
- Compute the HMAC over the raw body bytes. Many frameworks parse JSON before your handler runs. Read the raw body instead.
- Use the secret of the subscription that sent the request. Each subscription has its own secret.
- Compare hex strings. The header holds a lowercase hex digest, not base64.
Frequently asked questions
How many webhooks can one account have?
Several. Each subscription has its own URL, secret and events. The API returns a 400 error when you reach the maximum.
Does textbee send events in order?
Not always. A retry can arrive after a later event for the same message. Read status and the timestamps in the payload. The events reference explains how to handle order and duplicates.
Can I change the secret without downtime?
Create a second subscription with the new secret and the same events, and accept both secrets on your endpoint. Then delete the old subscription and remove the old secret.
Do deleted subscriptions keep their history?
Yes. GET /webhooks/notifications still returns deliveries of a deleted subscription.
Next steps
- Webhook events reference: every payload and field
- Receiving SMS: turn on forwarding on the phone
- Delivery status and message states: what each status means
- Message history and polling: fill gaps with a poll
- JavaScript SDK: verify signatures with one call
- Python SDK: verify signatures with one call