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.
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
Event flow
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
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
Verify with the Python SDK
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:
- Reject any request whose
x-saperly-timestampis more than 5 minutes away from your server’s current time. The SDK helpers do this by default viaclockToleranceSec/clock_tolerance_seconds. - Cache every
x-saperly-delivery-idyou 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):
Rotation is a two-call flow:
- 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. - Second call ≥24 hours later promotes
next → current(new traffic signs with the new secret) and mints a freshnext. You have a full day to roll the new secret out to every receiver before signed traffic actually changes.
Response:
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
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.
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:
- Update your LLM context: record the assistant turn as
partial_response_text(caller didn’t hear the rest). - Discard any pending generation for that turn.
- Wait for the next
messageevent 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
Main event you need to handle
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:
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:
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
Response rules that matter
- return JSON
- return quickly
- keep the first response short
- handle non-
messageevents without trying to speak back
If your server is slow or broken, the caller feels it immediately. This is the whole game.
Minimal handler
Handle errors gracefully
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.message events, return a streaming NDJSON response to get a longer budget: 15 seconds per line and 5 minutes total.Inspect failures:
Streaming responses (NDJSON)
For lower perceived latency, return streaming NDJSON instead of a single JSON object. Each line is a complete JSON object:
The caller hears each chunk as it arrives, reducing time to first word.
Security practices
- Validate the
call_idexists 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.
Related guides
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
2. Add the smallest useful webhook
3. Call the number
Say one sentence. You should hear the echo response spoken back.
GET /api/v1/webhooks/deliveries to see what happened.4. What your webhook receives
5. What your webhook should return
6. Verify the signature
Every outbound webhook is signed. Verify before you trust the body.
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
messageevent. - Skipping signature verification in production.
