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 endpointSee 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:
| Header | Value |
|---|---|
x-saperly-timestamp | Unix seconds when the event was signed. |
x-saperly-delivery-id | UUID v4 — unique per delivery; use it to dedup. |
x-saperly-signature | v1=<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.
Related
- 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.
Voice channels
Call a phone number and talk to your own Claude Code or openclaw agent — in its own context, with its tools and memory — over Saperly's manual mode. No audio ever reaches your machine.
Errors & idempotency
Saperly returns typed errors with stable codes and HTTP statuses, and every mutating endpoint accepts an Idempotency-Key so a retried request never duplicates an effect.