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

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

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

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:

HeaderExample
Speechify-Eventconversation.completed
Speechify-Signaturet=1729425600,v0=<64-char hex>
Speechify-Delivery-Id550e8400-e29b-41d4-a716-446655440000 (UUID)
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.