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 is the standard WebhookEvent envelope: data.object is the completed conversation (identical to a direct GET), with the full transcript and evaluations attached under data - everything you need to push outcomes into your CRM, analytics pipeline, or data warehouse without polling. This is the same shape workspace webhook endpoints receive, so one handler covers both.
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 envelope, 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
Breaking change - payload envelope. The webhook_url post-call delivery previously sent a flat body: { event, timestamp, conversation, messages, evaluations }. It now sends the standard WebhookEvent envelope below, identical to workspace webhook endpoints. Update your handler:
- Read the event type from
type(wasevent). - Read the conversation from
data.object(wasconversation) - it now equals a directGETof the conversation. - Read the transcript from
data.messages(was top-levelmessages) and evaluations fromdata.evaluations(was top-levelevaluations). - The flat top-level
timestamp(Unix ms) is gone. Usecreated_at(RFC 3339) for the event time, and keep using the Unix-secondstfrom theSpeechify-Signatureheader for verification.
The signing scheme, header set, delivery/retry semantics, and delivery-log endpoint are unchanged.
data.object equals a direct GET /v1/agents/conversations/{id} - the webhook payload and the API response are interchangeable. data.messages is the full transcript in order (identical to GET .../messages); data.evaluations includes both kind="criterion" (success eval) and kind="data" (structured extractors). id is the unique evt_… event id, created_at is the RFC 3339 dispatch time, and version names the dated payload shape (mirrored in the Speechify-Version header). The signature input uses the separate Unix-seconds t carried in the Speechify-Signature header (see below), not created_at.
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.