Guide · Start building
Run bulk AI calls from a CSV with a Mirai campaign.
Upload a CSV this afternoon and an agent calls every row in Hindi, English, Gujarati, Tamil or Telugu, inside your calling hours. Missed calls get retried, spending stops at your budget, and each result comes back with your own columns attached.
AI agents from ₹1 a minute.¹
Upload the list once and the campaign does the rest.
Upload the list once and press start. The campaign paces itself, waits out the night, retries calls that didn’t connect, skips your do-not-call list and pauses when the money runs out.
Its calls are ordinary calls: same call.* webhooks, same wallet, all in GET /v2/calls. The ₹200 of sandbox credit covers 40 minutes at the pay-as-you-go rate.
| Setting | Range | What it controls |
|---|---|---|
timezone |
IANA name, such as Asia/Kolkata |
How dates and slots are read |
start_date, end_date |
YYYY-MM-DD, inclusive |
The days it may dial |
slots |
1 to 4 windows of HH:MM |
The hours it may dial each day |
max_concurrent |
1 to 50, default 1 | Calls live at once |
retry_count |
0 to 5, default 0 | Retries after the first try |
re_attempt_period_secs |
60 to 86400, default 900 | Shortest gap before a retry |
max_duration_secs |
30 to 1800 | Longest call; defaults to the agent’s |
budget_paise |
Positive integer paise | Spend cap; ₹5,000 is 500000 |
final_results |
Default true |
Each call ends with a call.processed event carrying the transcript |
Campaign money is in paise. Call money is in rupees.
Each row of the CSV is one person.
One row per person, a header row, a phone column, and a column for each variable the prompt reads:
phone,customer_name,order_id,crm_id,branch
+919876543210,Rohit Sharma,AC-88213,L-17,Pune
9876543211,Anita Desai,AC-88219,L-18,Mumbai
customer_name and order_id fill the prompt’s {{placeholders}}. crm_id and branch become metadata: the agent never sees them, but they come back on every webhook and in the report. That’s up to 16 keys, 256 characters each. If the agent has an input_schema, a column it doesn’t declare rejects the row, so send it as metadata.
The console adds +91 to ten-digit mobiles. The API doesn’t, so send E.164 there. In Excel, format the phone column as Text first, or numbers turn into 9.83E+09.
You can upload the list in the console.
- Campaigns → New campaign. Pick the agent, the calling number and the timezone.
- Set the dates, up to four calling windows a day, calls at once, retries, a budget and an optional webhook URL.
- Under Contacts, Download template gives a CSV with your agent’s exact columns. Or upload your own export and match it under Columns.
- The preview shows numbers ready, duplicates removed and every bad row with its reason.
- Create campaign saves a draft. Nothing dials until you press Start.
The Contacts tab shows each person’s result, recording and transcript. Download CSV gives one row per contact with your columns, the result, a summary and the transcript.
Or send the same list from your code.
This script reads the CSV above, creates a draft with the first 1,000 rows, adds the rest in batches and prints the rows we rejected. It needs httpx==0.28.1.
import csv
import os
import re
import sys
import httpx
API = "https://sandbox.voice.miraiminds.co"
auth = {"Authorization": f"Bearer {os.environ['MIRAI_API_KEY']}"}
AGENT_ID = "agt_..." # your agent
VARIABLES = ("customer_name", "order_id") # columns the prompt reads
METADATA = ("crm_id", "branch") # your own columns, never shown to the agent
def e164(raw: str) -> str:
digits = re.sub(r"[^\d+]", "", raw)
if re.fullmatch(r"[6-9]\d{9}", digits): # a ten-digit Indian mobile
return "+91" + digits
return digits
def load(path: str) -> list[dict]:
contacts = []
with open(path, newline="", encoding="utf-8-sig") as f:
for line, row in enumerate(csv.DictReader(f), start=2):
contact = {
"id": row.get("crm_id") or f"line-{line}",
"phone": e164(row["phone"]),
"variables": {k: row[k].strip() for k in VARIABLES},
}
metadata = {k: row[k].strip() for k in METADATA if row.get(k)}
if metadata:
contact["metadata"] = metadata
contacts.append(contact)
return contacts
contacts = load(sys.argv[1])
batches = [contacts[i:i + 1000] for i in range(0, len(contacts), 1000)] or [[]]
campaign = httpx.post(f"{API}/v2/campaigns", headers=auth, timeout=60, json={
"name": "Order confirmations, October",
"agent_id": AGENT_ID,
"timezone": "Asia/Kolkata",
"start_date": "2026-10-19",
"end_date": "2026-10-23",
"slots": [{"start": "10:00", "end": "13:00"}, {"start": "16:00", "end": "19:00"}],
"max_concurrent": 5,
"retry_count": 1,
"re_attempt_period_secs": 900,
"budget_paise": 500000, # ₹5,000
"webhook_url": "https://example.com/mirai/webhook",
"contacts": batches[0],
}).raise_for_status().json()
results = [campaign]
for batch in batches[1:]:
r = httpx.post(f"{API}/v2/campaigns/{campaign['id']}/contacts",
headers=auth, json={"contacts": batch}, timeout=60)
results.append(r.raise_for_status().json())
for batch, result in zip(batches, results):
for bad in result["contacts_rejected"]:
print(batch[bad["index"]]["id"], bad["phone"], bad["reason"])
print(campaign["id"], campaign["status"],
"accepted", sum(r["contacts_accepted"] for r in results),
"duplicates", sum(r["contacts_duplicate"] for r in results))
You get the campaign back with contacts_accepted, contacts_duplicate and contacts_rejected. Bad rows are listed by their index in your array, and the rest are stored. Only a malformed body, or one over 1,000 rows, fails outright.
When the draft looks right, start it:
started = httpx.post(f"{API}/v2/campaigns/{campaign['id']}/start", headers=auth, timeout=30)
print(started.raise_for_status().json()) # {"id": "cmp_...", "status": "play", "funded_calls": ...}
202 means the dialler is running, not that a phone is ringing. Outside the window it waits in play. If the wallet can’t cover every contact left, you’ll see funded_calls: it dials what the balance covers, then pauses.
We ran the CSV part on the sample file and both rows came out right. The API calls match the reference; we didn’t run them, as a started campaign rings real phones.
The campaign keeps to your hours and pauses itself.
It dials only on your dates, inside a slot, in the campaign’s timezone. A contact who used every try ends exhausted. A number on your do-not-call list ends suppressed, never dialled and never billed.
It pauses itself with a pause_reason: insufficient_balance when the wallet runs dry, budget_exhausted when spent_paise reaches budget_paise. Contacts keep their place. Top up or PATCH the budget, then POST /start again. Mid-run you can change only max_concurrent and slots.
Besides the call.* events, the campaign’s URL gets campaign.started, campaign.paused, campaign.resumed, campaign.completed and campaign.stopped. Alert on campaign.paused; it’s always something you can fix in one request. The webhook guide covers the receiver.
For a queue that keeps growing, set always_on: true. It then calls anyone you add, until its end_date.
The report shows who answered and what you spent.
GET /v2/campaigns/{id}/report gives contacts by final status, plus contacts, dialed, attempts, connected and spent_inr. connected / dialed is your answer rate. attempts / dialed shows whether retries pay. Add ?format=csv for one row per contact with last_call_id, and GET /v2/campaigns/{id}/contacts?status=exhausted for the people you never reached.
A quick estimate: 2,000 contacts, 35% answer, 60 seconds each. That’s 2,000 × 0.35 = 700 calls, and 700 × 1 minute × ₹5 = ₹3,500 pay as you go, before GST. The 1,300 unanswered dials are free. Rounding up to 10-second blocks adds a little; the pricing guide shows how much.
These are the usual reasons a campaign stalls.
409 window_closedon start: the window can’t open again, often anend_dateof today after the last slot.PATCHa later date or slot.- Rows in
contacts_rejected: usually a number that isn’t E.164. The rest of the batch is stored. - Started, nothing dials: check
timezone,start_dateandslots. 409 conflictonPATCH: pause first, then patch.409 conflictadding contacts: the campaign is stopped or completed. Start a new one.- Fewer live calls than
max_concurrent: all campaigns share your workspace limit, 5 on pilots. Ask us to raise it. 503 campaigns_unavailable: campaigns aren’t on for your workspace yet. Ask us.503 fleet_offlineon start: wait forRetry-After. Nothing is charged.
Test with ten contacts before the big run.
Try ten contacts, one slot and max_concurrent: 1, and read the transcripts. For Indian mobiles, the docs suggest retry_count: 1 with a 15-minute gap.
Keep to legal hours: 09:00 to 21:00 IST at the outside, and many teams use 10:00 to 19:00. Push known opt-outs to POST /v2/dnc first. Put a budget on every campaign, so a duplicated CSV can’t run up a bill.
For branching scripts, see prompt-based versus node-based agents. For one call at a time, see the Python phone-call 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
- Campaigns API reference
- docs.miraiminds.co
- Campaigns in the console
- docs.miraiminds.co
- Do-not-call list
- docs.miraiminds.co
- Limits and India calling rules
- docs.miraiminds.co
- Webhooks and final results
Questions
These are the questions developers ask us most.
How many contacts can one campaign take?
The API takes 1,000 per request, so send bigger lists in batches to POST /v2/campaigns/{id}/contacts. The console takes the whole CSV, and its report holds up on six-figure campaigns.
Which calls get retried?
Only no_answer, busy and failed. Each contact gets up to retry_count + 1 tries, at least re_attempt_period_secs apart, inside the calling window. Completed, voicemail and timeout calls never retry.
What does a campaign cost?
The same as single calls: ₹1 a minute on our volume plan, ₹5 pay as you go. Unanswered dials and do-not-call skips are free, and budget_paise caps the spend.
How do I know which of my records a call was for?
Every call.* webhook from a campaign carries data.call.campaign_id, data.call.contact_id and the contact's metadata. Put your CRM ID there; the agent never sees it.
Does the campaign scrub numbers against the national DND registry?
No. It skips your own do-not-call list. Checking TRAI's DND/NCPR register, and getting consent, is on you.
Still deciding? Ask us.
Fifteen minutes with the team that builds it. Bring your script, or your hardest call.
Book 15 minutes