Run a campaign over the API
Build a campaign as a draft, rotate it across a pool of numbers, steer it while it runs, and read per-contact results.
The dashboard and the API drive the same campaign engine. This page covers the parts that only matter when you are driving it yourself: building a campaign across several requests, rotating across numbers, and reading results back without pulling the whole campaign into memory.
If you are running campaigns by hand, start with the campaign guide instead.
Runnable code: this whole page as a Python and TypeScript CLI, printing each request before it sends.Open on GitHub ↗The shape of a campaign
A campaign created without save_as_draft starts dialing the moment it is
created. That is fine when you have everything up front. When you don't, create
it as a draft, build it up, and start it when it is ready.
Create the campaign as a draft
curl -X POST "https://backend.omnidim.io/api/v1/calls/bulk_call/create" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "August follow-ups",
"phone_number_id": "177",
"save_as_draft": true,
"concurrent_call_limit": 3
}'Keep the id from the response. Every request below uses it.
Add contacts in batches
Send up to 1000 per request rather than one contact per request.
curl -X POST "https://backend.omnidim.io/api/v1/calls/bulk_call/314/add_contacts" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"contacts": [
{"to_number": "+15551234567", "custom_variables": {"first_name": "Ravi"}},
{"to_number": "+15559876543", "custom_variables": {"first_name": "Priya"}}
]
}'Anything in custom_variables reaches the agent as context for that specific
call, so the agent can greet Ravi by name. Rows that fail validation come back
in rejected with the array index and a reason, and the rest are still added.
A single bad number never costs you the batch.
Set up number rotation
Optional, and covered in Rotating across numbers below. Skip it if you are dialing from one number.
Start it
curl -X POST "https://backend.omnidim.io/api/v1/calls/bulk_call/314/start" \
-H "Authorization: Bearer YOUR_API_KEY"The contact key is not the same on every endpoint. contact_list on
Create bulk call uses
phone_number with loose keys alongside it. add_contact and add_contacts
use to_number with variables inside an explicit custom_variables object.
Rotating across numbers
One number dialing an entire campaign gets reported as spam and stops being answered. Carriers score numbers on how they are used, and a number that places hundreds of unanswered calls a day degrades on its own. A pool spreads that load and rotation moves off a number before it burns out.
Everything rotation needs goes in one rotation object at creation:
{
"name": "August follow-ups",
"phone_number_id": "177",
"save_as_draft": true,
"rotation": {
"numbers": [
{"phone_number_id": 177, "sequence": 10},
{"phone_number_id": 178, "sequence": 20},
{"phone_number_id": 179, "sequence": 30}
],
"strategy": "fixed_count",
"calls_per_number": 50
}
}The policy and the numbers it applies to live in the same object on purpose. You cannot ask for rotation without saying what to rotate across, so the commonest way to get this wrong does not exist.
sequence is just the dialing order, lowest first. 1, 2, 3 works exactly like
10, 20, 30; any integers do. The tens are only a habit that leaves room to slot
a number in between later without renumbering the rest.
How the pool relates to phone_number_id
phone_number_id is always required, but what it does depends on whether you
send a rotation object:
| Request | What dials |
|---|---|
No rotation | phone_number_id makes every call. |
With rotation | The numbers in rotation.numbers make the calls. phone_number_id goes on standby and is used only if every rotation number ends up paused. |
Sending a number as phone_number_id does not put it in the rotation. That
is why the example lists 177 in both places: once as the campaign's number, once
in numbers so it takes a share of the calls. Leave it out of numbers and it
sits in reserve.
Verified behaviour, not a guess: with a rotation of one other number, a campaign
whose phone_number_id was +15550009001 dialled from +15550009002, and only
fell back to +15550009001 once every rotation number was paused.
Choosing a strategy
strategy defaults to fixed_count, so a rotation block with just numbers in
it rotates sensibly rather than sitting on the first number.
Prop
Type
When every number falls below the threshold, fallback decides what happens:
pause stops the campaign so you can add fresh numbers, continue_best keeps
dialing with the healthiest one you have.
What happens to the agent on your numbers
A rotation number has to answer as the campaign's agent. What that means depends on where the number is pointed already:
| The number's current agent | What happens |
|---|---|
| None | The campaign's agent is attached to it automatically. Nothing for you to do. |
| The same agent | Nothing changes. |
| A different agent | The request is refused and names the number. |
The last row is refused rather than repointed because the number would otherwise sit in your rotation while still belonging to another agent. Either attach it to this campaign's agent first, or leave it out.
The same rule applies to bot_id: if you name an agent that contradicts the
agent on phone_number_id, the request is refused rather than one of them
quietly winning.
Attaching an agent to a number affects more than this campaign. The attached agent also answers that number's inbound WhatsApp and SMS. It does not change inbound voice.
Watching rotation happen
List rotation pool shows which number is dialing right now and how far into its cycle it is:
{
"rotation": {"strategy": "fixed_count", "calls_per_number": 50},
"numbers": [
{
"assignment_id": 508,
"phone_number": "+15551234567",
"is_active": true,
"is_dialing_now": true,
"calls_dispatched": 124,
"calls_this_cycle": 24,
"health_score": 82.5
}
]
}Watch calls_this_cycle, not calls_dispatched. The cycle count is what
fixed_count compares against calls_per_number, so at 24 of 50 this number
has 26 calls left before rotation. calls_dispatched is its lifetime total
across every cycle it has had.
Reading results
Bulk call results returns one row per contact: what happened, the variables you sent, and a pointer to the recording.
Paging
There is one rule. Call it, then keep passing back the next_cursor you were
handed until it comes back null.
cursor = None
while True:
page = get(f"/api/v1/calls/bulk_call/314/lines",
params={"pagesize": 150, "cursor": cursor}).json()
for row in page["records"]:
handle(row)
cursor = page["next_cursor"]
if not cursor:
breakSend no cursor on the first request. Cursors are opaque: pass back the string you were given and never build one yourself.
Each call returns a page of rows, oldest first: pagesize goes up to 150 and
defaults to 30, so a 1,000-contact campaign reads back in 7 requests. No
contact is skipped or returned twice, even while the campaign is still dialing.
include_total=true adds total_records, at the cost of a count across the
whole filtered campaign. Ask for it once to fill a header, not on every page.
Getting a transcript
Rows carry call.recording_id, not the conversation. Transcripts reach 212 KB,
so a full page of them would be tens of megabytes. Fetch the ones you want:
curl "https://backend.omnidim.io/api/v1/calls/logs/50585" \
-H "Authorization: Bearer YOUR_API_KEY"A row whose call is null has not been dialed yet.
Polling a running campaign
For a progress bar, use Bulk call live status rather than walking the results. It returns aggregate counts in one query and does not grow with the campaign.
Steering a campaign while it runs
| You want to | Call |
|---|---|
| Speed up or slow down | Change concurrency |
| Stop dialing from a number going bad | Pause a pool number |
| Bring in a fresh number | Add number to pool |
| Re-queue contacts that did not connect | Retry |
| Hold overnight and resume in the morning | Set calling hours |
Pausing a number is usually better than removing it: dialing moves to the next number in sequence, and the paused number keeps its counters and history in case you want it back. You cannot pause the last active number of a running campaign, since the campaign would have nothing left to dial from.
Filtering contacts before dialing
call_conditions lets you hand over a whole list and have the campaign decide
who to actually call. It exists so you do not have to filter your export first,
and so the decision is recorded rather than lost in a spreadsheet somewhere.
The mental model
Three things to hold, and it stops being confusing:
- A condition tests one column on one contact row.
columnis the key on the row, not a field of ours. - All conditions must pass. They are ANDed. There is no OR.
- A contact that fails is kept, not deleted. It lands with
call_status: "Skipped", so the results still show it and you can check the filter did what you meant.
A worked example
Say you export every open account from your CRM, but you only want to call customers on the pro plan who owe more than 100. Send the whole export:
{
"name": "Renewals sweep",
"phone_number_id": "177",
"call_conditions": [
{"column": "plan", "operator": "equals", "value": "pro"},
{"column": "balance", "operator": "greater_than", "value": "100"}
],
"contact_list": [
{"phone_number": "+15551110001", "plan": "pro", "balance": "240"},
{"phone_number": "+15551110002", "plan": "free", "balance": "900"},
{"phone_number": "+15551110003", "plan": "pro", "balance": "12"}
]
}plan and balance are columns you invented on your own rows. Both conditions
have to pass, so only the first contact is called:
| Contact | plan | balance | Result |
|---|---|---|---|
| +15551110001 | pro | 240 | Called |
| +15551110002 | free | 900 | Skipped, wrong plan |
| +15551110003 | pro | 12 | Skipped, balance too low |
The create response tells you the split before a single call goes out:
"filtering_stats": {
"total_contacts": 3, "filtered_contacts": 1,
"skipped_contacts": 2, "filtered_percentage": 33
}Worth reading. If you meant to call most of your list and this says 5%, you have a typo in a column name rather than a very selective campaign.
The operators
| Operator | Passes when | Example |
|---|---|---|
equals | Exact match | plan equals pro |
not_equals | Anything but that | status not_equals churned |
contains | Substring, case-insensitive | email contains @acme. |
greater_than | Numerically greater | balance greater_than 100 |
less_than | Numerically less | days_overdue less_than 90 |
value is always sent as a string, including for the numeric two: send
"100", not 100.
A row whose value is not a number fails a numeric comparison rather than
erroring, so a stray "balance": "n/a" skips that one contact instead of
failing the whole request.
One difference on add_contacts
On Add contacts in bulk, a
contact whose custom_variables do not contain the condition's column at
all is rejected rather than added as Skipped. A missing column cannot be
judged either way, and dialing it on a guess is worse than saying so. Send every
column your conditions name.
