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.
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 withfinal_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
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:
- Parse
tandv1out of the header. - Reject the delivery 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 test vector before you try 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 most common integration bug is 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 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 any2xx. 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.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 and do the work afterwards.
- A
401from 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 oncall_id. You have it before any event can arrive, because
POST /v2/calls returns it synchronously in the 202:
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 onid:
- Python
- Node.js
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:"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
typevalues ignored, not crashed on. - Handler does no slow work inline. It enqueues the work and returns.