# 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_submit_notification` | 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_start_notification`, `conversation_stop_notification` | A conversation starts or stops |
| `CONTACT_CREATE`, `CONTACT_UPDATE`, `CONTACT_DELETE`, `CONTACT_MERGE` | `contact_create_notification` 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_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:

```json
{
  "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:

```shell
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.

```javascript
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:

```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](/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](/docs/retries) 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](/docs/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](/docs/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](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

### 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](/webhooks/guides/implement-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](https://hookdeck.com) 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](https://dashboard.hookdeck.com/signup) for free and handle Sinch webhooks reliably in minutes.