Post-call webhooks

Receive a signed POST after every conversation completes

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}:

1{
2 "webhook_url": "https://your-app.com/speechify/agents",
3 "webhook_secret": "<random 32+ bytes you store securely>"
4}

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 (was event).
  • Read the conversation from data.object (was conversation) - it now equals a direct GET of the conversation.
  • Read the transcript from data.messages (was top-level messages) and evaluations from data.evaluations (was top-level evaluations).
  • The flat top-level timestamp (Unix ms) is gone. Use created_at (RFC 3339) for the event time, and keep using the Unix-seconds t from the Speechify-Signature header for verification.

The signing scheme, header set, delivery/retry semantics, and delivery-log endpoint are unchanged.

1{
2 "id": "evt_01HS...",
3 "version": "2026-06-28",
4 "type": "conversation.completed",
5 "created_at": "2026-04-17T17:32:08Z",
6 "data": {
7 "object": {
8 "id": "conv_01HS...",
9 "agent_id": "agent_01HS...",
10 "status": "completed",
11 "duration_ms": 142300,
12 "started_at": "2026-04-17T17:29:46Z",
13 "ended_at": "2026-04-17T17:32:08Z",
14 "recording_url": "https://storage.googleapis.com/...",
15 "metadata": {}
16 },
17 "messages": [
18 { "id": "msg_...", "role": "user", "content": "Hi, I'd like to book an appointment", "started_at": "..." },
19 { "id": "msg_...", "role": "assistant", "content": "Sure - what day works best for you?", "started_at": "..." }
20 ],
21 "evaluations": [
22 { "id": "eval_...", "kind": "criterion", "name": "booked_slot", "passed": true, "rationale": "..." },
23 { "id": "eval_...", "kind": "data", "name": "intent", "data": { "value": "book" } }
24 ]
25 }
26}

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:

HeaderExample
Speechify-Eventconversation.completed
Speechify-Signaturet=1729425600,v0=<64-char hex>
Speechify-Delivery-Id550e8400-e29b-41d4-a716-446655440000 (UUID)
Speechify-Version2026-06-28 (the dated payload shape, mirrors the body version)
Content-Typeapplication/json
User-AgentSpeechify-Voice-Agents/1.0

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.

1import { createHmac, timingSafeEqual } from "node:crypto";
2
3function verify(req, secret: string) {
4 const header = req.headers["speechify-signature"] as string; // "t=…,v0=…"
5 const parts = Object.fromEntries(
6 // Split on the first "=" only (matching Python's split("=", 1)) so a
7 // value that ever contains "=" stays intact.
8 header.split(",").map((p) => {
9 const i = p.indexOf("=");
10 return [p.slice(0, i), p.slice(i + 1)] as [string, string];
11 }),
12 );
13 const { t, v0 } = parts;
14 // Reject deliveries older than 5 minutes to guard against replay.
15 if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
16 const expected = createHmac("sha256", secret)
17 .update(`${t}.${req.rawBody}`)
18 .digest("hex");
19 // Guard the length check before timingSafeEqual - it throws
20 // TypeError on mismatched buffer sizes, which would crash your
21 // handler on a malformed header instead of cleanly rejecting.
22 if (v0.length !== expected.length) return false;
23 return timingSafeEqual(Buffer.from(v0), Buffer.from(expected));
24}

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:

1GET /v1/agents/conversations/{conversation_id}/webhook-deliveries

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 lookup
  • GET /v1/agents/conversations/{id}/messages - full transcript, ordered
  • GET /v1/agents/conversations/{id}/evaluations - criteria + data extractors

All three are fast and safe to poll at 1 Hz on an active conversation.