Skip to main content
A campaign is a contact list plus the rules for dialling it: which agent runs, what hours it may ring people, how many lines it may use at once, how often to retry, and how much it is allowed to spend. You upload the list once and start it. From there the platform owns the dialling — it paces itself, sleeps out the night, retries what did not connect, skips anything on your do-not-call list, pauses itself when the money runs out, and hands you a per-contact report at the end. Base URL https://sandbox.voice.miraiminds.co.
A campaign’s calls are ordinary calls. They appear in GET /v2/calls, they emit the same call.* webhooks, and they debit the same wallet at the same per-minute rate. A campaign is a dialler on top of the call API, not a separate billing path.

The campaign object

Campaign money is in paise, call money is in rupees.budget_paise and spent_paise are integer paise (100 paise = ₹1), so a ₹500 cap is "budget_paise": 50000. The call object and the wallet use rupee fields (amount_inr, balance_inr). Nothing mixes the two units inside one object.

Lifecycle

Only draft and paused can be edited or started. stopped and completed never dial again — and a campaign in either refuses new contacts with 409, rather than accepting rows that would never ring.

Pause reasons

pause_reason distinguishes “the operator stopped this” from “we stopped it for them”. A pause you asked for carries no reason (null); the two reasons below are set by the money gate.
Being outside the calling window is not a pause. The campaign stays play and sleeps until the next opening edge — a durable sleep, so a campaign scheduled for 10:00 tomorrow costs nothing overnight and survives our restarts. An offline fleet is not a pause either: it is a 503 fleet_offline refusal at start time.

Contact statuses

Every row you upload carries its own status. The first block mirrors the call status vocabulary, so a campaign report reads the same way the call list does; the last three exist only inside campaigns. exhausted is the one to watch: it is the honest count of “we tried everything you paid for and never reached this person”. A contact that ends no_answer still had attempts left; one that ends exhausted did not.

Calling window

Three things together decide whether the dialler may ring right now, all in the campaign’s own timezone:
  1. today’s local date is between start_date and end_date, inclusive; and
  2. the local HH:MM falls inside at least one slots entry.
  • 1 to 4 slots. Fewer is a 400; five is a 400.
  • A slot may cross midnight. 21:0006:00 is open on both sides of it. Legal, and almost never what you want in India — see calling rules.
  • A zero-length slot is a 400. {"start":"10:00","end":"10:00"} is rejected rather than read as “all day”: guessing wrong there means ringing somebody at 03:00.
  • Local means local. Times are stored and evaluated in timezone, not UTC, so Asia/Kolkata’s half-hour offset and every daylight-saving boundary are handled for you. This is the difference between a campaign that respects the window and one that respects it except twice a year.
When the date range runs out entirely, the campaign finishes as completed — even with contacts still pending. A window that never opens again cannot dial them.

Retries

Only outcomes that never reached a human are retried: no_answer, busy and failed. completed, voicemail and timeout all produced live media — the call happened, and re-dialling somebody who already spoke to us is worse than not calling at all. A retry that lands outside the calling window simply waits: eligibility is a lower bound, not a schedule.

Budget and pacing

max_concurrent (1–50) is this campaign’s own ceiling on live calls. It is independent of — and additionally bounded by — your workspace concurrency and queue-depth limits. Two campaigns at max_concurrent: 20 on a workspace provisioned for 5 concurrent calls will share those 5. budget_paise caps the spend of this campaign alone. When spent_paise reaches it, the campaign pauses with budget_exhausted — contacts keep their place, so raising the budget and resuming continues where it stood. The wallet is checked before every dialling tick. If it cannot cover another minute at the campaign’s tier, the campaign pauses with insufficient_balance. POST /start also does a wallet estimate up front and tells you how far your balance goes, in funded_calls.
Set budget_paise on every campaign, even when you trust the list. It is the one control that bounds a mistake in the list — a duplicated CSV, a column shifted by one — rather than a mistake in the code.

Create a campaign

Request

201 Created
The response is the campaign object itself, with three extra top-level keys — not {"campaign": …, "contacts": …}. Read the id at .id, and the upload result at .contacts_accepted / .contacts_duplicate / .contacts_rejected.

Contacts are reported row by row, not rejected as a batch

A 900-row upload with two bad numbers is not an all-or-nothing 400. The good rows are stored and the bad ones come back by index:
201 Created — two rows rejected, one duplicate
Two things do fail the whole request: more than 1000 rows (400 invalid_request), and a malformed body. Everything else is a row report. variables follow the same rules as call variables — a flat string map, 32 keys, 512 characters per value — and fill the same {{placeholders}} in the agent’s prompt. id is your own key for the row; omit it and we mint one.

