saperly
Guides

Webhooks

Saperly delivers events — inbound SMS, call lifecycle, 10DLC status, delivery receipts — to your endpoint, signed with HMAC-SHA256; verify the signature on the raw body and dedup the delivery id before you trust a payload.

Saperly pushes events to your HTTPS endpoint: inbound SMS, call lifecycle, 10DLC status, and delivery receipts. Delivery is inline-first — the first attempt is made synchronously — then queued with retries and a dead-letter queue if the first attempt fails. Every delivery is signed; you must verify the signature before trusting the payload.

Base URL https://api.saperly.com. Authenticate with Authorization: Bearer sk_live_...; the workspace is resolved from the token.

Setting a webhook

Set a per-number webhook with POST /numbers/:id/webhook and { url }:

curl -X POST https://api.saperly.com/numbers/$NUMBER_ID/webhook \
  -H "Authorization: Bearer $SAPERLY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "url": "https://example.com/saperly/webhook" }'
await fetch(`https://api.saperly.com/numbers/${numberId}/webhook`, {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.SAPERLY_API_KEY!}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({ url: 'https://example.com/saperly/webhook' }),
})

The v1 SDK additionally exposes a default webhook (applied to numbers without a per-number override) plus inspection helpers:

await saperly.webhooks.deliveries() // recent delivery attempts
await saperly.webhooks.stats()      // delivery success / failure stats
await saperly.webhooks.test()       // send a test event to your endpoint

See Numbers for the number a webhook is bound to.

Signature verification

Always verify signatures and dedup delivery ids

Never trust an unverified payload. Verify the HMAC signature on the raw request body (before any JSON parsing) and dedup the delivery id for at least 5 minutes to block replays. A request that fails either check should be rejected.

Every delivery carries three headers:

HeaderValue
x-saperly-timestampUnix seconds when the event was signed.
x-saperly-delivery-idUUID v4 — unique per delivery; use it to dedup.
x-saperly-signaturev1=<hex> — HMAC-SHA256 of the signed payload.

The signature is an HMAC-SHA256, keyed by your webhook secret, computed over:

`${timestamp}.${delivery_id}.${rawBody}`

where timestamp and delivery_id are the header values and rawBody is the exact bytes of the request body. To verify, recompute the HMAC over the same string with your secret and compare it (in constant time) to the hex after v1=. Reject if the timestamp is too old (replay) or the delivery id has already been seen.

Verifying with the SDK

The Node SDK ships verifyWebhook(rawBody, secret, headers, options?), which returns { valid: boolean, reason?: string }. Python ships the equivalent. Verify on the raw body, reject when !valid, then parse and handle:

import { verifyWebhook } from '@saperly/sdk'

// Express: capture the RAW body, not parsed JSON
app.post(
  '/saperly/webhook',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const rawBody = req.body.toString('utf8') // a Buffer of the exact bytes

    const result = verifyWebhook(
      rawBody,
      process.env.SAPERLY_WEBHOOK_SECRET!,
      req.headers,
    )

    if (!result.valid) {
      // reason explains why: bad signature, stale timestamp, replayed id, …
      return res.status(400).send(`invalid webhook: ${result.reason}`)
    }

    const event = JSON.parse(rawBody)
    // … handle the verified event (dedup on x-saperly-delivery-id) …

    res.sendStatus(200)
  },
)

Your framework must hand you the raw body

The signature covers the exact bytes of the body. If your framework parses JSON before your handler runs, re-serializing it will not match the signature. Configure a raw-body reader for the webhook route (e.g. express.raw(...)), then JSON.parse only after verification passes.

Delivery, retries, and the DLQ

The first delivery attempt is inline. If it fails (a non-2xx response or a timeout), the event is queued and retried; deliveries that exhaust their retries land in a dead-letter queue. Return a 2xx quickly to acknowledge — do slow work asynchronously so you don't trip the timeout and trigger needless retries. Because retries can re-deliver an event, deduping on x-saperly-delivery-id is what keeps your handler idempotent.

  • Numbers — set the webhook bound to a number.
  • Messaging — inbound SMS arrives via webhook.
  • Voice — call lifecycle events arrive via webhook.
  • Compliance — 10DLC status updates arrive via webhook.
  • Errors & idempotency — making request handling idempotent.

On this page