Webhook Mode

Webhook mode gives you the easiest path to “our backend decides what the caller hears.”

Saperly handles telephony, transcription, and speech output. Your server handles the decision.

Webhook mode costs 0.13/min,billedpersecondforZoneA(US/Canada).Thatshalfthecostofhostedmode(0.13/min, billed per second for Zone A (US/Canada). That's half the cost of hosted mode (0.13 vs $0.26) because you provide the AI. International destinations use Zone B (×2) and Zone C (×3) — see Voice zones.
Compliance is opt-in. Saperly does NOT verify consent or play AI disclosures unless you set compliance_enabled: true on the line. With the flag off, TCPA, GDPR, and local telecom compliance are the customer’s responsibility. See Compliance and Consent for the full liability framing and how to opt in.

Create a webhook line

$curl -X POST https://saperly.com/api/v1/lines \
> -H "Authorization: Bearer sk_live_..." \
> -H "Content-Type: application/json" \
> -d '{
> "name": "Support webhook line",
> "mode": "webhook",
> "webhook_url": "https://your-server.com/saperly-webhook",
> "status_callback_url": "https://your-server.com/saperly-status",
> "recording_enabled": true
> }'

Event flow

caller speaks
-> Saperly transcribes
-> Saperly POSTs a webhook event (signed)
-> your app verifies the signature, then returns JSON
-> Saperly speaks the returned text

Signature verification

Every outbound Saperly webhook is signed with HMAC-SHA256 so your receiver can confirm the request came from Saperly and has not been tampered with or replayed.

Headers sent with every webhook

HeaderValue
x-saperly-timestampUnix seconds when the delivery was signed
x-saperly-delivery-idUUID v4, unique per delivery attempt
x-saperly-signaturev1=<hex> — HMAC-SHA256 over ${timestamp}.${delivery_id}.${rawBody} keyed by the line’s signing secret

The payload body also includes "line_id": "<uuid>" so a single webhook endpoint can serve multiple lines and look up the correct signing secret per request.

Verify with the TypeScript SDK

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

Verify with the Python SDK

1from saperly.webhooks_verify import verify_webhook
2
3@app.post("/saperly-webhook")
4def handler(request):
5 raw_body = request.body.decode("utf-8")
6 line_id = json.loads(raw_body)["line_id"]
7 secret = lookup_secret_for_line(line_id) # your secret store
8 result = verify_webhook(raw_body, secret, dict(request.headers))
9 if not result.valid:
10 return Response(json.dumps({"error": result.reason}), status=401)
11 # ... handle the event

Replay defense

The signature alone does not defeat replay attacks — an attacker who captured a valid request once could replay it later. To close that gap your receiver MUST:

  1. Reject any request whose x-saperly-timestamp is more than 5 minutes away from your server’s current time. The SDK helpers do this by default via clockToleranceSec / clock_tolerance_seconds.
  2. Cache every x-saperly-delivery-id you accept for at least 5 minutes and reject duplicates. The SDK helpers cannot do this for you because the cache is receiver-side state.

Rotating the signing secret

Your line’s signing secret lives server-side and is minted during line creation. To rotate it (for example, after a suspected leak):

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

Rotation is a two-call flow:

  1. First call stages a new secret into webhook_secret_next. The current secret keeps signing traffic so existing receivers keep verifying. Deploy the returned secret to your receivers as a second acceptable secret.
  2. Second call ≥24 hours later promotes next → current (new traffic signs with the new secret) and mints a fresh next. You have a full day to roll the new secret out to every receiver before signed traffic actually changes.

Response:

1{
2 "webhook_secret": "<64 hex chars>",
3 "rotated_at": "2026-04-25T12:00:00.000Z",
4 "promoted_previous_next": false
5}

promoted_previous_next is true when the prior next secret was aged >24h and got promoted to current as part of this call. The secret is returned exactly once — there is no read endpoint. Losing the returned value means rotating again.

Event types

Events sent to your webhook_url

EventWhen it firesYour action
call_startedCall connected (inbound or outbound, webhook mode)Log it, set up context
messageCaller finished a sentenceReturn JSON with text to speak back
call_endedCall terminatedClean up, log final state
sms_receivedInbound SMS arrivedProcess and optionally reply

Events sent to your status_callback_url

EventWhen it firesPayload includes
call_incomingInbound call receivedcall_id, from_number, relay_url
call_outgoingOutbound call initiatedcall_id, to_number, relay_url

Main event you need to handle

1{
2 "event": "message",
3 "call_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
4 "timestamp": 1711900005000,
5 "text": "hi, i need help with my order",
6 "context": [
7 {
8 "role": "caller",
9 "text": "hello?",
10 "timestamp": 1711899995000
11 }
12 ]
13}

Minimal valid response

1{
2 "text": "Sure, what is your order number?"
3}

Response rules that matter

  • return JSON
  • return quickly
  • keep the first response short
  • handle non-message events without trying to speak back

If your server is slow or broken, the caller feels it immediately. This is the whole game.

Minimal handler

1import express from "express";
2
3const app = express();
4app.use(express.json());
5
6app.post("/saperly-webhook", async (req, res) => {
7 const event = req.body;
8
9 if (event.event !== "message") {
10 return res.status(200).end();
11 }
12
13 return res.json({
14 text: `You said: ${event.text}`,
15 });
16});
17
18app.listen(3000);

Handle errors gracefully

Saperly delivers each webhook once. There are no retries. Timeouts are 5 seconds for SMS and call lifecycle events, 10 seconds for message events. If your server returns non-200 or times out, the delivery is marked failed in the webhook log and the caller hears silence.
For message events, return a streaming NDJSON response to get a longer budget: 15 seconds per line and 5 minutes total.

Inspect failures:

$GET /api/v1/webhooks/deliveries?status=failed

Streaming responses (NDJSON)

For lower perceived latency, return streaming NDJSON instead of a single JSON object. Each line is a complete JSON object:

1{"text": "Let me "}
2{"text": "check that for you."}
3{"done": true}

The caller hears each chunk as it arrives, reducing time to first word.

Security practices

  • Validate the call_id exists by checking your records or GET /api/v1/calls/{id}
  • Use HTTPS exclusively for your webhook URL
  • Return responses within 5 seconds (call lifecycle and SMS) or 10 seconds (message events)

Status callbacks

If you set status_callback_url, Saperly POSTs lifecycle events (call_incoming for inbound, call_outgoing for outbound) to that URL separately from the main webhook. See the event table above for payload shape. Use this for logging and analytics without mixing it into your response logic.

Read Audio Mode if you need raw audio. The Fern docs API reference still covers the underlying line and call endpoints.

What to log on day one

Log these for every call:

  • event type
  • call ID
  • caller number
  • line ID when present
  • model latency on your side
  • final text returned

This saves hours when the first real user says “the bot sounded weird.”