# Sinch Webhooks

## When to Use This Skill

* How do I receive Sinch Conversation API webhooks (callbacks)?
* How do I verify the Sinch `x-sinch-webhook-signature` header?
* How do I handle `message`, `message_delivery_report` or `contact_create_notification` callbacks?
* Why is my Sinch webhook signature verification failing?

> For Sinch Conversation API callbacks. Set a secret on the Conversation API webhook and paste it here; other Sinch APIs sign callbacks differently.

This skill covers **Conversation API callbacks only**. Sinch's SMS, Voice, Numbers and Verification APIs are other products with other signing schemes.

## Verification (core)

Callbacks are signed only when the webhook has a `secret`. Three headers matter: `x-sinch-webhook-signature` (bare padded base64, no prefix), `x-sinch-webhook-signature-nonce` and `x-sinch-webhook-signature-timestamp` (Unix seconds). The signature is **HMAC-SHA256**, keyed with the secret's UTF-8 bytes (used as-is), over `rawBody + "." + nonce + "." + timestamp`, base64 encoded.

```javascript
const crypto = require('crypto');

function verifySinchSignature(rawBody, { signature, nonce, timestamp }, secret) {
  if (!signature || !nonce || !timestamp || !secret) return false;
  if (!/^[A-Za-z0-9+/]{43}=$/.test(signature)) return false; // padded base64 of 32 bytes

  const signed = Buffer.concat([ // bytes, never a re-encoded string
    rawBody, Buffer.from('.'), Buffer.from(nonce, 'latin1'),
    Buffer.from('.'), Buffer.from(timestamp, 'latin1'),
  ]);
  const expected = crypto.createHmac('sha256', secret).update(signed).digest('base64');
  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
}

```

Respond **200** for a valid delivery and **401** for a missing, malformed or mismatched signature. Read the raw body first (`express.raw({ type: '*/*' })`, `await request.arrayBuffer()`, `await request.body()`) and parse JSON only after the check.

**Timestamp window is off by default.** Hookdeck retries and replays resend the original headers and timestamp (and Hookdeck applies no tolerance itself), so a fixed window would reject legitimate retries. The examples offer an opt-in: set `SINCH_WEBHOOK_TOLERANCE_SECONDS=300` to enforce one (unset or `0` = off). Use idempotency instead (see below).

Published test vector from Sinch's docs: secret `foo_secret1234`, nonce `01FJA8B4A7BM43YGWSG9GBV067`, timestamp `1634579353` gives `6bpJoRmFoXVjfJIVglMoJzYXxnoxRujzR4k2GOXewOE=`. Every example's tests include it (body in [references/verification.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/sinch-webhooks/references/verification.md)).

> **For complete handlers with tests**, see [examples/express/](https://github.com/hookdeck/webhook-skills/tree/main/skills/sinch-webhooks/examples/express/), [examples/nextjs/](https://github.com/hookdeck/webhook-skills/tree/main/skills/sinch-webhooks/examples/nextjs/), [examples/fastapi/](https://github.com/hookdeck/webhook-skills/tree/main/skills/sinch-webhooks/examples/fastapi/).

## Common Callback Types

Callbacks have **no `type` field**. Each has common fields (`project_id`, `app_id`, `accepted_time`, `event_time`, `message_metadata`, `correlation_id`) plus one callback-specific top-level object whose **key is the type**. Dispatch on which key is present.

| Key | Triggered When |
| --- | --- |
| `message` | A contact sends a message to your app |
| `message_delivery_report` | Delivery status of a message you sent changes (e.g. `QUEUED_ON_CHANNEL`) |
| `message_submit_notification` | A message is submitted to the channel |
| `event` / `event_delivery_report` | An event is received / its delivery status changes |
| `conversation_start_notification` / `conversation_stop_notification` | A conversation starts or stops |
| `contact_create_notification` | A contact is created |
| `contact_update_notification` / `contact_delete_notification` / `contact_merge_notification` | A contact is updated, deleted or merged |
| `opt_in_notification` / `opt_out_notification` | A contact opts in or out |
| `capability_notification` | Channel capability lookup result |
| `batch_status_update_notification` | Batch send status changes |
| `duplicated_contact_identities_notification` | Duplicate contact identities detected |
| `unsupported_callback` | A channel callback Sinch can't map |

Sinch lists 19 callback types in all; the examples handle these 17 documented sample payloads and log any unrecognised key. Delivery: 2xx is success; network errors, timeouts, 5xx and 429 are retried with exponential backoff; other 4xx are not. Dedup is best-effort, so duplicates are possible and order is not guaranteed.

## Environment Variables

```bash
# Secret set on the Conversation API webhook (used as UTF-8)
SINCH_WEBHOOK_SECRET=your_sinch_webhook_secret
# Optional: reject callbacks older than N seconds (unset or 0 = off)
SINCH_WEBHOOK_TOLERANCE_SECONDS=0

```

## Local Development

```bash
# Start tunnel (no account needed)
npx hookdeck-cli listen 3000 sinch --path /webhooks/sinch

```

Use the printed public HTTPS URL as the webhook `target`. Hookdeck's Sinch source (type `SINCH`) verifies the same signature; paste the webhook secret into it. Hookdeck's setup note for the source: "For Sinch Conversation API callbacks. Set a secret on the Conversation API webhook and paste it here; other Sinch APIs sign callbacks differently."

## Reference Materials

* [references/overview.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/sinch-webhooks/references/overview.md) - Callback structure, types, delivery behaviour
* [references/setup.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/sinch-webhooks/references/setup.md) - Create the webhook and secret
* [references/verification.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/sinch-webhooks/references/verification.md) - Signature verification details and gotchas