Start building
Run bulk AI calls from a CSV with a Mirai campaign
A campaign is a contact list plus the rules for dialling it. Upload a CSV in the console, or send the rows to POST /v2/campaigns from your code, and the platform calls each number inside your calling hours, retries the ones that didn't connect, skips your do-not-call list and stops at your budget. Each call's webhook names the campaign and contact and carries your own columns.
How does a campaign work?
You upload the list once and press start. The platform paces itself, waits through the night, retries calls that didn’t connect, skips numbers on your do-not-call list, pauses when the money runs out and gives you a per-contact report. Campaign calls are ordinary calls: they appear in GET /v2/calls, send the same call.* webhooks and draw on the same wallet.
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), and a campaign’s calls are billed at your plan’s rate in 10-second blocks. A new sandbox workspace gets ₹200 of credit at T5, which is 40 billed minutes (₹200 / ₹5) for a small first run. For the business case, see AI calling.
| 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 the campaign 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 from this campaign live at once |
retry_count |
0 to 5, default 0 | Retries after the first attempt |
re_attempt_period_secs |
60 to 86400, default 900 | Minimum wait before a retry |
max_duration_secs |
30 to 1800 | Per-call cap; 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 and call money is in rupees, and no object mixes the two.
How do you prepare the CSV?
One row per person with a header row. You need a phone column, a column for each variable the agent’s prompt reads, and any extra columns you want back in webhooks and reports:
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 agent’s {{customer_name}} and {{order_id}}. crm_id and branch are extra info: they become the contact’s metadata, which the agent never sees and which comes back on every webhook and in the report. Metadata takes up to 16 keys, with string values of up to 256 characters. If the agent declares its variables in an input_schema, a column that isn’t declared rejects the row, so send it as metadata instead.
The console adds +91 to a ten-digit Indian mobile number for you. The API does not; send E.164 there. If you edit the file in Excel, format the phone column as Text first, or numbers turn into 9.83E+09.
Option 1: upload it in the console
- Open Campaigns and press New campaign. Name it, pick the agent, choose which number calls come from, and set the timezone.
- Set the first and last day and up to four calling windows a day.
- Set calls at once, retries, the retry gap, a maximum call length and a budget. You can also skip numbers that answered in an earlier campaign, and add a webhook URL.
- Under Contacts, press Download template for a CSV with exactly the columns your agent reads, or upload your own export and match its columns under Columns.
- Check the preview. It shows how many numbers are ready, how many duplicates were removed, and every unusable row with its reason, before anything is created.
- Press Create campaign. It is saved as a draft, and nothing dials until you press Start.
While it runs, the Contacts tab shows each person’s status, tries and result, with the recording and transcript one click away. Download CSV gives one row per contact with your own columns, the result, a summary, the transcript and a recording link.
Option 2: send it from your code
This script reads the CSV above, creates a draft campaign with the first 1,000 rows, adds the rest in batches and prints the rows Mirai rejected. Install httpx==0.28.1 first.
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))
The create response is the campaign object itself, with contacts_accepted, contacts_duplicate and contacts_rejected added at the top level. Bad rows are reported by their index in the array you sent and the rest are stored; only a body over 1,000 rows or a malformed request fails as a whole. A number repeated in the list counts as a duplicate and is called once.
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, which doesn’t mean a phone is ringing: outside the calling window the campaign waits in play. funded_calls appears when the wallet can’t cover every remaining contact. The campaign still starts, dials what the balance covers and then pauses itself.
We ran the CSV-reading part on the sample file above: both rows came out with E.164 numbers, their variables and their metadata. The API requests were checked against the Campaigns reference and not run, because a started campaign places real calls.
What happens while it runs?
The dialler rings only when today’s local date is inside the date range and the local time is inside a slot. A retry that falls due outside the window waits for it. Only no_answer, busy and failed are retried. A contact that used every attempt without connecting ends exhausted, and a number on your do-not-call list ends suppressed, never dialled and never billed.
A campaign pauses itself with a pause_reason: insufficient_balance when the wallet can’t cover another minute, budget_exhausted when it reaches budget_paise. Contacts keep their place. Top up or raise the budget with PATCH, then start it again. While it runs you can only change max_concurrent and slots; pause it to change anything else.
Every call.* webhook from a campaign call carries data.call.campaign_id, data.call.contact_id and the contact’s metadata, and with final_results on, each call ends with one call.processed that includes the transcript. The campaign’s URL also gets campaign.started, campaign.paused, campaign.resumed, campaign.completed and campaign.stopped. Alert on campaign.paused with a reason: it means the campaign stopped spending for something you can fix in one request. The webhook guide covers the receiver.
To run a queue that keeps growing, set always_on: true. The campaign stays open after its list is done and calls the contacts you add later under the same rules, until its end_date.
How do you read the results?
GET /v2/campaigns/{id}/report returns the totals: contacts by final status, contacts, dialed, attempts, connected and spent_inr. connected / dialed is your answer rate, and attempts / dialed shows whether retries are paying off. Add ?format=csv for one row per contact with its status, attempts and last_call_id. GET /v2/campaigns/{id}/contacts?status=exhausted lists the people you never reached.
To estimate a run before you start, multiply out answered calls. With 2,000 contacts, 35% answered and a 60-second average call: 2,000 × 0.35 = 700 answered calls, 700 × 1 minute × ₹5 = ₹3,500 on T5, before GST. The 1,300 unanswered dials cost nothing. Rounding each call up to whole 10-second blocks makes the real figure slightly higher; the pricing guide shows how to forecast with the rounding included.
What can go wrong?
| What you see | Why | Fix |
|---|---|---|
409 window_closed on start |
The calling window can never open again, often an end_date of today with a slot that has passed |
PATCH a later end_date or another slot, then start again |
Rows in contacts_rejected |
Usually a phone number that isn’t E.164 | Fix the number; the rest of the batch is already stored |
| Started, but nothing dials | Outside the date range or slots, in the campaign’s timezone | Check timezone, start_date and slots |
Paused with insufficient_balance |
The wallet can’t cover another minute | Top up, then POST /start |
Paused with budget_exhausted |
spent_paise reached budget_paise |
Raise the budget with PATCH, then resume |
409 conflict on PATCH |
A setting other than pace or slots changed while dialling | Pause, patch, resume |
409 conflict adding contacts |
The campaign is stopped or completed | Create a new campaign or duplicate this one |
Fewer calls at once than max_concurrent |
Your workspace limit is shared by every campaign; 5 is the pilot default | Ask Mirai to raise it |
503 campaigns_unavailable |
Campaigns are not switched on for the workspace | Ask Mirai to enable them |
503 fleet_offline on start |
Speech capacity is offline | Wait for Retry-After; nothing is charged |
Start with ten contacts, one slot and max_concurrent: 1, and read the transcripts before you upload the rest. The docs suggest retry_count: 1 with a 15-minute gap for most Indian mobile lists. Keep the window legal: the docs give 09:00 to 21:00 IST as the outer bound for commercial calls, and many programmes use 10:00 to 19:00. Push known opt-outs to POST /v2/dnc before you upload, and set a budget on every campaign, because it limits the cost of a duplicated or shifted CSV.
If you want branching scripts for the calls themselves, read prompt-based versus node-based agents. For one call at a time, see the Python phone-call guide.
Common questions
How many contacts can one campaign take?
The API takes up to 1,000 contacts per request, so send a larger list in batches to POST /v2/campaigns/{id}/contacts. The console takes your whole CSV file, and the docs describe the per-contact CSV report as safe on a six-figure campaign.
Which calls get retried?
Only calls that never reached a person: no_answer, busy and failed. A contact is tried up to retry_count + 1 times, at least re_attempt_period_secs apart, and only inside the calling window. Completed, voicemail and timeout calls are never retried.
What does a campaign cost?
Campaign calls are billed exactly like single calls: from ₹1 a minute at volume (T1, with a ₹1,00,000 monthly commitment) to ₹5 a minute pay as you go, in 10-second blocks. Unanswered dials and do-not-call suppressions are free. Set budget_paise to cap one campaign's 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 or branch in metadata; the agent never sees it.
Does the campaign scrub numbers against the national DND registry?
No. It skips numbers on your workspace's own do-not-call list at dial time. Scrubbing against TRAI's DND/NCPR register and getting consent remain your responsibility.
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.