For AI agents: a documentation index is available at the root level at /llms.txt and /llms-full.txt. Append /llms.txt to any URL for a page-level index, or .md for the markdown version of any page.
WebsiteDashboardGet API key
  • Get Started
    • Welcome
    • Quickstart
    • Agent onboarding
    • Service keys
    • Core Concepts
  • Guides
    • Hosted Mode
    • Webhook Mode
    • Audio Mode
    • SMS and Conversations
    • Compliance and Consent
    • Billing and Usage
    • Voice zones
    • SMS zones
  • Reference
    • Authentication
    • API Overview
    • Errors and Testing
  • API Reference
  • Changelog
    • Cloudflare Insights CSP
    • Agent-native key management
    • Postpaid auto-charge
    • Cents-honest pricing
    • Stripe payments foundation
    • Hosted/webhook mode rename
    • Upgrading to v0.5.7.0
LogoLogo
WebsiteDashboardGet API key
On this page
  • What changed
  • 1. Accept line_id in your request-body schema
  • 2. Switch multi-tenant receivers to line-keyed secret lookup
  • 3. Call verifyWebhook before trusting the body
  • Testing your migration
  • Rollback
Changelog

Hosted/webhook mode rename

2026-04-20 · v0.3.0.0 · breaking

Was this page helpful?
Previous

Upgrading to v0.5.7.0

Service keys + /v1/keys lifecycle endpoints

Next
Built with

v0.3.0.0 signs every outbound Saperly webhook with HMAC-SHA256 and adds line_id to every payload body. Receivers that ignore unknown fields and headers keep working. Receivers with strict-schema validation need three small changes.

What changed

  • Three new request headers on every outbound callback:
    • x-saperly-timestamp — unix seconds
    • x-saperly-delivery-id — UUID v4
    • x-saperly-signature — v1=<hex> HMAC-SHA256 over ${timestamp}.${delivery_id}.${rawBody}
  • line_id: "<uuid>" in every payload body.
  • New endpoint POST /v1/lines/:id/webhook-secret/rotate for two-phase secret rotation.

1. Accept line_id in your request-body schema

Strict schemas (Zod, Pydantic, etc.) reject unknown fields. Add the field.

1const EventSchema = z.object({
2 event: z.string(),
3 call_id: z.string().uuid().optional(),
4 line_id: z.string().uuid(), // ← add
5 timestamp: z.number(),
6 // ... your other fields
7});

2. Switch multi-tenant receivers to line-keyed secret lookup

Single-secret receivers keep working — just set the env var to the line’s current secret. Receivers that handle more than one line need a lookup keyed on line_id.

1// before — single secret for every line
2const SAPERLY_WEBHOOK_SECRET = process.env.SAPERLY_WEBHOOK_SECRET!;
3
4app.post("/saperly-webhook", (req, res) => {
5 verify(rawBody, SAPERLY_WEBHOOK_SECRET, req.headers);
6 // ...
7});
1// after — look up the secret for the line that produced the event
2async function lookupSecretForLine(lineId: string): Promise<string> {
3 // your own store — database row, secrets manager, etc.
4 const row = await db.query("select webhook_secret from lines where saperly_line_id = $1", [lineId]);
5 return row.webhook_secret;
6}
7
8app.post("/saperly-webhook", async (req, res) => {
9 const rawBody = req.body.toString("utf8");
10 const { line_id } = JSON.parse(rawBody);
11 const secret = await lookupSecretForLine(line_id);
12 // ...
13});

3. Call verifyWebhook before trusting the body

Use the SDK helper. The signing primitives are inlined into the SDK, so there is no separate package to install.

1import express from "express";
2import { verifyWebhook } from "@saperly/sdk";
3
4const app = express();
5app.use(express.raw({ type: "application/json" }));
6
7app.post("/saperly-webhook", async (req, res) => {
8 const rawBody = req.body.toString("utf8");
9 const { line_id } = JSON.parse(rawBody);
10 const secret = await lookupSecretForLine(line_id);
11 const result = verifyWebhook(rawBody, secret, req.headers);
12 if (!result.valid) return res.status(401).json({ error: result.reason });
13 // trusted — handle the event
14});

The helpers already enforce the 5-minute clock skew window. You still need to cache x-saperly-delivery-id for 5 minutes and reject duplicates — that cache is receiver-side state.

Testing your migration

  1. Rotate a fresh secret so you know which value is live.

    $curl -X POST https://saperly.com/api/v1/lines/$LINE_ID/webhook-secret/rotate \
    > -H "Authorization: Bearer sk_live_..."

    Capture the returned webhook_secret — it is returned exactly once.

  2. Deploy your new receiver with that secret wired into lookupSecretForLine.

  3. Send a test delivery. The body is a signed test event.

    $curl -X POST https://saperly.com/api/v1/webhooks/test \
    > -H "Authorization: Bearer sk_live_..." \
    > -H "Content-Type: application/json" \
    > -d '{"line_id": "'$LINE_ID'"}'
  4. Confirm the delivery record shows status: success and HTTP 200.

    $curl "https://saperly.com/api/v1/webhooks/deliveries?line_id=$LINE_ID&limit=1" \
    > -H "Authorization: Bearer sk_live_..."

If valid is false, check the reason — common failures are clock_skew (your server clock is off), bad_signature (wrong secret), or missing_header (reverse proxy is stripping the x-saperly-* headers).

Rollback

If your receiver can’t be updated in time, there is no app-side rollback — signed traffic is on. Your options:

  • Drop a thin reverse proxy in front of your receiver that verifies the signature and strips the new headers/field before forwarding.
  • Leave strict-schema validation permissive for a window and skip verification, accepting the security risk.

Prefer the proxy.