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 the intended way to learn a call’s outcome. Use polling only as a fallback. To show a call while it runs, turn by turn, read its live events. Your endpoint must be public HTTPS and must return a 2xx quickly. Any other status, or a connection failure, triggers retries.
To receive events for every call of an agent or of your whole workspace, campaign events, low-balance alerts and payment notices without a URL on each call, use webhook subscriptions.

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.processed is a separate processing event, not another terminal event. It includes data.processing.version, analysis status, and post-call tool status. Explicit reruns produce a new version. Background on-call tools can finish later; read their receipts separately. See Tools: call.processed. For the finished transcript in the same event, create the call with final_results: true. See final results. A call run by a node-based agent always sends call.processed when it has a webhook_url, with the flow’s outcome at data.call.flow, unless the agent sets emit_processed_webhook: false. See final results. Campaigns add five more event types. We deliver them to the campaign’s own webhook_url, signed with the same workspace secret and in the same envelope, with data.campaign in place of data.call. A campaign’s calls emit the ordinary call.* events as well, to the same URL. For the payload, see Webhooks on the Campaigns page. A campaign’s URL also receives phone_number.rested, phone_number.resumed, phone_number.cooling, phone_number.alert and phone_pool.degraded for the numbers and pools it calls from. They carry data.phone_number or data.phone_pool instead of data.call. See Phone numbers and pools.

call.failed

Read data.call.status to tell the failure modes apart. A no_answer is worth retrying the next day, while a failed is worth paging someone about.

call.voicemail

status: voicemail always comes with ended_reason: "voicemail". That reason is how we identify the answering machine in the first place, so you will only ever see the two together. Voicemail calls are billed, because media was live. The 18-second call above bills as two 10-second blocks: ₹1.00 on T3. 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. An aborted call is billed for the time it was connected; one cancelled in the queue or while ringing costs nothing.

Wallet balance on call-ending events

call.completed, call.failed, call.voicemail and call.aborted carry your wallet balance beside the call, so you can top up or hold back new calls without polling the wallet.
call.completed with data.wallet
data.wallet.balance_inr is the workspace balance in rupees, read right after this call was billed. It is the number GET /v2/wallet would have answered at that moment. A call that cost nothing, such as a no_answer or a busy, still reports the balance. Calls end and are billed side by side, so the balance is a snapshot. A later event can show a lower balance than one that arrived before it, and retries can deliver events out of order. Compare each value with your own threshold instead of with the previous event, and read GET /v2/wallet when you need the current figure. data.wallet is left out when we could not read the balance, for example when the call’s billing could not be recorded. We never send 0 in place of a number we do not know, so treat a missing wallet as unknown. It appears only on the four terminal events, never on call.queued, call.started, call.processed or campaign events, and it is not part of the call object.

Final results

Create a call with final_results: true to receive one call.processed event with everything about the finished call: the call object, the transcript, and the status of analysis and post-call tools. Start processing a conversation when this event arrives. Campaign calls send it by default.
call.processed with final_results: true
analysis is done, failed, skipped or not_enabled. post_call_tools is done, partial or failed when the agent has post-call tools. See Tools: call.processed. Analysis and the follow-up message. When the agent has analysis enabled, data.call.analysis holds the summary, success and your extracted data. If the agent also has analysis.templates, it holds the template chosen for the follow-up message:
data.call.analysis on call.processed
template.status is selected, none or failed. A failed choice never fails the analysis: analysis.status stays done and the summary still arrives. Check template.missing before you send. See the result for every field and error code. Choosing the template happens before this event is sent, so it adds the time of one model call, plus your template fetch when you use one. Node-based agents. For a call run by a node-based agent, data.call.flow holds the flow’s outcome, in the ordinary call.processed and in final results alike:
data.call.flow
The fields are described in flow outcome. nodes_visited and variables are always present; final_node, status and tags are omitted when unknown. A node-based agent’s call sends call.processed whenever the call has a webhook_url, even without analysis, post-call tools or final_results. The outcome is usually recorded within seconds of the call ending. call.processed waits up to 2 minutes for it and is then sent without flow. In that case, read GET /v2/calls/{id} later. When it arrives. It comes after the terminal event, once the transcript has settled (within about 2 minutes of the call ending) and any analysis and post-call tools have finished, which can take longer. Do not wait for it to decide that a conversation is over. One event, stable ID. With final_results: true, this is the call’s call.processed. It replaces the ordinary one, so you never get a second. Retries keep its id, so dedupe on it as usual. An explicit post-call rerun produces a new version. Final results are delivered only to the call’s webhook_url. Set one when you set final_results.

Which event to act on

For the whole browser-call flow, see Ending and results on the Browser calls page.

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 takes four steps:
  1. Parse t and v1 out of the header.
  2. Reject the delivery 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 test vector before you try 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 most common integration bug is 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 do not reject deliveries on your behalf. The timestamp is there so that you can: drop any delivery 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. Keep it synced with NTP.

Rotating the secret

Ask ops to rotate it. During rotation, the old and the new secret are both valid for a short overlap, so verify against a list:

Retries

A delivery succeeds on any 2xx. We retry anything else, including a 4xx, a 5xx, a timeout, a TLS error or a DNS failure. You get six attempts with exponential backoff over roughly 36 minutes. The first fires immediately, and the gaps widen up to a half-hour cap. Design for the whole window. The individual delays are an implementation detail that we tune, so do not build around them. What we guarantee is that a brief outage on your side will not lose the event, and that an endpoint that is down for the whole 36 minutes will lose it. After the sixth attempt, the event goes to a dead-letter queue (DLQ), and we can replay it from there. A replayed event is byte-identical to the original, including its id. It carries a fresh t and v1, so it passes your window check. Some consequences to plan 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 and do the work afterwards.
  • A 401 from your signature check is a retryable failure to us. We do this on purpose, so that a transient secret-loading bug on your side does not lose the event.

Linking an event to your own records

An event describes the call and carries nothing from your own database. Join the two on call_id. You have it before any event can arrive, because 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 and are not echoed back on the event. metadata is echoed back. Set small identifiers such as your session_id when you create the call, and every event for the call carries them in data.call.metadata. This also handles a webhook that lands before your own call_id write has finished. Store anything larger in your own row, keyed by call_id.

Idempotency

Assume at-least-once delivery. Dedupe on id:
You can also make the handler naturally idempotent. A statement such as UPDATE orders SET call_status = ? WHERE id = ? needs no dedupe table at all.

Local development

Webhooks need a public URL. To receive them on your laptop, open a tunnel:
Then pass "webhook_url": "https://a1b2c3d4.ngrok-free.app/mirai/webhook" when creating a call. To iterate without spending wallet balance, replay a captured delivery against your handler with curl:

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. It enqueues the work and returns.