Guide · Production engineering

Connect voice-agent webhooks to your application.

Your app hears about every call the moment it changes: started, finished, transcript ready. The receiver is one short Flask route that checks the signature, stores the event before it answers, and shrugs off retried duplicates.

By Frequency · Updated 16 October 2026.

AI agents from ₹1 a minute.¹

Read the API docs

We send a signed event at every step.

Pass webhook_url on a call or a campaign, and we POST a signed event at every step. The envelope is always id (evt_…), type, created_at, and data.call, the call as GET /v2/calls/{id} would show it right then.

Event When it fires
call.queued Queued. Opt-in; ask us to turn it on
call.started Audio is live and the talking has begun
call.completed Connected and finished normally
call.voicemail An answering machine answered
call.failed Didn’t 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

Each call gets exactly one ending: call.completed, call.voicemail, call.failed or call.aborted. Calls cost ₹1 a minute on our volume plan, ₹5 pay as you go, and the ending says what this one cost. It also carries data.wallet.balance_inr. Other calls bill at the same time, so compare that balance with your own threshold, not the last event.

call.processed follows the ending. With final_results: true it carries the transcript, and data.transcript.status is ready, empty, failed or, over 512 KB, fetch_required: read that one from GET /v2/calls/{id}/transcript. Campaign calls send final results by default, and node-based agents add data.call.flow.

Campaign URLs also get campaign.started, campaign.paused, campaign.resumed, campaign.completed and campaign.stopped, with data.campaign. New fields can appear without a version bump, so ignore what you don’t know.

Check the signature on every event.

X-Mirai-Signature looks like t=1785057398,v1=771bca…. v1 is the lowercase hex HMAC-SHA256 of <t>.<raw_body>, keyed with your whsec_ secret. To check it:

  1. Read t and v1 from the header.
  2. Reject anything more than 300 seconds off your clock. We don’t enforce this for you.
  3. Recompute the HMAC over t + "." + raw_body.
  4. Compare with v1 in constant time.

In Flask:

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 your own insert, with a unique key on the event ID. We checked verify against the webhook reference test vector. It accepts the documented 177-byte body and rejects it with one word changed or a timestamp 301 seconds old.

Grab the bytes before any JSON parser touches them; re-serialising breaks the digest. In Express, put express.raw({ type: "application/json" }) on this route, before any global express.json().

During a secret rotation both secrets work for a while, so check against a list. Real deliveries failing the time check? Sync your clock with NTP.

Store the event before you answer.

Leave slow work to a worker, because we time out slow handlers and retry them.

  1. Verify the signature.
  2. Insert it into an inbox table keyed on id. A duplicate is already stored; answer it anyway.
  3. Return 200 after the write commits.
  4. A worker picks up pending rows, updates your app and marks them done.
  5. A failed job retries with the same event ID.

If the worker calls another service, use its idempotency key or an outbox. An inbox alone can’t make outside effects exactly-once.

Use metadata to match events to your records.

POST /v2/calls returns the call ID in its 202. Save it against your order before that request returns, since a webhook can beat it.

The metadata you set on a call (up to 16 string keys) comes back in data.call.metadata on every event. Put an order ID there and you can match events even before your own write lands. variables don’t come back. Campaign calls also carry campaign_id and contact_id.

Events can arrive out of order.

A retried call.started can show up after call.completed. Order your state by data.call.status and let a final status win. Statuses only move forward.

If events disagree, read GET /v2/calls/{id}. Note that created_at is when we made the event, not when it arrived.

Test before you go live.

The docs show how to sign a saved event.json with openssl and replay it free with curl. Try a duplicate, a bad signature, a broken body, a late call.started, a database failure, and a restart between the inbox write and the worker.

Then check the right record changed. A 200 doesn’t prove it did. Count received, pending, processed and failed events, so stuck jobs stand out. If you were down longer than the retry window, catch up from GET /v2/calls.

Haven’t built the calling side yet? Start with the Python phone-call guide. For lists, see campaigns from a CSV.

Sources

These are the references this guide is built on.

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

Questions

These are the questions developers ask us most.

Should my webhook process every delivery as a new event?

No. Delivery is at least once, so a retry can bring the same event, same id, again. Dedupe on the id and keep the work after it idempotent.

How long do you keep retrying?

Six attempts with exponential backoff, over roughly 36 minutes. Any 2xx stops them. Anything else fails, even a timeout, a TLS or DNS error, or a 401 from your own check. Then the event goes to a dead-letter queue, which we can replay.

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

No. Answer 2xx once it's stored. If the process dies before the write, the event is gone, because we've stopped retrying.

Does a completed call mean my business task succeeded?

No. call.completed only means the call connected and ended normally. Whether the customer confirmed, paid or rescheduled is in the analysis or transcript. Check that first.

Still deciding? Ask us.

Fifteen minutes with the team that builds it. Bring your script, or your hardest call.

Book 15 minutes

Book a call

15 minutes with Sneh Mehta

Finding a time for us…

  1. AI agents from ₹1 a minute: on our volume plan, ₹1,00,000 a month. Pay as you go is ₹5 a minute. Prices exclude GST.