Production engineering

Connect voice-agent webhooks to your application

Mirai sends a signed JSON event to your webhook_url at each step of a call. A reliable receiver verifies X-Mirai-Signature over the raw body, stores the event before answering 2xx, and dedupes on the event id, because retries repeat events and can deliver them out of order. Match events to your records with the call id you saved, or with metadata you set on the call.

By Mirai Minds · Updated

Which events will you receive?

Pass webhook_url when you create a call, or set one on a campaign, and Mirai POSTs a signed event at each transition. The terminal events report what each call cost: voice agents are billed from ₹1 a minute at volume (T1, with a ₹1,00,000 monthly commitment) to ₹5 a minute pay as you go (T5). Every event has the same envelope: id (evt_…), type, created_at and data.call, which is the call object as GET /v2/calls/{id} would return it at that moment.

Event When it fires
call.queued The call is admitted to the queue. Opt-in; ask Mirai to enable it
call.started Media is live and the conversation has begun
call.completed The call connected and finished normally
call.voicemail An answering machine answered
call.failed It did not connect (no_answer, busy, failed, timeout)
call.aborted You cancelled it with POST /v2/calls/{id}/abort
call.processed Analysis, post-call tools or final results are ready

Exactly one of call.completed, call.voicemail, call.failed and call.aborted fires per call. Those four terminal events also carry data.wallet.balance_inr, the wallet balance right after the call was billed. It is a snapshot taken while other calls are billing, so compare it with your own threshold rather than with the previous event.

call.processed is a separate event that comes after the terminal one. Create the call with final_results: true and that event carries the finished transcript inline, with data.transcript.status set to ready, empty, failed or fetch_required. A transcript over 512 KB arrives as fetch_required; fetch it from GET /v2/calls/{id}/transcript. Campaign calls send final results by default. Calls run by a node-based agent add the flow’s outcome at data.call.flow.

Campaign URLs also receive campaign.started, campaign.paused, campaign.resumed, campaign.completed and campaign.stopped, in the same envelope with data.campaign. Treat the envelope as additive: new fields appear without a version bump, so ignore what you don’t recognise.

How should a receiver verify a request?

The X-Mirai-Signature header looks like t=1785057398,v1=771bca…. v1 is the lowercase hex HMAC-SHA256 of the string <t>.<raw_body>, keyed with your workspace’s whsec_ secret. Four steps:

  1. Parse t and v1 from the header.
  2. Reject the delivery if it is more than 300 seconds from your clock. Mirai does not enforce this window for you.
  3. Recompute the HMAC over t + "." + raw_body.
  4. Compare with v1 in constant time.

Here is a Flask receiver that does that and stores the event before acknowledging it:

import hashlib
import hmac
import os
import time

from flask import Flask, request

app = Flask(__name__)
WEBHOOK_SECRET = os.environ["MIRAI_WEBHOOK_SECRET"]  # whsec_...
TOLERANCE_SECS = 300


def verify(raw_body: bytes, header: str, secret: str) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
    t, v1 = parts.get("t"), parts.get("v1")
    if not t or not v1 or not t.isdigit():
        return False
    if abs(time.time() - int(t)) > TOLERANCE_SECS:
        return False
    expected = hmac.new(secret.encode(), t.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, v1)


@app.post("/mirai/webhook")
def mirai_webhook():
    raw = request.get_data()  # the exact bytes, before any JSON parsing
    if not verify(raw, request.headers.get("X-Mirai-Signature", ""), WEBHOOK_SECRET):
        return {"error": "invalid signature"}, 401
    event = request.get_json()
    save_to_inbox(event["id"], event["type"], raw)  # your durable insert; a unique key on id
    return "", 200  # any 2xx stops retries

save_to_inbox is yours: an insert into a table with a unique constraint on the event ID, where a duplicate insert counts as success. We checked verify against the test vector in the webhook reference: it accepts the documented 177-byte body and header, and rejects the same body with one word changed or with a timestamp 301 seconds old.

Capture the bytes before a JSON parser touches them. Parsing and re-serialising changes key order, spacing or escaping, and the digest no longer matches. In Express that means express.raw({ type: "application/json" }) on this route, mounted before any global express.json().

Keep the webhook secret apart from your API key. During a rotation the old and new secrets are both valid for a short overlap, so verify against a list. If legitimate deliveries fail the time check, your server clock is wrong; keep it synced with NTP.

When is it safe to acknowledge an event?

After the event is durably stored. Slow work belongs in a worker, and Mirai’s client times out slow handlers and retries them, so answer quickly. The inbox pattern looks like this:

  1. Verify the signature.
  2. Insert the event into an inbox table keyed on its id. A duplicate means it is already stored, which is safe to acknowledge.
  3. Return 200 after the write commits.
  4. A worker claims pending rows, updates your application and marks them done.
  5. A failed worker job retries with the same event ID.

The inbox is a pattern for your own application, not a Mirai endpoint. If the worker calls another service, use that service’s idempotency key or an outbox. An inbox alone does not make external side effects happen exactly once.

An event carries nothing from your database. POST /v2/calls returns the call ID in its 202 response, so write it against your order or job before that request returns. A webhook can arrive while the request that placed the call is still in flight.

The metadata you set on a call, up to 16 string keys, comes back unchanged in data.call.metadata on every event for that call. Put your own small identifiers there, such as an order ID, so a handler can match an event even before your own write has finished. variables are not echoed back; they only fill the prompt. Campaign calls also carry campaign_id and contact_id.

What happens when events arrive out of order?

Retries and independent network paths can reorder delivery. A retried call.started can arrive after call.completed. Order your state machine by data.call.status and let a terminal status win. The docs state that statuses only move forward and a terminal status never changes.

When the local record is incomplete or events conflict, read the current state from GET /v2/calls/{id}. Keep the event ID and created_at for diagnosis. created_at is when the event was generated, not when it was delivered.

What should you test before going live?

Replay events against your own receiver before you place a real call. The docs show how to sign a captured event.json with openssl and send it with curl, which costs nothing. Cover a duplicate delivery, a bad signature, a malformed body, a delayed call.started, a database failure, and a restart between the inbox write and processing.

Check that the intended record changed. A handler can return 200 and still fail to update it. Track received, pending, processed and failed events so an operator can find a stuck job without reading transcripts. If your endpoint was down for longer than the retry window, reconcile from GET /v2/calls, which is what the list endpoint is for.

If you haven’t built the call-creation side yet, start with the Python phone-call guide. For lists of contacts, see campaigns from a CSV.

Common questions

Should my webhook process every delivery as a new event?

No. Delivery is at least once, so a retry can bring the same event again with the same id. Dedupe on the event id and make the downstream work idempotent, so a retry never repeats a notification or another business action.

How long does Mirai keep retrying?

Six attempts with exponential backoff over roughly 36 minutes. Any 2xx stops the retries; any other status, a timeout or a TLS or DNS error counts as a failure, including a 401 from your own signature check. After the sixth attempt the event goes to a dead-letter queue, from which Mirai can replay it.

Should I return success before putting the event in a queue?

Return 2xx once the event is durably stored. If you acknowledge first and the process dies before the write, the event is gone, because Mirai has stopped retrying.

Does a completed call mean my business task succeeded?

No. call.completed means the call connected and ended normally. Whether the customer confirmed, paid or rescheduled is a separate question; read the post-call analysis or transcript before you mark the business task done.

Sources and API references

References reviewed on . Measurement dates and experimental limits are stated in the article. Check current API and runtime documentation when integrating.

Try the sandbox