Gareth Wilson Gareth Wilson

Guide to Sinch Webhooks: Features and Best Practices

Published


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:

HeaderContents
x-sinch-webhook-signatureBase64 HMAC-SHA256 of the raw body, the nonce and the timestamp, joined with dots
x-sinch-webhook-signature-nonceA unique nonce
x-sinch-webhook-signature-timestampWhen the signature was computed, in UTC (Unix seconds in Sinch's example)
x-sinch-webhook-signature-algorithmHmacSHA256

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

FeatureDetails
ConfigurationSinch Portal (Conversation API apps) or the /webhooks management endpoint
Webhooks per appUp to 5
SubscriptionA list of triggers per webhook
MethodPOST, JSON body
Signature headerx-sinch-webhook-signature, base64, no prefix
SchemeHMAC-SHA256, base64 digest
Signed content{raw body}.{nonce}.{timestamp}
SecretSet by you on the webhook, used as UTF-8 text
Other authenticationOAuth2 bearer tokens, alone or alongside HMAC
PayloadCommon fields plus one callback-specific key
RetriesExponential backoff on network errors, timeouts, 5xx and 429
Not retriedOther 4xx responses, and any callback to a known test domain
Duplicates and orderBest-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:

TriggerCallback keyFires when
MESSAGE_INBOUNDmessageA contact sends a message to your app
MESSAGE_DELIVERYmessage_delivery_reportThe delivery status of a message you sent changes
MESSAGE_SUBMITmessage_submit_notificationA message you sent is submitted to a channel
EVENT_INBOUNDeventA contact sends an event, such as a composing indicator
EVENT_DELIVERYevent_delivery_reportThe delivery status of an event you sent changes
CONVERSATION_START, CONVERSATION_STOPconversation_start_notification, conversation_stop_notificationA conversation starts or stops
CONTACT_CREATE, CONTACT_UPDATE, CONTACT_DELETE, CONTACT_MERGEcontact_create_notification and so onA contact is created, updated, deleted or merged
OPT_IN, OPT_OUTopt_in_notification, opt_out_notificationA contact opts in or out
CAPABILITYcapability_notificationAn asynchronous capability check finishes
CHANNEL_EVENTchannel_eventA channel reports something about itself
UNSUPPORTEDunsupported_callbackA channel sends a callback the Conversation API doesn't map

The full list also includes CONVERSATION_DELETE, CONTACT_IDENTITIES_DUPLICATION, RECORD_NOTIFICATION, BATCH_STATUS_UPDATE, and the Smart Conversations triggers SMART_CONVERSATION and MESSAGE_INBOUND_SMART_CONVERSATION_REDACTION. 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_create_notification 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-signature header 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 5xx for problems on your side that a retry could fix, and reserve 4xx for requests you never want again.
  • Alert on a rise in 4xx responses 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.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.