Guide to Sinch Webhooks: Features and Best Practices
Sinch is a communications platform, and its Conversation API puts SMS, WhatsApp, RCS, Messenger, Viber and other channels behind one messaging API. Webhooks, which Sinch calls callbacks, are how that API tells your application that a customer has replied, that a message you sent was delivered or read, or that a contact has opted out.
This guide covers how Conversation API webhooks are created and signed, the callback types you will receive, how Sinch decides whether to retry, and the practices that keep a Sinch receiver reliable. It applies to the Conversation API only. Sinch's SMS, Voice, Numbers and Verification APIs send callbacks of their own and authenticate them differently.
What are Sinch webhooks?
A Conversation API webhook belongs to one app. It has a target URL, a list of triggers that decide which callbacks it receives, and optionally a secret and OAuth2 settings. Each app can have up to five webhooks, so you can send different triggers to different URLs.
When a subscribed event happens, Sinch sends a JSON POST to the target. If the webhook has a secret, the request carries four extra headers:
| Header | Contents |
|---|---|
x-sinch-webhook-signature | Base64 HMAC-SHA256 of the raw body, the nonce and the timestamp, joined with dots |
x-sinch-webhook-signature-nonce | A unique nonce |
x-sinch-webhook-signature-timestamp | When the signature was computed, in UTC (Unix seconds in Sinch's example) |
x-sinch-webhook-signature-algorithm | HmacSHA256 |
The secret is optional. "The callbacks are signed with the webhook secret if such is provided", so a webhook created without one sends unsigned requests.
Sinch webhook features
| Feature | Details |
|---|---|
| Configuration | Sinch Portal (Conversation API apps) or the /webhooks management endpoint |
| Webhooks per app | Up to 5 |
| Subscription | A list of triggers per webhook |
| Method | POST, JSON body |
| Signature header | x-sinch-webhook-signature, base64, no prefix |
| Scheme | HMAC-SHA256, base64 digest |
| Signed content | {raw body}.{nonce}.{timestamp} |
| Secret | Set by you on the webhook, used as UTF-8 text |
| Other authentication | OAuth2 bearer tokens, alone or alongside HMAC |
| Payload | Common fields plus one callback-specific key |
| Retries | Exponential backoff on network errors, timeouts, 5xx and 429 |
| Not retried | Other 4xx responses, and any callback to a known test domain |
| Duplicates and order | Best-effort deduplication; order not guaranteed |
Sinch doesn't publish the number of retries or the maximum retry period, only that it retries "a reasonable number of times".
Common events
Each webhook subscribes to triggers. These are the main ones:
| Trigger | Callback key | Fires when |
|---|---|---|
MESSAGE_INBOUND | message | A contact sends a message to your app |
MESSAGE_DELIVERY | message_delivery_report | The delivery status of a message you sent changes |
MESSAGE_SUBMIT | message_ | A message you sent is submitted to a channel |
EVENT_INBOUND | event | A contact sends an event, such as a composing indicator |
EVENT_DELIVERY | event_delivery_report | The delivery status of an event you sent changes |
CONVERSATION_START, CONVERSATION_STOP | conversation_, conversation_ | A conversation starts or stops |
CONTACT_CREATE, CONTACT_UPDATE, CONTACT_DELETE, CONTACT_MERGE | contact_ and so on | A contact is created, updated, deleted or merged |
OPT_IN, OPT_OUT | opt_in_notification, opt_out_notification | A contact opts in or out |
CAPABILITY | capability_notification | An asynchronous capability check finishes |
CHANNEL_EVENT | channel_event | A channel reports something about itself |
UNSUPPORTED | unsupported_callback | A channel sends a callback the Conversation API doesn't map |
The full list also includes CONVERSATION_DELETE, CONTACT_, RECORD_NOTIFICATION, BATCH_STATUS_UPDATE, and the Smart Conversations triggers SMART_CONVERSATION and MESSAGE_. Don't use UNSPECIFIED_TRIGGER, which causes errors.
There is no type field. Every callback shares a few top-level fields (project_id, app_id, accepted_time, event_time, message_metadata, correlation_id) and adds one key that holds the details. The key tells you what kind of callback it is. Here is Sinch's example of a delivery receipt:
{
"app_id": "01EB37HMH1M6SV18BSNS3G135H",
"accepted_time": "2020-11-17T15:09:11.659Z",
"event_time": "2020-11-17T15:09:13.267185Z",
"project_id": "c36f3d3d-1513-2edd-ae42-11995557ff61",
"message_delivery_report": {
"message_id": "01EQBC1A3BEK731GY4YXEN0C2R",
"conversation_id": "01EPYATA64TMNZ1FV02JKF12JF",
"status": "QUEUED_ON_CHANNEL",
"channel_identity": {
"channel": "MESSENGER",
"identity": "2734085512340733",
"app_id": "01EB27HMH1M6SV18ASNS3G135H"
},
"contact_id": "01EXA07N79THJ20WSN6AS30TMW",
"metadata": "",
"processing_mode": "CONVERSATION"
}
}
A delivery report's status moves from QUEUED_ON_CHANNEL to DELIVERED, READ or FAILED, with SWITCHING_CHANNEL when delivery failed on one channel and Sinch is trying the next. READ and FAILED are terminal. Some channels skip DELIVERED when a message is read straight away.
Setting up Sinch webhooks
In the Sinch Portal, open your Conversation API app and add a webhook. Enter the target URL, choose the triggers and set a secret. Store the same secret server-side, for example as SINCH_WEBHOOK_SECRET.
To create a webhook with the API, call the management endpoint for your app's region. This example uses the EU region:
curl -X POST \
'https://eu.conversation.api.sinch.com/v1/projects/{PROJECT_ID}/webhooks' \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer {ACCESS_TOKEN}' \
-d '{
"app_id": "{APP_ID}",
"target": "https://example.com/webhooks/sinch",
"target_type": "HTTP",
"triggers": ["MESSAGE_INBOUND", "MESSAGE_DELIVERY"],
"secret": "{YOUR_WEBHOOK_SECRET}"
}'
Without secret the callbacks are not signed, so include it.
If you use Hookdeck, create a source with the Sinch source type and use the Hookdeck source URL as the webhook target. Hookdeck's setup notes for this source say: "For Sinch Conversation API callbacks. Set a secret on the Conversation API webhook and paste it here; other Sinch APIs sign callbacks differently." The source accepts POST only, which is the method Sinch uses.
Securing Sinch webhooks
To verify a callback, read the signature, nonce and timestamp headers. Build the signed content from the raw request body, a ., the nonce, another . and the timestamp. Compute an HMAC-SHA256 with the secret as the key, encode it as base64 and compare it with x-sinch-webhook-signature in constant time.
The body comes first, which is the reverse of most timestamped schemes. Sinch is also strict about using the raw body: verification "must be performed before any JSON parsing, deserialization, normalization, pretty-printing, whitespace modification, or other processing".
Sinch publishes a test vector you can use in your own tests: secret foo_secret1234, nonce 01FJA8B4A7BM43YGWSG9GBV067 and timestamp 1634579353 give 6bpJoRmFoXVjfJIVglMoJzYXxnoxRujzR4k2GOXewOE= for the contact_ body in Sinch's callback docs.
The timestamp check in the code below is optional and off by default.
const crypto = require('crypto');
const express = require('express');
function verifySinchSignature(rawBody, nonce, timestamp, signature, secret, toleranceSeconds = 0) {
if (!nonce || !timestamp || !signature || !secret) return false;
// A SHA-256 digest is 44 characters of padded base64.
if (!/^[A-Za-z0-9+/]{43}=$/.test(signature)) return false;
// Optional replay check, off by default.
if (toleranceSeconds > 0) {
if (!/^\d+$/.test(timestamp)) return false;
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > toleranceSeconds) return false;
}
// Body first, then nonce, then timestamp, joined with dots.
const expected = crypto
.createHmac('sha256', secret)
.update(rawBody)
.update('.')
.update(nonce, 'latin1')
.update('.')
.update(timestamp, 'latin1')
.digest('base64');
const received = Buffer.from(signature, 'utf8');
const computed = Buffer.from(expected, 'utf8');
return received.length === computed.length && crypto.timingSafeEqual(received, computed);
}
const app = express();
app.post('/webhooks/sinch', express.raw({ type: '*/*' }), (req, res) => {
const ok = verifySinchSignature(
req.body,
req.get('x-sinch-webhook-signature-nonce'),
req.get('x-sinch-webhook-signature-timestamp'),
req.get('x-sinch-webhook-signature'),
process.env.SINCH_WEBHOOK_SECRET
);
if (!ok) return res.sendStatus(401);
const callback = JSON.parse(req.body.toString('utf8'));
// Dispatch on the key that is present, e.g. callback.message_delivery_report.
res.sendStatus(200);
});
The same check in Python:
import base64
import hashlib
import hmac
import re
import time
BASE64_SHA256 = re.compile(r"[A-Za-z0-9+/]{43}=")
def verify_sinch_signature(raw_body: bytes, nonce, timestamp, signature, secret: str,
tolerance_seconds: int = 0) -> bool:
if not nonce or not timestamp or not signature or not secret:
return False
if not BASE64_SHA256.fullmatch(signature):
return False
# Optional replay check, off by default.
if tolerance_seconds > 0:
if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > tolerance_seconds:
return False
signed = raw_body + b"." + nonce.encode("latin-1") + b"." + timestamp.encode("latin-1")
digest = hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).digest()
expected = base64.b64encode(digest)
return hmac.compare_digest(signature.encode("utf-8"), expected)
# In FastAPI: raw_body = await request.body()
# verify_sinch_signature(raw_body, request.headers.get("x-sinch-webhook-signature-nonce"),
# request.headers.get("x-sinch-webhook-signature-timestamp"),
# request.headers.get("x-sinch-webhook-signature"),
# os.environ["SINCH_WEBHOOK_SECRET"])
Hookdeck's Sinch source type checks the same signature: HMAC-SHA256 over {body}.{nonce}.{timestamp}, base64, keyed with the secret. It applies no timestamp tolerance. A request that fails the check is marked as failed verification and is not delivered to your destinations.
Make Sinch webhooks production-ready. Hookdeck Event Gateway verifies the
x-sinch-webhook-signatureheader on every callback, stores each inbound message and delivery report, and delivers it to your service with retries you control.
Sinch webhook limitations and pain points
A 4xx ends delivery for good
The Problem: Callbacks rejected during a bad deploy are lost, even though your service recovered minutes later.
Why It Happens: Sinch retries only network errors, timeouts, 5xx and 429. A 400, 401, 403 or 404 from your endpoint is "treated as permanent failure". A handler that answers 401 because of a misconfigured secret, or 404 because a route moved, drops every callback in that window.
Workarounds:
- Return
5xxfor problems on your side that a retry could fix, and reserve4xxfor requests you never want again. - Alert on a rise in
4xxresponses from the webhook route. - After an incident, reconcile message state with the Conversation API.
How Hookdeck Can Help: Hookdeck accepts each callback as it arrives and stores it, so Sinch sees a success. If your service then fails, the callback can be retried on your schedule or replayed after a fix.
Test domains are never retried
The Problem: Callbacks go missing during development, and the same code works in production.
Why It Happens: Sinch skips retries entirely for known test and sandbox domains, including webhook.site, collect2.com and the ngrok domains. A single failed delivery to a tunnel is the end of that callback.
Workarounds:
- Expect gaps when testing against those domains, and don't treat them as a measure of reliability.
- Use a stable HTTPS endpoint for anything you need to receive in full.
How Hookdeck Can Help: The Hookdeck CLI gives you a public URL that forwards to your local server, and Hookdeck keeps each callback so you can replay it while you debug.
Duplicates and out-of-order receipts
The Problem: A message shows as DELIVERED after READ, or an inbound message is handled twice.
Why It Happens: Sinch's deduplication is best-effort, and it "attempts to preserve callback order; however, because of retries and network conditions, exact ordering is not guaranteed".
Workarounds:
- Deduplicate on the message or event ID together with the status, or on the inbound message
id. - Model delivery status as a state machine, as Sinch recommends, and never move a message back from a terminal state.
How Hookdeck Can Help: Hookdeck deduplication can drop repeated callbacks on fields you choose before they reach your service.
Signing is optional, and per webhook
The Problem: One webhook verifies and another fails every check.
Why It Happens: Each webhook has its own secret, and a webhook created without one sends no signature headers at all. With up to five webhooks per app, it is easy for one to be missing its secret or to have a different one.
Workarounds:
- Set a secret on every webhook, and keep a record of which secret belongs to which target.
- Treat a callback with no signature header as a failure, not as a request to accept.
How Hookdeck Can Help: A separate Hookdeck source for each webhook holds that webhook's secret, and a callback with no signature fails verification instead of being delivered.
Best practices
Verify against the raw body
The signature covers the exact bytes Sinch sent, and Sinch notes that it "is sensitive to white spaces". In Express, use express.raw({ type: '*/*' }) on the webhook route. In a Next.js route handler, use Buffer.from(await request.arrayBuffer()). In FastAPI, read await request.body(). Parse JSON only after verification passes.
Acknowledge fast and process asynchronously
Sinch buffers callbacks during slowdowns, but if your endpoint can't keep up, "the buffer may overflow, leading to discarded callbacks". Verify, persist the callback, return 200, and process afterwards. Sinch's throughput guidance asks you to size the endpoint for peak traffic, since one sent message can produce several delivery reports. See why to process webhooks asynchronously.
Make processing idempotent
Sinch's docs say plainly that "API implementations must be idempotent to handle potential duplicate callbacks". Store a key for each processed callback and skip repeats. See our guide to webhook idempotency.
Ignore unknown fields
Sinch adds optional fields to callbacks without a new API version. Parse what you need and ignore the rest, rather than validating against a strict schema.
Consider OAuth2 as well
Sinch recommends OAuth2 for production: it fetches a short-lived token from an endpoint you control and sends it as a bearer token. It can run alongside HMAC, so you can check both.
Conclusion
Sinch signs Conversation API callbacks only when a webhook has a secret, with a base64 HMAC-SHA256 of the raw body, the nonce and the timestamp in that order. Set a secret on every webhook, verify against the raw bytes, and be careful with 4xx responses, because Sinch never retries them.
Hookdeck Event Gateway verifies every Sinch signature and holds each callback until your service has processed it, so a bad deploy doesn't cost you inbound messages or delivery reports.
Get started with Hookdeck for free and handle Sinch webhooks reliably in minutes.