Post-call webhooks
Post-call webhooks
When an agent conversation ends, the control plane POSTs a signed JSON payload to the webhook_url configured on the agent. The payload contains the full transcript, evaluations, and conversation metadata - everything you need to push outcomes into your CRM, analytics pipeline, or data warehouse without polling.
Today the only event is conversation.completed. Stream events (message.created, session.errored, etc.) are on the near-term roadmap and will share the same signing scheme and header set.
Configure
Set the webhook on an agent via POST /v1/agents or PATCH /v1/agents/{id}:
Both fields are optional. If webhook_url is empty the feature is disabled for that agent. Rotate the secret at any time by PATCHing a new value; requests after the rotation use the new secret.
The console mirrors this flow on the agent detail page.
Payload
The body timestamp is UNIX milliseconds at dispatch time. The signature input uses the separate Unix-seconds t carried in the Speechify-Signature header (see below), not this body field. messages is the full transcript in order. evaluations includes both kind="criterion" (success eval) and kind="data" (structured extractors).
Signing - HMAC-SHA256
Every delivery carries these headers:
Speechify-Signature is a single combined header in the Stripe/Svix/ElevenLabs format: t=<unix-seconds>,v0=<signature>, where v0 = HEX(HMAC_SHA256(secret, "<t>.<raw body>")). Read the seconds value from the header’s t=, join it to the raw body with a literal ., and HMAC that. The . separator is load-bearing - it prevents a malicious timestamp shift from being absorbed into the body without changing the input.
Migration from the legacy scheme. This replaced the older two-header form (X-Speechify-Signature: sha256=<hex> + X-Speechify-Timestamp: <ms>). Update your verifier to: parse t/v0 from the single Speechify-Signature header, sign over "<t>.<raw body>" using the seconds t (not the body’s millisecond timestamp), and compare v0 (no sha256= prefix). The X-Speechify-Event / X-Speechify-Delivery-Id headers also drop their X- prefix.
Speechify-Delivery-Id is a stable id for one delivery of one event. Delivery is at-least-once with retries: a failed delivery is retried up to 3 times (immediate, then +5s, then +10s), and every retry reuses the same delivery id. Treat the id as an idempotency key - ignore a delivery whose id you have already processed.
Reject any delivery whose t is more than 5 minutes from receipt time - the window we sign within - to guard against replays.
Delivery + retries
- We wait for your endpoint for up to 10 seconds per attempt.
- Up to 3 attempts total on a single delivery: immediate, +5s, +10s.
- A 2xx response terminates the delivery as successful.
- A 4xx response terminates as failed (no retry - we assume your handler intentionally rejected the payload).
- A 5xx response or network error is retried up to the attempt cap.
A single delivery record per (conversation, webhook URL) is updated in place across retries. Fetch it with:
Each row shows the cumulative status, attempt_count, last_status_code, and last_error for that dispatch. Useful for debugging why a specific conversation didn’t show up in your downstream system.
While you wait
If you prefer polling (or want to reconcile after a webhook outage on your side), the raw endpoints are:
GET /v1/agents/conversations/{id}- single-row status lookupGET /v1/agents/conversations/{id}/messages- full transcript, orderedGET /v1/agents/conversations/{id}/evaluations- criteria + data extractors
All three are fast and safe to poll at 1 Hz on an active conversation.