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-signatureheader? - How do I handle
message,message_delivery_reportorcontact_callbacks?create_ notification - 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_ 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.
| 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_ | A message is submitted to the channel |
event / event_delivery_report | An event is received / its delivery status changes |
conversation_ / conversation_ | A conversation starts or stops |
contact_ | A contact is created |
contact_ / contact_ / contact_ | 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_ | Batch send status changes |
duplicated_ | 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
# 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
- references/overview.md - Callback structure, types, delivery behaviour
- references/setup.md - Create the webhook and secret
- references/verification.md - Signature verification details and gotchas