webhook_url when you create a call and we POST
a signed JSON event at each lifecycle transition. Webhooks are how you learn a
call’s outcome — polling is the fallback, not the design.
Your endpoint must be public HTTPS and must return a 2xx quickly. Any
other status, or a connection failure, triggers retries.
Event envelope
Every event has the same shape.
Treat the envelope as additive: new fields appear without a version bump.
Ignore what you do not recognise.
Event types
Exactly one terminal event fires per call:
call.completed, call.voicemail,
call.failed or call.aborted.
call.failed
Read data.call.status to tell the failure modes apart — no_answer is a
retry-tomorrow, failed is a page-someone.
call.voicemail
status: voicemail always carries ended_reason: "voicemail" — that reason is
what identifies the answering machine in the first place, so the pair is the
only combination you will see.
Voicemail calls are billed — media was live. 18 seconds bills as
18 seconds: ₹0.30 at t1. See metering.
call.aborted
aborted-by-api is the only ended_reason an abort produces, whether you
cancelled the call in the queue, mid-ring or mid-conversation. Aborted calls are
never billed.
Signature verification
Every delivery carries a timestamped HMAC-SHA256 signature.
The signed string is the timestamp, a literal
., then the raw request body
bytes. The secret is your per-workspace whsec_…, shown once when your key is
issued.
Verification is four steps:
- Parse
tandv1out of the header. - Reject if
|now − t| > 300seconds — this is the replay window. - Recompute the HMAC over
t + "." + raw_body. - Compare with
v1in constant time.
- Python
- Node.js
Test vector
Check your implementation against this before you go anywhere near a real call.secret
raw body (177 bytes, exactly as shown — no trailing newline)
header
v1. (The timestamp is in the past, so skip the
window check while testing this vector.)
Raw body
The single most common integration bug: signing a re-serialised body.express.raw({ type: "application/json" }) on this route (mount it before
any global express.json()); in Django, request.body; in Rails,
request.raw_post; behind API Gateway, make sure the payload is not
base64-transformed on the way in.
Replay protection
We reject nothing on your behalf — the timestamp is there so you can. Drop deliveries where|now − t| > 300 seconds. Without that check, anyone who
captures one valid delivery can replay it forever.
If legitimate deliveries fail the window check, your server clock is wrong.
Run NTP.
Rotating the secret
Ask ops. During rotation both the old and the new secret are considered valid for a short overlap, so verify against a list:Retries
A delivery succeeds on any2xx. Anything else — 4xx, 5xx, timeout, TLS
error, DNS failure — is retried: six attempts with exponential backoff over
roughly 36 minutes, the first fired immediately and the gaps widening up to a
half-hour cap.
Design against the window, not against the individual gaps — the exact delays
are an implementation detail and we tune them. What is contractual is that a
brief outage on your side is survivable without losing the event, and that an
endpoint down for the whole 36 minutes will lose it.
After the sixth attempt the event goes to a dead-letter queue. We can replay
from the DLQ — a replayed event is byte-identical to the original, including its
id, and carries a fresh t and v1 so it passes your window check.
Consequences worth planning for:
- Order is not guaranteed. A retried
call.startedcan arrive aftercall.completed. Order your own state machine bydata.call.status, not by arrival. - Slow endpoints get retried. If your handler takes longer than our client timeout we treat it as a failure and send again. Acknowledge first, work later.
- A
401from your signature check is a retryable failure to us. That is intentional — a transient secret-loading bug on your side should not lose the event.
Linking an event to your own records
An event carries the call, not your database. The join key iscall_id, and
you have it before any event can arrive — POST /v2/calls returns it
synchronously in the 202:
variables are substituted into the prompt; they are not echoed back on
the event. Anything you need at webhook time belongs in your own row, keyed by
call_id.
Idempotency
Assume at-least-once delivery. Dedupe onid:
- Python
- Node.js
UPDATE orders SET call_status = ? WHERE id = ? needs no dedupe table at all.
Local development
Webhooks need a public URL. Tunnel to your laptop:"webhook_url": "https://a1b2c3d4.ngrok-free.app/mirai/webhook" when
creating a call. Replay a captured delivery against your handler with curl to
iterate without burning wallet balance:
Checklist
- Endpoint is public HTTPS and returns 2xx in well under a second.
- Signature verified against the raw body, in constant time.
- Timestamp window enforced at 300 seconds.
- Events deduped on
id. - Unknown
typevalues ignored, not crashed on. - Handler does no slow work inline — enqueue and return.