A webhook is an address of your own (a CRM, n8n, Make, a small server) that the gateway calls on its own when something happens: an SMS is received, a message is delivered, a phone stops answering, a backup fails. Your application no longer has to ask the gateway every minute whether something changed: it is told, within seconds.
Each call is signed, so your application can check that it really comes from your gateway, and retried when your application does not answer. It is the only way data leaves your gateway on its own initiative, and only towards the addresses you choose.
What a webhook carries, and what it does not
A webhook tells you that something happened and which item it concerns, by its identifier. It never carries the text of an SMS, a full phone number or the name of a contact. To get them, your application reads the item through the API, with a key whose scope you chose:
1. The gateway calls you: message.received, id = 0192f0c1-...
2. You read the message: GET /api/v1/messages/0192f0c1-... (key with the read scope)
This way, the content of your SMS never ends up in a third party's logs just because a webhook passed through it, and an address you configured by mistake receives nothing sensitive.
Two ways to create a webhook
| Where | Which events | Who |
|---|---|---|
| The Webhooks page of the dashboard | Any event of the catalogue, several per webhook | Operators and superadmins |
The API, POST /api/v1/webhooks |
The same | A key with the admin scope |
Both show the same webhooks: one created through the API appears on the Webhooks page, and one created on the page can be read and changed through the API.
Configure a webhook from the dashboard
The Webhooks page is in the Automation section of the sidebar, right under Rules. It has two tabs, Webhooks (the list, and a panel on how to verify the signature) and Delivery log. Everyone can open it; only operators and superadmins can change something (a read-only account sees a note saying so, and no action buttons).
Create or edit a webhook
Two buttons at the top of the page open the same form:
- New webhook opens it empty: you choose the events.
- Forward received SMS opens it with
message.receivedalready ticked: the shortcut for the most common need, described in Forwarding received SMS.
To change an existing webhook, open the menu at the end of its row and click Edit. The form holds every option of the webhook:
| Field | Default | Allowed values | Effect |
|---|---|---|---|
| Name | none, required | 1 to 80 characters, unique (capitals ignored) | Shown in the list, in the delivery log filter and in the log |
| URL | none, required | An http:// or https:// address, up to 2,000 characters |
Where each call goes. The machine of the gateway itself is refused and redirects are not followed: see Allowed destinations |
| Method | POST |
POST, PUT, PATCH |
The HTTP verb of each call |
| Events | none (the shortcut ticks message.received) |
At least one | Which events trigger a call. They are grouped by family (Messages, Phones, Campaigns, Automation and links, Licence, Backups), each with a one-line explanation. The box of a family ticks or unticks all its events at once. A change applies to events that happen afterwards |
| Custom headers | none | Up to 10, name up to 100 characters, value up to 500 | Added to every call, for example an Authorization token your tool expects. A reserved header (see Options) is refused before saving |
| Signing secret (optional) | drawn by the gateway | Empty, or 16 to 200 characters | The key of the signature. Leave it empty: the gateway draws a strong one |
| New signing secret (edit) | empty | Empty, or 16 to 200 characters | Empty keeps the current secret; a value replaces it from the next call, retries included |
| Webhook on | on | On or off | Off, nothing is sent and events that happen meanwhile are not kept. Turning it back on resets the failure counter |
Each label carries a small "i" that explains the field in more detail when you hover it. The form checks the name, the address, the events, the secret and the headers before sending, and says next to each field what is wrong.
After a creation, the Signing secret window shows the secret once, with a copy button. Paste it into your application, then click I have copied the secret: the gateway never shows it again. If you lose it, edit the webhook and type a new one in New signing secret.
The list
| Column | What it shows |
|---|---|
| Webhook | Name, URL and number of custom headers |
| Method | POST, PUT or PATCH |
| Events | The first three events, then +N for the others (hover it to read them), or All events |
| State | Active, Paused (someone turned it off) or Disabled after failures (the gateway turned it off, see Retries and automatic disabling) |
| Failures in a row | Deliveries given up in a row. At 100 the gateway switches the webhook off; any success resets it |
| Last success | The last delivery your application accepted, or Never |
The menu of each row offers:
- View deliveries: opens the Delivery log tab filtered on this webhook (for every role).
- Edit: the form above.
- Pause or Turn back on: switches the webhook off or on without opening the form. Turning it back on resets the failure counter.
- Delete, after a confirmation. The webhook and its delivery log are deleted; this cannot be undone. To keep the history, pause it instead.
The delivery log tab
Every call the gateway made, newest first, with three filters that can be combined: Webhook, Status (Pending, Delivered, Failed) and Event. Load more reads older entries, and the refresh button reads the newest ones again.
| Column | What it shows |
|---|---|
| Date | When the delivery was created |
| Event | The event delivered |
| Webhook | The webhook concerned, or Rule action for a call made by an automation rule |
| Status | Pending (waiting for its next attempt), Delivered or Failed (5 attempts used) |
| HTTP code | The status your application answered, or - if it never answered |
| Attempts | Attempts made so far, out of 5 |
| Duration | How long the last attempt took |
| Detail | The network error, the first 500 characters of your answer, or the time of the next attempt |
The Replay the delivery button at the end of a row sends the same body again as a new delivery, freshly signed. It appears on the deliveries of an active webhook only, for operators and superadmins: the gateway refuses to replay towards a paused or switched-off webhook, and a rule's call cannot be replayed.
Try your receiver
There is no "send a test" button. To try your application, trigger a real event: send an SMS to one
of your phones for message.received, send a message for message.sent, or start a backup for
backup.completed. Then open the Delivery log tab: the HTTP code and the detail tell you what
your application answered.
The signature panel
Under the list, Verify the signature sums up the five checks your application must make (the details and code examples are below), with a button that opens this page.
Create a webhook through the API
curl -X POST https://sms.example.com/api/v1/webhooks \
-H "X-API-KEY: sk_..." \
-H "Content-Type: application/json" \
-d '{
"name": "CRM delivery reports",
"url": "https://crm.example.com/hooks/sms",
"events": ["message.sent", "message.delivered", "message.failed"]
}'
The answer (201 Created) contains the webhook and, once only, its signing secret:
{
"webhook": {
"id": "0192f0d4-1c2b-7e8f-a1b2-3c4d5e6f7a8b",
"name": "CRM delivery reports",
"url": "https://crm.example.com/hooks/sms",
"method": "POST",
"events": ["message.sent", "message.delivered", "message.failed"],
"headers": [],
"active": true,
"consecutiveFailures": 0,
"createdAt": "2026-09-21T09:20:00Z",
"updatedAt": "2026-09-21T09:20:00Z"
},
"secret": "3f9c1e..."
}
Store the secret in your application right away: the gateway keeps it encrypted and never shows it again, neither in the dashboard nor through the API. If you lose it, set a new one (below).
The other operations: GET /webhooks (list), GET /webhooks/{id}, PATCH /webhooks/{id} (partial
change), DELETE /webhooks/{id}, GET /webhooks/deliveries (delivery log) and
POST /webhooks/deliveries/{id}/replay. Their details are in the API reference of your gateway.
Options
| Option | Where | Default | Allowed values | Effect |
|---|---|---|---|---|
name |
Webhooks page, API | required | 1 to 80 characters, unique (capitals ignored) | A label, shown in the list and in the delivery log |
url |
Webhooks page, API | required | An http:// or https:// address, up to 2,000 characters |
Where each delivery is sent. See Allowed destinations |
method |
Webhooks page, API | POST |
POST, PUT, PATCH |
The HTTP verb of each call. GET is refused: it would carry neither the data nor the signature |
events |
Webhooks page, API | required | At least one name from the catalogue | Which events trigger a call. An unknown name is refused |
headers |
Webhooks page, API | none | Up to 10, name up to 100 characters, value up to 500 | Added to every call, for example an Authorization token your tool expects. The gateway's own headers cannot be replaced |
secret |
Webhooks page, API | drawn by the gateway | 16 to 200 characters | The key of the signature. Leave it empty at creation and the gateway draws a strong one. On an edit, empty keeps the current one, a value replaces it |
active |
Webhooks page ("Webhook on", Pause, Turn back on), API | true |
true, false |
An inactive webhook receives nothing. Turning it back on resets the failure counter |
What the options change elsewhere in the product:
- Pausing (
active: false) does not keep events aside: whatever happens during the pause is not sent later, and deliveries still waiting for a retry are dropped. Use it for maintenance of your application, and fetch what you missed through the API afterwards (for exampleGET /messages?direction=in). - Changing the secret applies to the next call, including retries of deliveries already queued. Plan the switch on your side: accept both secrets for a few minutes, or pause the webhook while you update your application.
- Changing the events applies to events that happen after the change.
- Changing the URL or the method also applies to deliveries waiting for a retry: each attempt goes to the address the webhook has at that moment.
- Deleting a webhook also deletes its delivery log. To keep the history, pause it instead.
- The reserved headers are
X-Signature,X-Timestamp,X-Delivery-Id,X-Webhook-EventandContent-Type: a custom header with one of those names is refused, so a configuration mistake can never replace the signature.
Allowed destinations
The gateway checks the address it actually connects to, after resolving the name:
| Destination | Allowed |
|---|---|
| A public address | Yes |
A private network address (10.x, 172.16-31.x, 192.168.x, fd..) |
Yes by default, so the gateway can call a CRM on your local network. GATEWAY_WEBHOOKS_ALLOW_PRIVATE_NETWORKS=false forbids it |
The machine itself (127.0.0.1, localhost, ::1) |
No, never |
Link-local addresses and cloud metadata endpoints (169.254.169.254) |
No, never |
A receiver installed on the same server as the gateway must therefore be called on its private
network address (for example that of the Docker network), not on localhost. A redirect (3xx) is
never followed: it counts as a failure, so point the webhook directly at the final address.
Prefer an https:// address as soon as the receiver is outside your local network: the signature
proves where a call comes from, but only HTTPS hides its content on the way.
What your application receives
Each delivery is an HTTP request with a JSON body and these headers:
| Header | Content |
|---|---|
Content-Type |
application/json; charset=utf-8 |
X-Webhook-Event |
The event name, for example message.received, to route without reading the body |
X-Delivery-Id |
The identifier of this delivery. The same on every retry of the same delivery |
X-Timestamp |
When this attempt was sent, in Unix seconds |
X-Signature |
sha256= followed by the signature in hexadecimal |
| Your headers | The custom headers of the webhook |
The body always has the same shape. Empty fields are left out:
{
"event": "message.received",
"at": "2026-09-21T09:14:02.481Z",
"id": "0192f0c1-7a3e-7c55-9d1e-2b8f4a6c1d20",
"conversationId": "0192f0c1-7a3e-7c55-9d1e-2b8f4a6c1d21",
"deviceId": "0192e8aa-0b1c-7d2e-8f3a-4b5c6d7e8f90"
}
| Field | Content |
|---|---|
event |
The event name, the same as X-Webhook-Event |
at |
When the fact happened, in UTC |
id |
The identifier of the item concerned (message, phone, campaign and so on) |
conversationId |
The conversation, for the events that concern one |
deviceId |
The phone, for the events that concern one |
status |
The new state, for the events that are a change of state |
The catalogue says which fields each event fills in.
Verify the signature
Anyone who guesses your webhook's address can send it requests. The signature lets you refuse those that do not come from your gateway, and those that were captured and replayed later.
The signature is an HMAC-SHA256, computed with the webhook's secret, over this exact text:
<X-Timestamp>.<X-Delivery-Id>.<raw body>
The timestamp and the delivery identifier are part of the signed text, so an attacker cannot reuse an old request with a new date. To verify:
- Rebuild the text above from the two headers and the body exactly as received, before any JSON parsing: re-encoding the JSON would change the bytes, and the signature would no longer match.
- Compute the HMAC-SHA256 with your secret, in hexadecimal, and prefix it with
sha256=. - Compare it with
X-Signatureusing a constant-time comparison. - Refuse a timestamp more than 5 minutes away from your clock, in either direction.
- Answer quickly with a
2xx.
Keep your server's clock on time (NTP): with a clock several minutes off, every delivery would look like a replay.
Node.js (Express)
import crypto from 'node:crypto';
import express from 'express';
const app = express();
const SECRET = process.env.SMS_WEBHOOK_SECRET;
const TOLERANCE_SECONDS = 300;
// express.raw keeps the body as bytes: the signature covers the body exactly as sent.
app.post('/hooks/sms', express.raw({ type: 'application/json' }), (req, res) => {
const timestamp = req.get('X-Timestamp') ?? '';
const deliveryId = req.get('X-Delivery-Id') ?? '';
const signature = req.get('X-Signature') ?? '';
const expected =
'sha256=' +
crypto
.createHmac('sha256', SECRET)
.update(`${timestamp}.${deliveryId}.`)
.update(req.body)
.digest('hex');
const given = Buffer.from(signature);
const wanted = Buffer.from(expected);
if (given.length !== wanted.length || !crypto.timingSafeEqual(given, wanted)) {
return res.status(401).end();
}
const age = Math.abs(Date.now() / 1000 - Number(timestamp));
if (!Number.isFinite(age) || age > TOLERANCE_SECONDS) {
return res.status(401).end();
}
const event = JSON.parse(req.body.toString('utf8'));
// Answer first, work afterwards: the gateway waits 10 seconds at most.
res.status(204).end();
handle(deliveryId, event);
});
Python (Flask)
import hashlib
import hmac
import os
import time
from flask import Flask, abort, request
app = Flask(__name__)
SECRET = os.environ["SMS_WEBHOOK_SECRET"].encode()
TOLERANCE_SECONDS = 300
@app.post("/hooks/sms")
def sms_webhook():
timestamp = request.headers.get("X-Timestamp", "")
delivery_id = request.headers.get("X-Delivery-Id", "")
signature = request.headers.get("X-Signature", "")
body = request.get_data() # raw bytes, before any JSON parsing
signed = f"{timestamp}.{delivery_id}.".encode() + body
expected = "sha256=" + hmac.new(SECRET, signed, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, signature):
abort(401)
try:
age = abs(time.time() - int(timestamp))
except ValueError:
abort(401)
if age > TOLERANCE_SECONDS:
abort(401)
event = request.get_json()
handle(delivery_id, event) # keep it short, or hand it to a queue
return "", 204
In both examples, handle is your own code.
Process each delivery once
A delivery can arrive twice: your application processed it but its answer was lost, so the gateway
tries again. Keep the X-Delivery-Id values you have already processed (a few days are enough) and
ignore a delivery you have already seen, while still answering 2xx.
A replay started by hand from the delivery log is a new delivery, with a new X-Delivery-Id and
a new signature, but the same body. If your processing must never run twice for the same fact, also
check the pair event + id.
Retries and automatic disabling
A delivery succeeds when your application answers with a 2xx status within 10 seconds. Any
other answer (3xx, 4xx, 5xx), a timeout or a connection that fails counts as a failed attempt.
| Setting | Value |
|---|---|
| Attempts per delivery | 5 |
| Wait before attempts 2, 3, 4 and 5 | 30 seconds, 2 minutes, 10 minutes, 1 hour |
| Time limit of one attempt | 10 seconds |
| Part of your answer kept in the log | The first 500 characters |
| Deliveries given up before disabling | 100 in a row |
These values are fixed; they cannot be changed. A delivery is therefore given up about 1 h 15 min after the first attempt. Deliveries leave in the order they are due; a webhook that never answers does not delay the others.
When 100 deliveries in a row have been given up, the gateway switches the webhook off, so it stops spending effort on an address that no longer answers. The count moves once per given-up delivery, not per attempt, and any success resets it to zero. A switched-off webhook:
- appears as Disabled after failures on the Webhooks page, and with
active: falseand adisabledAtdate through the API; - creates an entry in the Activity log, which says which webhook was switched off and after how many failures;
- receives nothing more, and its deliveries still waiting are dropped.
To restart it, fix your application, then Turn back on from the menu of its row on the
Webhooks page, or send
PATCH /webhooks/{id} with {"active": true}. The failure counter goes back to zero. Events that
happened while it was off are not sent: read them through the API if you need them.
The delivery log
Every delivery is logged: date, event, webhook, status (pending, success, failed), number of
attempts, HTTP code of your answer, duration, and the first 500 characters of your answer or the
network error. It is the first place to look when your application says it receives nothing.
- In the dashboard: Webhooks page, Delivery log tab, which can be filtered by webhook, status and event.
- Through the API:
GET /webhooks/deliveries, which can be filtered bywebhookId,statusandevent.
A delivery can be replayed (button in the log, or POST /webhooks/deliveries/{id}/replay): the
gateway sends the same body again as a new delivery, freshly signed. Replaying is refused on a
webhook that is paused or switched off.
Event catalogue
| Event | When it is sent | Fields filled in |
|---|---|---|
message.received |
An SMS was received by one of your phones | id (message), conversationId, deviceId |
message.sent |
A phone confirmed it sent a message | id (message), deviceId, status: sent |
message.delivered |
The carrier confirmed delivery to the recipient. Only when the carrier sends delivery receipts | id (message), status: delivered |
message.failed |
A message was given up after its last attempt. Read the message to get its errorCode |
id (message), status: failed |
device.online |
A phone that was silent is answering again | id and deviceId (phone), status: online |
device.offline |
A phone lost its connection, or has sent nothing for 5 minutes | id and deviceId (phone), status: offline |
device.low_battery |
A phone's battery went down to 15 % or below. Sent once when the threshold is crossed, not on every reading | id and deviceId (phone) |
campaign.completed |
Every recipient of a campaign reached a final state (sent, delivered, failed or withdrawn) | id (campaign), status: completed |
rule.triggered |
An automation rule matched an incoming SMS and ran its actions | id (rule), conversationId |
link.clicked |
A short link was opened | id (link) |
license.offline |
The gateway can no longer reach our licence server. Nothing is suspended: information only | id: the first four characters of the licence key |
license.reconnected |
Our licence server answers again | id: the first four characters of the licence key |
backup.completed |
A backup was written | id (backup), status: ok |
backup.failed |
A backup attempt produced nothing usable | id (backup), status: failed |
Every event also carries event and at.
Some useful combinations:
- Forward replies to a CRM:
message.received, thenGET /messages/{id}for the text and the number. - Delivery reports:
message.sent,message.delivered,message.failed, matched against theidreturned by yourPOST /messages. - Monitoring:
device.offline,device.low_battery,backup.failed, towards a chat channel or an alerting tool.
Rule actions that call a URL
An automation rule can also call an address when it fires (action "Call a webhook", see
Automation). That call is not a subscription: its address and secret
are set on the rule. It is signed in the same way, retried with the same table and appears in the
same delivery log, with the event rule.triggered. Its body is different:
{
"ruleId": "0192f0e1-...",
"ruleName": "Opening hours",
"conversationId": "0192f0c1-...",
"maskedPhone": "+261******67",
"occurredOn": "2026-09-21T09:14:03Z"
}
The sender's number only appears masked. A rule's call is never switched off automatically: it is the rule you disable if its address no longer answers.
Troubleshooting
| Symptom in the delivery log | Likely cause |
|---|---|
| No delivery at all | The webhook is paused or switched off, or does not listen to that event |
| Error "destination address is not allowed" | The URL points at the gateway's own machine or at a forbidden range. See Allowed destinations |
HTTP code 301, 302, 307, 308 |
Your address redirects (often http towards https, or a missing /). Use the final address |
HTTP code 401 or 403 |
Your application refuses the signature: wrong secret, body parsed before verification, or clock off by more than 5 minutes |
| Timeout | Your application takes more than 10 seconds. Answer first, then process |
Status failed after 5 attempts |
Your application was unreachable for more than an hour. Replay the delivery once it is fixed |
See also Using the API and Security.