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 sap_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' }),
})

A workspace-level default webhook (applied to numbers without a per-number override) plus delivery inspection, stats, and test sends are managed in the dashboard under Settings → Webhooks.

Events

Every delivery body is { deliveryId, eventType, payload }. The call events:

EventWhenPayload
call.receivedAn inbound call reached one of your numbers{ callId, connectionId, from, to }
call.completedA call connected and then ended{ callId, status: "completed", durationSec, costCents, from, to, hangupCause? }
call.failedA call never connected — the callee didn't answer, the carrier couldn't place it, or it was declined before answer{ callId, status: "no_answer" | "failed", durationSec: 0, costCents: 0, from, to, hangupCause? }
call.recording.savedA call's recording is ready to fetch{ callId, … }
message.receivedAn inbound SMS reached one of your numbers{ messageId, numberId, to, from, body }

Exactly one terminal event (call.completed or call.failed) is delivered per call, by whichever path finalizes it first — the carrier's hangup, or Saperly's own safety net for calls the carrier never reported back (those arrive hours after placement, a day at most). Only completed calls bill; costCents on a call.failed is always 0. A call refused before it was even attempted (insufficient balance) produces no terminal event — the API returned the refusal synchronously.

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}.${rawBody}`

where timestamp is the x-saperly-timestamp header value 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 outside the tolerance window (a replay), and dedup on x-saperly-delivery-id to drop any re-delivery.

Verifying with the SDK

The Node SDK ships verifyWebhook(rawBody, secret, headers, options?). It is async (it uses Web Crypto) and resolves { valid: boolean, reason?: string, deliveryId?: string, eventType?: string } — deliveryId and eventType are present on success, so you can dedup straight off the result. Verify on the raw body, reject when !valid, then parse and handle:

import { verifyWebhook } from '@trysaperly/sdk'

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

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

    if (!result.valid) {
      // reason explains why: signature_mismatch, stale_timestamp, …
      return res.status(400).send(`invalid webhook: ${result.reason}`)
    }

    // Dedup on result.deliveryId (== the x-saperly-delivery-id header) for at
    // least the tolerance window before trusting the event.
    const event = JSON.parse(rawBody)
    // … handle the verified event …

    res.sendStatus(200)
  },
)

On a Web-Request runtime (Workers, Deno, Bun, Next.js route handlers), read the raw body with await req.text() and pass req.headers straight through:

import { verifyWebhook } from '@trysaperly/sdk'

const raw = await req.text() // the EXACT bytes, not a re-serialized object
const result = await verifyWebhook(raw, process.env.SAPERLY_WEBHOOK_SECRET!, req.headers)
if (!result.valid) return new Response(`invalid: ${result.reason}`, { status: 400 })
// dedup result.deliveryId, then JSON.parse(raw) and handle …

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