Start building
Make an AI phone call from Python with the Mirai API
Three requests put an AI agent on a real phone call: POST /v2/agents once, POST /v2/calls with an Idempotency-Key for each call, and a webhook receiver that verifies X-Mirai-Signature and reads the outcome. This guide does it in Python with httpx and Flask, against the sandbox, where a new workspace gets ₹200 of credit at the pay-as-you-go rate.
What do you need before you start?
- A secret key. Open the sandbox, go to Developers and press Create key. You don’t need to sign up to look around; the first time you create something, the console asks for your work email. Confirming it gives the workspace ₹200 of starter credit, billed at T5. You also get a
whsec_webhook secret. - A phone number you have permission to call, in E.164 form such as
+919876543210. - A public HTTPS URL for webhooks. A tunnel such as
ngrok http 3000is fine while you build. - Python with
pip install "httpx==0.28.1" "flask==3.1.2".
Voice agents run from ₹1 a minute at volume (T1, with a ₹1,00,000 monthly commitment) and ₹5 a minute pay as you go (T5), in 10-second blocks. Until your company completes Go live, every call is billed at T5. Mirai builds its own speech, recognition and language models and offers them as APIs; this guide uses the agent API, which runs all of them on the call for you. The pricing guide and AI calling cover cost in more detail.
export MIRAI_API_KEY="sk_live_..."
export MIRAI_WEBHOOK_SECRET="whsec_..."
Step 1: create an agent
An agent is the reusable configuration a call runs: prompt, opening line, voice, language and a duration cap. Create it once and use it for every call.
import os
import httpx
API = "https://sandbox.voice.miraiminds.co"
auth = {"Authorization": f"Bearer {os.environ['MIRAI_API_KEY']}"}
agent = httpx.post(
f"{API}/v2/agents",
headers=auth,
json={
"name": "Order Confirmation",
"system_prompt": (
"You are Priya from Acme. Confirm order {{order_id}} with "
"{{customer_name}} and ask whether the delivery address is "
"unchanged. Keep replies to one or two short sentences. When "
"the customer is done, thank them and end the call."
),
"first_message": "नमस्ते {{customer_name}}, मैं Acme से Priya बोल रही हूँ।",
"voice": {"voice_id": "neha", "language": "hi-IN"},
"language": "hi-IN",
"max_duration_secs": 300,
},
timeout=30,
).raise_for_status().json()
print(agent["id"]) # agt_...
The voice is one of ashu, neha, shruti and sameer. Keep voice.language the same as the agent’s language; the voice renders in the top-level one. {{customer_name}} and {{order_id}} are filled from each call’s variables. Before you run this at volume, read the prompting guide on structure, Hinglish and ending a call.
Step 2: place the call
call_resp = httpx.post(
f"{API}/v2/calls",
headers={**auth, "Idempotency-Key": "order-8842-confirm-1"},
json={
"agent_id": agent["id"],
"to": "+919876543210",
"variables": {"customer_name": "Rahul", "order_id": "8842"},
"metadata": {"order_id": "8842"},
"webhook_url": "https://example.com/mirai/webhook",
"final_results": True,
},
timeout=30,
)
if call_resp.status_code == 402:
raise RuntimeError(call_resp.json()["error"]["message"]) # top up the wallet
call = call_resp.raise_for_status().json()
print(call["id"], call["status"]) # call_... queued
202 Accepted means the call is queued. The phone has not rung yet. Store call["id"] against your order before this request returns, because a webhook can arrive while it is still in flight.
Derive the Idempotency-Key from your own record. If a request times out, send it again with the same key and you get the original response back, with no second call. Keys are held for 24 hours. metadata (up to 16 string keys) comes back unchanged on every webhook, while variables only fill the prompt. final_results: true makes the call end with one call.processed event that carries the transcript.
Step 3: receive the webhook
import hashlib
import hmac
import os
import time
from flask import Flask, request
app = Flask(__name__)
WEBHOOK_SECRET = os.environ["MIRAI_WEBHOOK_SECRET"]
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():
if not verify(request.get_data(), request.headers.get("X-Mirai-Signature", ""), WEBHOOK_SECRET):
return "", 401
event = request.get_json()
call = event["data"].get("call", {})
if event["type"] in ("call.completed", "call.voicemail", "call.failed", "call.aborted"):
print(event["type"], call["status"], call.get("ended_reason"), call.get("metadata"))
elif event["type"] == "call.processed":
transcript = event["data"].get("transcript", {})
for turn in transcript.get("turns", []):
print(f"{turn['role']}: {turn['text']}")
return "", 200 # any 2xx stops retries
if __name__ == "__main__":
app.run(port=3000)
Run it, point your tunnel at port 3000, and use the tunnel’s URL as webhook_url in step 2. In production, store each event before you answer 200 and dedupe on event["id"], because retries repeat events. The webhook guide covers that in detail.
We tested verify against the docs’ signature test vector. The agent and call requests match the quickstart field for field; we did not run them for this guide, because they place a real call.
What comes back after the call?
Exactly one terminal event arrives: call.completed, call.voicemail, call.failed or call.aborted. Read data.call.status and ended_reason. A completed call also has duration_secs, the billed cost and data.wallet.balance_inr, the balance right after billing.
With final_results: true, a call.processed event follows once the transcript has settled, usually within about two minutes. Its data.transcript.turns hold each role, text, start_ms and end_ms. Hindi, Hinglish and English come back as they were spoken, untranslated. If the transcript is over 512 KB, status is fetch_required and you read it from GET /v2/calls/{id}/transcript. Recordings are at GET /v2/calls/{id}/recording, a 15-minute signed link. Both are kept for 90 days.
If you can’t host an endpoint, poll GET /v2/calls/{id} no faster than every 5 seconds until the status is terminal. Polling counts against your rate limit and webhooks don’t.
What can go wrong?
| Symptom | Cause | Fix |
|---|---|---|
401 unauthorized |
Missing Bearer prefix, or a newline in the key |
Re-copy the key into Authorization: Bearer sk_live_… |
402 insufficient_balance |
The wallet can’t cover one minute at your tier | Check GET /v2/wallet and top up from Billing |
400 invalid_request on to |
Not E.164 | Use +919876543210, not 9876543210 |
400 invalid_request on voice_id |
Voice not in the catalogue | Use ashu, neha, shruti or sameer |
409 duplicate_call |
The first request with this key is still being processed | Retry a moment later and you get the original response |
403 contract_unavailable |
You sent a tier that isn’t your plan’s |
Leave tier out |
429 rate_limited |
Request rate, or 500 calls already queued | Honour Retry-After |
status: no_answer, or failed with no-media |
The call didn’t connect; some carriers give no verdict, so a ring-out can show as no-media |
Treat all three as not connected; none is billed |
| No webhook arrives | URL not public, or not HTTPS | curl -X POST your own URL first |
| Signature never verifies | The framework re-serialised the body | Sign over the raw bytes |
Two more things to plan for. Single calls ignore your do-not-call list and have no calling window, so check both yourself; the docs give 09:00 to 21:00 IST as the outer bound for commercial calls. And calls over your concurrency limit (5 by default on pilot keys) wait in the queue instead of failing, so a burst rings later than you asked.
When your company has gone live, send the same requests to https://prod.voice.miraiminds.co. To call a list instead of one number, use campaigns. If the agent misbehaves on real calls, GET /v2/agents/{id}/logs and the prompt repair guide are the next places to look.
Common questions
What does one AI phone call cost?
Voice agents run from ₹1 a minute at volume (T1, with a ₹1,00,000 monthly commitment) to ₹5 a minute pay as you go (T5), billed in 10-second blocks rounded up. A 96-second call bills as 100 seconds, ₹8.34 on T5. Calls that ring out, are busy or fail to connect are free.
Is there an official Python SDK?
The docs describe one, mirai-voice, installed from Mirai's own package index rather than PyPI. This guide uses httpx directly so every request is visible; the API is small enough that plain HTTP works well.
Can I test without placing a real phone call?
Partly. There is no test key and every phone call is real. You can test the agent in the console's web phone in the browser, and verify your webhook handler against the documented test vector without placing any call.
Will Mirai stop me calling at night?
Not for single calls. POST /v2/calls dials when you ask, so enforce the 09:00 to 21:00 IST window and your own opt-outs in your scheduler. Campaigns do enforce the calling window you set on them.
Can the agent transfer the call to a person?
Not yet. Warm transfer is on the roadmap. Until then, have the agent say the team will call back, and follow up from your own system.
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.