MCP is live.Set upAsk on Discord
OmniDimension

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:

RequestWhat dials
No rotationphone_number_id makes every call.
With rotationThe 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 agentWhat happens
NoneThe campaign's agent is attached to it automatically. Nothing for you to do.
The same agentNothing changes.
A different agentThe 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:
        break

Send 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 toCall
Speed up or slow downChange concurrency
Stop dialing from a number going badPause a pool number
Bring in a fresh numberAdd number to pool
Re-queue contacts that did not connectRetry
Hold overnight and resume in the morningSet 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:

  1. A condition tests one column on one contact row. column is the key on the row, not a field of ours.
  2. All conditions must pass. They are ANDed. There is no OR.
  3. 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:

ContactplanbalanceResult
+15551110001pro240Called
+15551110002free900Skipped, wrong plan
+15551110003pro12Skipped, 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

OperatorPasses whenExample
equalsExact matchplan equals pro
not_equalsAnything but thatstatus not_equals churned
containsSubstring, case-insensitiveemail contains @acme.
greater_thanNumerically greaterbalance greater_than 100
less_thanNumerically lessdays_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.

Things that catch people out

On this page