Skip to main content
Everything so far in this API is contact-stateless: POST /calls takes a number, POST /batches takes rows. That is deliberate, and it stays true. The contacts layer sits beside it for the case where you would rather Vodex kept the people — so a campaign can say “everyone in Ohio with a balance over $500” instead of a pasted CSV, and so the report that comes back joins to a person rather than to a phone number.
A campaign is not a second dialer. Launching one creates an ordinary batch — same runner, same number rotation, same calling-window logic. What a campaign adds is who.

The shape of it

1

Contacts

People, with any number of phone and email channels. Anything industry-specific is a custom attribute, never a column.
2

Audiences

A saved filter, not a saved list — so it means the same thing next month, when the people who match it are different.
3

Campaigns

An audience plus an assistant. Creating one dials nobody; launching turns it into a batch.
4

Do not contact

Two separate things: a decision about a person, and a suppression list of addresses.

Getting people in

Create them one at a time:
Or import a CSV, which is how most tenants start:
The import is synchronous and transactional — one request, one receipt, no job to poll. Headers auto-map, and an unrecognised column becomes a custom attribute rather than an error or a silent drop, so a CRM export lands with no mapping UI. Rows upsert on externalRef, else accountNumber, so re-running the same export reports updates instead of duplicates, and channels merge rather than replace: a second file listing only a mobile will not delete the work number the first one brought. Rejections carry the file line number — the one your spreadsheet shows — up to 200 of them, and the ceiling is 50,000 rows. GET /contacts/imports/template returns the headers the importer maps without configuration. Two things are rejected rather than guessed:

Audiences are filters

Stored as a filter tree, never a materialised id list. A campaign launched next Tuesday should reach the people who match next Tuesday, including the ones imported tomorrow; a frozen list would make every recurring campaign a snapshot of the day it was written. Members are therefore evaluated on read, and two calls a week apart can legitimately differ. Fields are whitelisted — attr:<key> reaches a custom attribute — and the whitelist is published with the comparators each type admits, because a client that guesses field names gets a 400. The filter is validated when you save it, not only when a campaign runs, so a typo fails now rather than on the morning of the call. Preview before committing:
Exclusions are reported separately, never subtracted. “1,204 match, 38 are suppressed” is the honest sentence; a single number that quietly already had the exclusions removed cannot be reconciled against the member list the same filter shows.

Campaigns

variables maps each assistant {{var}} to a contact field or attr:<key>; the sources are listed rather than guessed. A variable with no value for a contact makes that person a rejected enrollment at launch — not a call with a blank in the prompt. Then launch, which is the only call that dials anything:
Three things worth knowing about launch:
  • Every refusal is recorded as an enrollment with a reasondnc_account, dnc_list, opted_out, no_channel, bad_number, rejected. “Why was this person not called” is what a compliance review asks, and a batch row that was never created cannot answer it.
  • tz is injected on every row from the contact’s timezone. Calling people only during their own local hours is the entire point of having contacts.
  • Relaunch is idempotent on campaign:<id>:run:<n>, so a double-clicked launch replays instead of dialling the audience twice.
Steer it from the campaign — pause, resume, stop — rather than opening its batch. Those delegate to the same transition the batch routes use, and campaign status is derived from the batch on every read, so the two can never disagree. Set blockSize to dial the audience in blocks; nothing beyond the current block runs until you ask for the next one.
Once a campaign is live, PATCH /contacts/campaigns/{id} answers 409. Use the live routeconcurrency, retry, window, startAt, and nothing else. A scheduled campaign is running with a future startAt, so that is also how you reschedule one.

Do not contact

There are two concepts here, and neither is derived from the other:
Clearing a person’s flag removes only the entries that flag created. An address suppressed by uploading a regulator’s list survives — the alternative is one person toggling a switch and silently resuming calls the tenant is obliged to stop.
Upload a list as CSV, and ask about addresses before you dial:
Values are normalised by the same code the channel path uses before they are compared — on the way in, on upload, and here. If any two of those disagreed by so much as a space, a number on the list would still be dialled and nothing anywhere would report an error. check is a read, so a viewer may call it; POST only because it takes a list in the body.

Joining back to the call

Launch sets each row’s contactRef to the contact’s id, which is the same field you would set by hand on POST /calls — so it is echoed on the interaction.completed webhook exactly as it always was. Nothing about the report changes because a call came from a campaign. That one field is also the whole of the join: the timeline matches calls on it, with no projection to maintain and no events to replay — and it works retroactively, for calls placed before the contact record existed. Enrollments appear there too, including the ones that were never called, each with its reason in words. The compliance view gathers what a reviewer wants on one screen: consent basis and when it was captured, the contact’s local time right now, the tenant’s calling window, and attempt counts for the last seven days.
Those counts gate nothing — the response says enforced: false, because a number that looks like a limit but is not one is worse than no number at all. Attempt limits are the retry policy on the batch.

Next

Batch dialing

What a launched campaign becomes. Retry policy, number rotation, calling windows.

Webhooks

The report that comes back, with your contactRef on it.

Import contacts

Headers, upsert keys, and what a rejected row tells you.

Launch a campaign

The one call that dials, and everything it refuses to dial.