Skip to main content

Webhooks

Let the gateway call your application when an SMS arrives, a status changes or a phone goes offline: subscriptions, options, signature verification, retries and the event catalogue.

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.received already 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 example GET /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-Event and Content-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:

  1. 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.
  2. Compute the HMAC-SHA256 with your secret, in hexadecimal, and prefix it with sha256=.
  3. Compare it with X-Signature using a constant-time comparison.
  4. Refuse a timestamp more than 5 minutes away from your clock, in either direction.
  5. 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: false and a disabledAt date 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 by webhookId, status and event.

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, then GET /messages/{id} for the text and the number.
  • Delivery reports: message.sent, message.delivered, message.failed, matched against the id returned by your POST /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.

Search the documentation

Type a few words, then pick a page.