# Run a campaign over the API (/docs/bulk-calls/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](/docs/bulk-calls/overview) instead.

[

  
    <strong>Runnable code:</strong>

     this whole page as a Python and TypeScript CLI, printing each request before it sends.
  

  
    Open on GitHub ↗
  ](https://github.com/Omnidim/examples/tree/main/bulk-campaigns)

## The shape of a campaign [#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 [#create-the-campaign-as-a-draft]

  ```bash
  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 [#add-contacts-in-batches]

  Send up to 1000 per request rather than one contact per request.

  ```bash
  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 [#set-up-number-rotation]

  Optional, and covered in [Rotating across numbers](#rotating-across-numbers)
  below. Skip it if you are dialing from one number.

  ### Start it [#start-it]

  ```bash
  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](/docs/api-reference/bulk-calls/createBulkCall) 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 [#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:

```json
{
  "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` [#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 [#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.

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 [#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 [#watching-rotation-happen]

[List rotation pool](/docs/api-reference/bulk-calls/listBulkCallNumbers) shows
which number is dialing right now and how far into its cycle it is:

```json
{
  "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 [#reading-results]

[Bulk call results](/docs/api-reference/bulk-calls/listBulkCallLines) returns one
row per contact: what happened, the variables you sent, and a pointer to the
recording.

### Paging [#paging]

There is one rule. Call it, then keep passing back the `next_cursor` you were
handed until it comes back `null`.

```python
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 [#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:

```bash
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 [#polling-a-running-campaign]

For a progress bar, use
[Bulk call live status](/docs/api-reference/bulk-calls/getBulkCallLiveStatus)
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 [#steering-a-campaign-while-it-runs]

| You want to                              | Call                                                                            |
| ---------------------------------------- | ------------------------------------------------------------------------------- |
| Speed up or slow down                    | [Change concurrency](/docs/api-reference/bulk-calls/setBulkCallConcurrency)     |
| Stop dialing from a number going bad     | [Pause a pool number](/docs/api-reference/bulk-calls/setBulkCallNumberActive)   |
| Bring in a fresh number                  | [Add number to pool](/docs/api-reference/bulk-calls/addBulkCallNumber)          |
| Re-queue contacts that did not connect   | [Retry](/docs/api-reference/bulk-calls/retryBulkCall)                           |
| Hold overnight and resume in the morning | [Set calling hours](/docs/api-reference/bulk-calls/setBulkCallDailyTimeControl) |

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 [#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 [#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 [#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:

```json
{
  "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:

```json
"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 [#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 [#one-difference-on-add_contacts]

  On [Add contacts in bulk](/docs/api-reference/bulk-calls/addBulkCallContacts), 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 [#things-that-catch-people-out]

  
    Check `strategy` is not `none`, and that the other numbers are not paused. `GET
    /numbers` shows `is_active` and `is_dialing_now` for each. Rotation also only
    moves on when its trigger fires, so with `calls_per_number: 50` the first number
    takes 50 calls before anything changes.
  

  
    `retry_limit` must be between 1 and 10. Do not send `0` to mean "no retries":
    leave `auto_retry` as `false` and omit `retry_limit` entirely.
  

  
    That is deliberate. Repeated numbers are kept rather than merged, because two
    rows for one number usually means two real reasons to call, often with different
    variables. If you want one call, deduplicate before sending.
  

  
    Cursors are opaque and are validated. Pass back exactly the `next_cursor` string
    you received, or omit it to start over from the beginning.
  

  
    The number either belongs to another account, is already in the rotation, is
    listed twice in the same request, or is attached to a different agent. The error
    names which.
  

  
    Read the `Skipped` rows back from
    [Bulk call results](/docs/api-reference/bulk-calls/listBulkCallLines) and look at
    their `custom_variables`. Usually the column name does not match what is on the
    rows, or a numeric comparison is hitting values that are not numbers. Conditions
    are ANDed, so one wrong condition skips everything.