Skip to main content
Pass 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:
  1. Parse t and v1 out of the header.
  2. Reject if |now − t| > 300 seconds — this is the replay window.
  3. Recompute the HMAC over t + "." + raw_body.
  4. Compare with v1 in constant time.
Flask:
FastAPI:

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
Your HMAC must equal that 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.
Capture the raw bytes before any body parser touches them. In Express that means 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 any 2xx. 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.started can arrive after call.completed. Order your own state machine by data.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 401 from 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 is call_id, and you have it before any event can arrive — POST /v2/calls returns it synchronously in the 202:
Do that write before you return from the request that placed the call. A webhook can land while the placing request is still in flight, and a handler that cannot find the call yet has to either drop the event or retry blindly. 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 on id:
Or make the handler naturally idempotent — UPDATE orders SET call_status = ? WHERE id = ? needs no dedupe table at all.

Local development

Webhooks need a public URL. Tunnel to your laptop:
Then pass "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 type values ignored, not crashed on.
  • Handler does no slow work inline — enqueue and return.