Campaigns
Call a list of your own customers with a hosted agent inside the hours you choose, with per-outcome retries and a do-not-call register.
A campaign is one contact list, one hosted agent, one or more caller-ID
numbers, a set of local calling hours and a per-outcome retry policy. OpenPhonex
places every attempt as an ordinary outbound call, so the same policy, wallet,
queue and evidence rules apply as for a single POST /v1/calls.
Use a campaign when your list is ready to be called and you want OpenPhonex to own the scheduling. Use a customer application when your own system owns the cadence and must decide each call.
Who you may call
Campaigns are self-serve for two consent bases, declared per campaign:
consent_basis | Meaning |
|---|---|
existing_customer | People with an active relationship with your business |
explicit_consent | People who agreed in writing to be called by you |
Cold sales, telemarketing, lead generation and purchased, scraped or listed
contacts stay blocked platform-wide; a campaign with such a purpose is refused
when it is created and again at launch. Campaigns are open to every active
workspace -- there is no per-profile campaign switch -- and launch runs one
authorization against your workspace capabilities: a sanctioned destination
blocks it, a suspended or not-yet-active workspace or a record without voice
holds it, a destination outside your allowlist holds it for the usual
new-country review, a missing consent basis blocks it, and on the custom
implementation-services profile every launch goes to an operator
(needs_review.custom_always_review). The full order and reason codes are in
Policy & outbound. Your caps (calls per
day, per minute, unique destinations, spend) are enforced per call while the
campaign runs: the wizard's review step shows each check for your draft and
how many days the list takes under the tighter of your daily call cap and
your unique-destinations cap -- a 5,000-contact list runs for 25 days under a
200-per-day cap, 34 under the default profile's 150 unique destinations per
day.
Contact lists
Create a list and import rows in one request. A row needs phone_e164; every
other field is optional.
The workspace wizard builds the same request from a CSV or an .xlsx
workbook: it reads the file in your browser — the first sheet, the first row
as column names — maps the columns, shows you every row it could not use, and
posts the rows this endpoint takes. The API itself never parses a spreadsheet,
so a workbook and its CSV export produce the same list and the same per-row
report. Date and number cells arrive as the text the sheet displays, not as
Excel serial numbers; keep an account reference or a phone number in a
text-formatted column, because a spreadsheet stores a number as a
15-significant-digit float and a longer one has already lost digits before any
export.
curl -X POST https://api.openphonex.com/v1/contact-lists \
-H "Authorization: Bearer $OPENPHONEX_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"organization_id": "org_123",
"project_id": "proj_123",
"name": "September renewals",
"contacts": [
{"phone_e164": "+15550100", "name": "Ama", "external_reference": "crm-4812",
"variables": {"plan": "gold", "renewal_date": "2026-09-30"}}
]
}'-
time_zonedefaults from the number's country (+233…becomesAfrica/Accra); set it per row when you know better. -
variablesare at most 28 keys, never secrets. They reach the agent ascall_contexttogether with the campaign metadata every attempt carries (campaign_id,campaign_run_id,attempt_no,contact_name), and the whole context must fit 32 keys / 4 KB: a row that would not is rejected at import, never at dial time. Those four names are reserved. -
A variable is a string or a number, or a list of flat objects of them: one debtor, several loans. A list holds at most 16 items of at most 16 keys each; item keys are letters, digits and underscores (a workflow names them in placeholders), values are strings or numbers under 512 characters,
true/falseis refused, and the 4 KB budget still applies to the whole row. A list is spoken only through a workflow section — see Several obligations on one account.{"phone_e164": "+12025550100", "name": "Nimal Perera", "language": "si", "variables": {"obligations": [ {"reference_id": "LN-1", "overdue_amount": "45300.00", "currency": "LKR", "due_date": "2026-08-01"}, {"reference_id": "LN-2", "overdue_amount": "1200.00", "currency": "LKR", "due_date": "2026-08-28"} ]}}In the workspace wizard, turn on Several rows per phone number when the sheet has one row per loan: rows sharing a number become one contact, the first row supplies the contact fields, and every row's other columns become one item of that contact's
obligationslist. Grouped rows are reported as joined, not as duplicates; a seventeenth row for one number is flagged, never dropped. Through the API, aggregate before you send: a second row with the same number in one request is stillskipped_duplicates. -
sourcerecords where the rows came from and is returned on the list:api,csv,xlsx, ormcp. It is a label, not a parser — send whichever one is true of your own import. It defaults toapion this endpoint and tomcpthrough the MCP tool. -
A row's
countryis derived from its number; a declared value only fills in for a number no calling code attributes, so a mislabelled row cannot steer the launch-time country check. -
The response carries a per-row report:
accepted,rejected,skipped_duplicatesandrejections[]with the row number and reason. Re-uploading the same numbers is idempotent. At most 10,000 rows per request.
Append later with POST /v1/contact-lists/{contact_list_id}/contacts.
Languages
Each contact's language is the language its calls are made in, and the
campaign's language is the default for contacts without one. The AI agent
must publish that language (multilingual agents):
every attempt is admitted against the agent's published profiles and dials
with that language's voice stack, so one campaign can carry si, ta and
en contacts on one multilingual agent. A contact whose language the agent
does not publish is never dialed; it ends pre_dial_failed with
skip_reason language_not_published, and a contact with no language on a
multilingual agent with no campaign default ends language_required. Both
appear in the contact page and the export.
Two things a language does not change: a conversation workflow speaks the text its author wrote, word for word, in whichever language it was written — a workflow campaign that must sound different per language uses one agent (and one campaign) per language; and the calling window is the contact's time zone, not its language.
Do-not-call register
POST /v1/do-not-call with phone_numbers[] adds numbers to the organization
register. It is honoured on every outbound path, campaigns and the direct calls
API alike, and by every campaign at launch (those contacts are marked and never
dialed). A hosted agent adds a number itself with the
set_call_disposition tool and the do_not_call outcome, and a workflow
cease_contact interrupt does the same. GET /v1/do-not-call lists the
register; DELETE /v1/do-not-call/{phone_number} removes one entry.
Create and launch
curl -X POST https://api.openphonex.com/v1/campaigns \
-H "Authorization: Bearer $OPENPHONEX_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"organization_id": "org_123",
"project_id": "proj_123",
"name": "September renewals",
"agent_id": "agent_123",
"from_number_ids": ["num_123", "num_456"],
"contact_list_id": "clist_123",
"purpose": "customer_outreach",
"consent_basis": "existing_customer",
"time_zone_mode": "contact",
"time_zone": "Africa/Accra",
"calling_windows": {"monday": [{"start": "08:00", "end": "19:00"}], "tuesday": [{"start": "08:00", "end": "19:00"}]},
"retry_policy_preset": "sales",
"max_concurrency": 2,
"reserved_concurrency": 1,
"scheduled_start_at": "2026-09-08T08:00:00Z"
}'Then POST /v1/campaigns/{campaign_id}/launch. The response carries
authorization.decision (allowed, needs_review or blocked) and
reason_code, plus the run and its counters. After launch the audience and
authorization are fixed; run settings can still change while the campaign is
paused (see Edit a campaign), and
POST /v1/campaigns/{campaign_id}/duplicate copies it into a new draft for a
new audience. pause, resume and cancel control the run; cancel also
cancels the campaign's own attempts that are still queued or held by the
live-call gate (a direct
POST /v1/calls request that reuses the campaign id as its
external_group_reference is not touched).
Calling hours
calling_windows is {weekday: [{start, end}]} in HH:MM, at most four
windows a day, never spanning midnight. With time_zone_mode: "contact" each
contact's own zone applies and time_zone is the fallback; with "fixed" the
one zone applies to everyone. A contact outside its window is deferred to the
next open window, never dropped. Defaults follow the destination country
(Ghana: 08:00–19:00 Monday to Saturday).
Retries
retry_policy maps a disposition to {retry, backoff_seconds[], max_attempts},
where max_attempts counts the first dial and the last backoff entry repeats.
Only attempts that reached the person count: an attempt refused before the line
rang because the campaign was paused, the contact's calling window had closed
since it was claimed, or the number joined your do-not-call register is
deferred (or, for the register, closed as do_not_call) and spends none of
the contact's attempts.
Presets: sales (no answer ×4 over three days, busy ×3, voicemail ×2),
collections (more persistent on no answer), single_attempt. A completed
conversation, a wrong party, a refusal, an invalid number or a do-not-call
request is never redialed by any policy; a requested callback dials at the
requested time.
Concurrency
max_concurrency caps the campaign's simultaneous calls and never exceeds your
plan's concurrent-call allowance; reserved_concurrency keeps lines free for
inbound and other calls.
Dispositions
Every attempt ends in exactly one disposition, derived from the call's structured termination cause and the agent's own signal:
connected_completed, connected_wrong_party, connected_callback_requested,
connected_refused, no_answer, busy, voicemail, invalid_number,
carrier_rejected, do_not_call, cancelled, failed_technical.
A hosted agent declares the connected outcomes with the set_call_disposition
builtin tool (a callback requires callback_at). Telephony outcomes come from
the SIP result and are never model-supplied.
Read progress
GET /v1/campaigns/{campaign_id}returns the campaign, its latest run and contact counters (pending,scheduled,dispatched,completed,failed,skipped,pre_dial_failed,do_not_call,audience,remaining).GET /v1/campaigns/{campaign_id}/contacts?status=pages through the run's contacts with disposition, attempt count, next attempt and last call id, which opens the ordinary call evidence;GET /v1/campaigns/{campaign_id}/resultsadds each contact's last-call business outcome.- Webhooks:
campaign.launched,campaign.contact.completed,campaign.completed. Per-call events are unchanged and carryexternal_reference(the contact's reference) andexternal_group_reference(the campaign id).
Edit a campaign
A draft (or a campaign held for review) accepts every setting through
PATCH /v1/campaigns/{campaign_id}, the same validation as create. After
launch the audience and the authorization are fixed: agent, contact list,
purpose and consent basis cannot change (duplicate the campaign for a new
audience). While a campaign is scheduled or paused you can still change:
name,from_number_idsandlanguage;calling_windows,time_zone_modeandtime_zone. Contacts that were waiting for the old window are re-evaluated on the next dispatcher tick; retries waiting on a backoff keep their time;retry_policyorretry_policy_preset,max_concurrencyandreserved_concurrency;ends_at, andscheduled_start_atwhile the run has not started.
A draft may also receive a new audience in the same request: send contacts
(the same rows as a contact-list import) and an optional contact_list_name
and the list is created inside the edit, so a status that locks the audience
refuses before any list exists.
A running campaign must be paused first. Every campaign carries
editable_fields for its current status, and the PATCH response adds
changed, the settings that actually changed. A setting that is locked in the
current status answers 409, as does a status change that raced the edit
(pause, resume, cancel or launch): reload and try again. In the workspace,
Edit on a campaign opens the same wizard with the locked steps disabled;
Duplicate opens the copy in the wizard before it is launched.
Export results
GET /v1/campaigns/{campaign_id}/export downloads the latest run as a CSV
attachment with a fixed column set per contact: phone_e164, name,
external_reference, language, time_zone, status, disposition,
attempt_count, next_attempt_at, callback_at, skip_reason,
last_call_id, last_call_status, termination_cause,
termination_detail and updated_at, then the last call's business outcome
(below). Rows follow the list order and cover
the whole run up to the list cap (50,000 rows); X-OpenPhonex-Row-Count
carries the count and X-OpenPhonex-Truncated says whether rows beyond the
cap were left out. phone_e164 is written verbatim: import that column as text in a
spreadsheet so the leading plus survives. The export is bound to the credential's own
workspace (a caller-selected organization_id is ignored). Uploaded variables are not echoed back, and free-text cells that
start with a formula character are prefixed so a spreadsheet does not execute
them. The workspace offers the same file as Export CSV on the campaign.
Business outcomes
Every exported row carries what the contact's last call concluded, read from the call's own evidence:
workflow_status,workflow_outcome_type(review_candidate,informationalorterminal_note),workflow_outcome(the outcome step the conversation workflow landed on, for examplepromise_review) andworkflow_outcome_label(that step's label);workflow_interrupt: the global interrupt that ended the conversation, if one did (wrong_party,cease_contact,dispute,hardship,callback,human_request,language_change);extraction_status(empty when no extraction was requested for the call; otherwisepending,running,readyorfailed),extraction_schema_version(the schema version the row's cells were extracted under; the column layout follows the AI agent's current schema) and oneextraction.<field>column per field of the AI agent's post-call extraction schema, filled only when the status isready. Extraction is advisory model output; an empty cell means the field was not extracted, never that it was extracted as empty;recording_availableandtranscript_available(true/false): whether the call's recording and transcript exist, whichGET /v1/calls/{call_id}/recordingand the call evidence serve.
A contact that was never dialed carries the empty outcome. Booleans are
written as true/false; the outcome label and every extraction cell are
formula-guarded like the other free text.
GET /v1/campaigns/{campaign_id}/results?status=&limit=&offset= is the same
information as JSON: the contact page of
GET /v1/campaigns/{campaign_id}/contacts with an outcome object per
contact (the fields above, extraction as an object,
extraction_schema_version) and extraction_fields, the AI agent's current
field list. Bounded and paged like the contact page (has_more).
Review inbox
A conversation workflow ends most calls that need a person on a
review_candidate outcome: a promise to pay, a payment already made, a
dispute, hardship, a callback request, a language request, a wrong party.
The review inbox lists those attempts for a campaign's latest run with the
call's evidence inline, so a reviewer can decide and record what they did.
GET /v1/campaigns/{campaign_id}/reviews?state=open|resolved|all&outcome=&language=&limit=&offset=pages through the candidates, newest first. Each item carries the attempt (attempt_id,attempt_no,call_id,disposition,termination_cause,settled_at), the contact (phone_e164,name,external_reference,language,status), the call'soutcome(the business outcome above, including the extraction fields and the recording/transcript availability) and itsresolution(nullwhile open;id,note,resolved_atandresolved_byonce resolved).countsgives the open and resolved totals;outcomes(outcome step id, label, count) andlanguagesare the facets over the whole candidate set, whatever the page's filters;extraction_fieldsis the field list.POST /v1/campaigns/{campaign_id}/reviews/{attempt_id}/resolvewith an optionalnote(at most 2,000 characters) resolves one candidate. The platform records who resolved it (the signed-in user, or the API key) and when, writes acampaign.review.resolvedaudit event with the campaign, call, workflow run, outcome and note, and returns the item as the inbox lists it. A candidate resolved before answers409and keeps the first reviewer's note; an attempt that is not a review candidate of this campaign answers404.GET /v1/campaigns/{campaign_id}/reviews/export?state=resolved|open|alldownloads the set as CSV: the contact, the attempt, the business-outcome and extraction columns of the results export, thenresolved_at,resolved_byandnote. Bounded and bound to the credential's own workspace like the results export.
In the workspace, a campaign shows Review inbox (N) with the number of candidates still open; the inbox page filters by state, outcome and language, plays the recording, links the transcript, shows the extracted fields, and resolves each item with a note. The inbox covers the campaign's latest run.
The same operations are available over MCP as create_contact_list,
add_contacts, add_do_not_call, create_campaign, update_campaign,
launch_campaign, pause_campaign, resume_campaign, cancel_campaign,
list_campaigns and get_campaign. The CSV exports, the results page and
the review inbox are REST-only.