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 (changes on every retry)
x-saperly-signaturev1=<hex> — HMAC-SHA256 over ${timestamp}.${delivery_id}.${rawBody} keyed by the line’s signing secret
x-saperly-delivery-attempt1-based attempt number for this delivery: 1 on the first send, 2, 3, … on retries. (Observability only.)

The payload body carries two fields you’ll use:

  • "line_id": "<uuid>" — so a single webhook endpoint can serve multiple lines and look up the correct signing secret per request.
  • "event_id": "<uuid>" — the logical event id, stable across retries. It lives inside the signed body, so once you’ve verified the signature you can trust it as your idempotency key. See Idempotent processing.

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.

Idempotent processing

Saperly retries failed deliveries (5xx / timeout / network error) on a backoff schedule, so your endpoint can legitimately receive the same event more than once. A retry carries a new x-saperly-delivery-id — so the replay cache above won’t suppress it — but the same body event_id.

After verifying the signature, dedupe your processing on the body’s event_id: record the event ids you’ve acted on and make the handler a no-op when you see one again. x-saperly-delivery-attempt (1-based) tells you which attempt you’re on — handy for logging.

The delivery id and the event id do different jobs, so use both:

  • x-saperly-delivery-id (header) → defends against malicious replays (cache and reject duplicates within the 5-minute window).
  • event_id (signed body field) → makes legitimate retries safe to process exactly once.

Because event_id is inside the signed body, it’s authenticated — once the signature checks out, you can trust it as the idempotency key (no need to special-case an unsigned header).

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
interruptedCaller spoke over the agent (barge-in)Update LLM context with partial_response_text, discard pending generation

The interrupted event

When a caller talks over the agent, Saperly cancels the agent’s TTS playback immediately and POSTs an interrupted event so your application can update its LLM conversation history.

1{
2 "event": "interrupted",
3 "call_id": "0af06af5-5e27-46f5-a931-fae2a87c69f2",
4 "timestamp": 1779441656686,
5 "partial_response_text": "Of course, I would be delighted to tell you everything. Saperly is the phone"
6}

partial_response_text is what the caller actually heard before the interruption — derived from Twilio’s mark confirmations, not from what your webhook returned. The text may be a prefix of your full response (or empty if barge-in fired before any audio confirmed). Use it — not your original LLM output — when constructing the next turn’s context.

Typical handling:

  1. Update your LLM context: record the assistant turn as partial_response_text (caller didn’t hear the rest).
  2. Discard any pending generation for that turn.
  3. Wait for the next message event with the caller’s interrupting utterance.

Signed with the same HMAC scheme as all other events. Delivery uses the same webhook_url. Treat (call_id, timestamp) as the natural idempotency key — the same barge-in event should never be POSTed twice, but guard for retries.

Ordering and timing

The interrupted event and the following message event are independent HTTP POSTs from Saperly to your webhook_url. Do not rely on HTTP arrival order if your receiver is latency-sensitive: order events by their timestamp field instead. In practice the interrupted event always carries an earlier timestamp than the next message (it fires at the moment of barge-in; the next message requires Deepgram VAD to finalize the caller’s new utterance, which takes hundreds of ms minimum), so a simple max(timestamp seen so far) comparator is sufficient.

partial_response_text is a snapshot at the moment of barge-in detection. A handful of Twilio mark echoes may arrive shortly after — those are not included in the snapshot, so the field can slightly under-report what the caller actually heard. In practice this is at most one mark’s worth of text. If your LLM context is sensitive to that boundary, mark the final sentence of the agent turn as “possibly partial” in your application state.

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 "detected_language": "en",
7 "context": [
8 {
9 "role": "caller",
10 "text": "hello?",
11 "timestamp": 1711899995000
12 }
13 ]
14}

detected_language (v0.5.10)

When the line is set to language: multi (the default), Deepgram identifies the language for each utterance and reports it in detected_language. Use it to route the caller to a language-specific LLM prompt:

1const prompt = body.detected_language === "he"
2 ? hebrewPrompt
3 : englishPrompt;

On language-pinned lines (e.g. language: he) the field may be absent — the pinned code is implicit, and the relay no longer asks Deepgram to detect.

Pinning the STT language

Multilingual auto-detect occasionally guesses wrong on short utterances and will transcribe Hebrew as Hindi or Spanish as Italian. Pin the line to a specific language to fix this:

$curl -X PATCH https://api.saperly.com/v1/lines/$LINE_ID \
> -H "Authorization: Bearer $SAPERLY_API_KEY" \
> -H "Content-Type: application/json" \
> -d '{"language": "he"}'

Supported codes: multi, en, he, es, ar, ru, fr, de, pt, it, pl, nl, tr, ja, zh. Hosted-mode lines auto-detect language automatically — language returns HTTP 400 hosted_mode_language_pinning_unsupported on hosted lines today.

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.”

End-to-end walkthrough

Use this when your app already has an agent runtime or business logic you want to keep in your own stack.

1. Create a line

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

2. Add the smallest useful webhook

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

3. Call the number

Say one sentence. You should hear the echo response spoken back.

If your webhook returns non-200 or takes longer than 10 seconds, the caller hears silence. Check GET /api/v1/webhooks/deliveries to see what happened.

4. What your webhook receives

1{
2 "event": "message",
3 "call_id": "2f450669-cb10-488e-9c50-c8fc1ad8116e",
4 "timestamp": 1711900005000,
5 "text": "hi, i need help with my order",
6 "context": [
7 { "role": "caller", "text": "hello?", "timestamp": 1711899995000 }
8 ]
9}

5. What your webhook should return

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

6. Verify the signature

Every outbound webhook is signed. Verify before you trust the body.

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); // your secret store
11 const result = verifyWebhook(rawBody, secret, req.headers);
12 if (!result.valid) return res.status(401).json({ error: result.reason });
13
14 const event = JSON.parse(rawBody);
15 if (event.event !== "message") return res.status(200).end();
16 return res.json({ text: `You said: ${event.text}` });
17});

See the signature verification section earlier on this page for the full headers table, replay-defense rules, and the secret rotation flow.

Common first-day mistakes

  • Using the wrong host. Use https://saperly.com/api/v1.
  • Returning plain text instead of JSON in webhook mode.
  • Taking too long to reply to a message event.
  • Skipping signature verification in production.