Guide · Start building
Make an AI phone call from Python with the Mirai API.
In ten minutes your Python script will place a real call in Hindi and get the transcript back. You create an agent once, place the call, and catch the result on a small Flask webhook.
AI agents from ₹1 a minute.¹
You need a key, a number and a webhook URL.
- A secret key. In the sandbox, open Developers and press Create key. Confirm your work email and the wallet gets ₹200 of credit. You also get a
whsec_webhook secret. - A number you’re allowed to call, in E.164 form:
+919876543210. - A public HTTPS URL for webhooks.
ngrok http 3000is fine for now. pip install "httpx==0.28.1" "flask==3.1.2".
Calls cost ₹1 a minute on our volume plan, ₹5 pay as you go. Until your company finishes Go live, every call bills at ₹5. The pricing guide has the detail.
export MIRAI_API_KEY="sk_live_..."
export MIRAI_WEBHOOK_SECRET="whsec_..."
First, create the agent once.
The agent holds the prompt, the opening line, the voice, the language and a time limit. Make it once.
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_...
Voices are ashu, neha, shruti and sameer. Keep voice.language equal to language; the voice follows the top-level one. Each call’s variables fill the {{placeholders}}. Before you scale up, read the prompting guide on Hinglish and ending a call.
Then place the call with one request.
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 queued, not ringing. Save call["id"] against your order at once, because a webhook can land before this request returns.
Build the Idempotency-Key from your own record. Resend a timed-out request with the same key and you get the first response back, not a second call. Keys last 24 hours.
variables only fill the prompt. metadata (up to 16 string keys) comes back on every webhook, so your IDs go there. final_results: true sends the transcript in one call.processed event at the end.
Last, catch the result on a signed 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)
Point your tunnel at port 3000 and use its URL as webhook_url in step 2. In production, store each event before you answer 200 and dedupe on event["id"], as the webhook guide shows.
We tested verify against the docs’ test vector. The other two requests match the quickstart field for field; we didn’t run them, as they place a real call.
Each call ends with an event, then a transcript.
Each call ends with one of call.completed, call.voicemail, call.failed or call.aborted, carrying data.call.status and ended_reason. A completed call also has duration_secs, the billed cost and data.wallet.balance_inr.
Usually within two minutes, call.processed brings the transcript. Each turn has role, text, start_ms and end_ms, in the language it was spoken in, Hinglish included. Over 512 KB it arrives as fetch_required, so read GET /v2/calls/{id}/transcript. GET /v2/calls/{id}/recording gives a 15-minute signed link. We keep both for 90 days.
No public URL? Poll GET /v2/calls/{id}, every 5 seconds at most. It counts against your rate limit.
These are the errors you are most likely to hit.
401 unauthorized: missingBearerprefix, or a stray newline in the key.402 insufficient_balance: checkGET /v2/walletand top up under Billing.400 invalid_requestontoorvoice_id: send+919876543210, not9876543210, and one of the four voices.409 duplicate_call: the first request with that key is still running. Retry shortly.403 contract_unavailable: you sent atierthat isn’t your plan’s. Leave it out.429 rate_limited: too many requests, or 500 calls already queued. Wait forRetry-After.no_answer, orfailedwithno-media: it didn’t connect. Some carriers report a ring-out asno-media. Neither is billed.- No webhook: the URL isn’t public HTTPS.
curl -X POSTit yourself. - Signature never matches: your framework re-serialised the body. Sign the raw bytes.
- A burst rings late: calls over your concurrency limit (5 on pilot keys) wait in the queue.
- Single calls skip your do-not-call list. Check it yourself.
Here is what to do after your first call.
After go-live, send the same requests to https://prod.voice.miraiminds.co. For a whole list, use campaigns. If the agent goes off script, check GET /v2/agents/{id}/logs and the prompt repair guide.
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.
- docs.miraiminds.co
- Voice-agent API quickstart
- docs.miraiminds.co
- Calls: create, get, transcript, abort
- docs.miraiminds.co
- Agents
- docs.miraiminds.co
- Webhooks and signature verification
- docs.miraiminds.co
- Limits and India calling rules
- docs.miraiminds.co
- Billing and tiers
Questions
These are the questions developers ask us most.
What does one AI phone call cost?
Calls bill in 10-second blocks, rounded up, so a 96-second call bills as 100 seconds: ₹8.34 pay as you go. Missed, busy and failed calls are free.
Is there an official Python SDK?
Yes, mirai-voice, from our own package index, not PyPI. We use plain httpx here so you can see every request.
Can I test without placing a real phone call?
Partly. There's no test key, so every phone call is real. But you can talk to the agent in the console's web phone, and check your webhook against the docs' test vector.
Will the API stop me calling at night?
Not for single calls. Keep to 09:00 to 21:00 IST and honour opt-outs in your own code. Campaigns keep to the window you set.
Can the agent transfer the call to a person?
Not yet, though it's on the roadmap. For now, have the agent promise a callback.
Still deciding? Ask us.
Fifteen minutes with the team that builds it. Bring your script, or your hardest call.
Book 15 minutes