Agent skill

Sinch Webhooks Skill

Receive and verify Sinch Conversation API callbacks (omnichannel messaging: SMS, WhatsApp, RCS, Messenger, Viber). Use when setting up Sinch webhook handlers, debugging x-sinch-webhook-signature verification, or handling message, message_delivery_report and contact_create_notification callbacks.

Install this skill

npx skills add hookdeck/webhook-skills --skill 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.

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).

For complete handlers with tests, see examples/express/, examples/nextjs/, 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.

KeyTriggered When
messageA contact sends a message to your app
message_delivery_reportDelivery status of a message you sent changes (e.g. QUEUED_ON_CHANNEL)
message_submit_notificationA message is submitted to the channel
event / event_delivery_reportAn event is received / its delivery status changes
conversation_start_notification / conversation_stop_notificationA conversation starts or stops
contact_create_notificationA contact is created
contact_update_notification / contact_delete_notification / contact_merge_notificationA contact is updated, deleted or merged
opt_in_notification / opt_out_notificationA contact opts in or out
capability_notificationChannel capability lookup result
batch_status_update_notificationBatch send status changes
duplicated_contact_identities_notificationDuplicate contact identities detected
unsupported_callbackA 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

# 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

# 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


Repository

hookdeck/webhook-skills

v0.1.0 · MIT · Updated Nov 10, 2026

View on GitHub →