Post-call webhooks

Receive a signed POST after every conversation completes

When a voice-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/voice-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": "c_01HS...",
6 "agent_id": "a_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": "m_...", "role": "user", "content": "Hi, I'd like to book an appointment", "started_at": "..." },
16 { "id": "m_...", "role": "assistant", "content": "Sure — what day works best for you?", "started_at": "..." }
17 ],
18 "evaluations": [
19 { "id": "e_...", "kind": "criterion", "name": "booked_slot", "passed": true, "rationale": "..." },
20 { "id": "e_...", "kind": "data", "name": "intent", "data": { "value": "book" } }
21 ]
22}

timestamp is UNIX milliseconds at dispatch time and is part of the signature input (see below). 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
X-Speechify-Eventconversation.completed
X-Speechify-Timestamp1729425600000 (ms)
X-Speechify-Signaturesha256=<64-char hex>
Content-Typeapplication/json
User-AgentSpeechify-Voice-Agents/1.0

The signature is sha256=HEX(HMAC_SHA256(secret, "<timestamp>.<raw body>")) — the . between timestamp and body is load-bearing, it prevents a malicious timestamp shift from being absorbed into the body without changing the input.

Verify in Node.js:

1import { createHmac, timingSafeEqual } from "node:crypto";
2
3function verify(req, secret: string) {
4 const ts = req.headers["x-speechify-timestamp"] as string;
5 const sig = req.headers["x-speechify-signature"] as string;
6 const expected = "sha256=" + createHmac("sha256", secret)
7 .update(`${ts}.${req.rawBody}`)
8 .digest("hex");
9 // Guard the length check before timingSafeEqual — it throws
10 // TypeError on mismatched buffer sizes, which would crash your
11 // handler on a malformed header instead of cleanly rejecting.
12 if (sig.length !== expected.length) return false;
13 return timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
14}

Verify in Python:

1import hashlib, hmac
2
3def verify(headers, raw_body: bytes, secret: str) -> bool:
4 ts = headers["X-Speechify-Timestamp"]
5 sig = headers["X-Speechify-Signature"]
6 expected = "sha256=" + hmac.new(
7 secret.encode(), f"{ts}.".encode() + raw_body, hashlib.sha256,
8 ).hexdigest()
9 return hmac.compare_digest(sig, expected)

Reject any delivery whose timestamp is more than a few minutes old 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.

Every attempt is recorded in the delivery log for the conversation. Fetch it with:

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

The response lists each attempt with status, attempt_count, last_status_code, and last_error. 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/conversations/{id} — single-row status lookup
  • GET /v1/conversations/{id}/messages — full transcript, ordered
  • GET /v1/conversations/{id}/evaluations — criteria + data extractors

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