Errors


List campaigns

Returns the standard paginated envelope of campaign objects, newest first. counters is omitted on list pages — fetch one campaign to get them.

Get a campaign

200 OK
counters comes from the contact table, not from the running dialler, so a finished campaign answers exactly like a live one. That is what makes this endpoint safe to build a dashboard on. 404 not_found for an unknown id and for one in another workspace — the two are deliberately indistinguishable.

Update a campaign

Editable while draft or paused only. Anything else is 409 conflict: changing the slots, the retry policy or the budget under a live dialler would mean the campaign’s own report describes settings that were never in force for half of its calls. Pause it, patch it, resume it. Patchable: name, slots, start_date, end_date, max_concurrent, retry_count, re_attempt_period_secs, max_duration_secs, budget_paise, webhook_url. Not patchable: agent_id, tier, timezone.
Returns 200 OK with the campaign object.

Add contacts

Up to 1000 rows per request. Call it repeatedly for a larger list — a running campaign accepts new contacts and will pick them up on its next tick, which is the whole point of keeping the list in a table rather than in the dialler.
200 OK
Same body as create, for the same reason: one shape for the two routes that add contacts means one parser in your client. 409 conflict if the campaign is stopped or completed — nothing would ever dial those rows, and accepting them silently is worse than refusing them.

List contacts

200 OK
last_call_id is an ordinary call_ id: fetch it from GET /v2/calls/{id} for the full outcome.

Start a campaign

Legal from draft and from paused — starting a paused campaign is how you resume one whose dialler is no longer running (after a top-up, say), because start re-runs the fleet and wallet preflight that a bare resume skips.
202 Accepted
202 means the dialler is running — not that a phone is ringing. Outside the calling window it will sit and wait, correctly, in play.

funded_calls: an underfunded campaign still starts

If your wallet cannot cover every remaining contact, the response carries a warning rather than a refusal:
202 Accepted — partially funded
The campaign starts, dials what the balance covers, and then pauses itself with pause_reason: "insufficient_balance". Top up and start it again to continue. funded_calls is a one-minute-per-call estimate at this campaign’s tier — a floor on how far you get, not a promise.

Errors


Pause a campaign

202 Accepted
Takes effect immediately, including while the campaign is asleep waiting for tomorrow’s window. Calls already live are not hung up — pause stops new calls, it does not cut off people mid-conversation. Use POST /v2/calls/{id}/abort if you really need a live call to end now. Only a play campaign can be paused; anything else is 409 conflict.

Resume a campaign

202 Accepted
409 conflict if the campaign is not paused — and also if it is paused but has no running dialler, in which case the message tells you to use POST /start instead. That is deliberate: a bare resume would skip the wallet and fleet preflight.

Stop a campaign

202 Accepted
Terminal and irreversible. Live calls are hung up, pending contacts are marked aborted, and the campaign will never dial again. Legal from draft, play and paused. If you might want to continue later, pause instead.

Campaign report

JSON — the aggregate

200 OK
connected / dialed is your answer rate. attempts / dialed is whether retry_count is earning its place.

CSV — per contact

Streamed, not buffered, so it is safe on a six-figure campaign. Join last_call_id against GET /v2/calls for durations and costs.

Webhooks

Set webhook_url on the campaign and you receive both its lifecycle events and every child call’s call.* events, signed with the same workspace secret and verified by the same code path.
POST https://example.com/mirai/webhook
Same envelope as a call event — {id, type, created_at, data} — with data.campaign where data.call would be. Verification, retries and replay protection are identical: see Webhooks.
Alert on campaign.paused with a pause_reason. That event is the platform telling you a campaign has stopped spending money for a reason you can fix in one API call.

A campaign that behaves

  1. Scrub first. Push known opt-outs to POST /v2/dnc before you upload the list. Suppression is checked at dial time, so late additions still work — but a number dialled at 10:00 cannot be un-dialled at 10:05.
  2. Keep the window legal. 09:0021:00 IST is the outer bound for commercial calls in India, and most enterprise programmes run 10:0019:00. See calling rules.
  3. Budget every campaign. budget_paise is the cheapest insurance against a bad CSV there is.
  4. Retry once, wait fifteen minutes. retry_count: 1, re_attempt_period_secs: 900 is the shape that works for most Indian mobile lists. Retrying five times in five minutes annoys people and does not connect.
  5. Start small. Ten contacts, one slot, max_concurrent: 1. Read the transcripts. Then upload the rest into the same campaign.
  6. Watch exhausted and suppressed in the report. They are the two numbers that tell you about your list rather than about your agent.