Skip to content
RelationsAI
Guides

Running campaigns

Preview

A campaign calls many recipients with one agent, on a schedule and at a controlled pace. This guide walks through creating a campaign, adding recipients, starting, pausing, resuming and stopping it, and reading per-recipient results.

Use a campaign whenever you call more than a handful of people for the same purpose: it paces calls for you (concurrency_limit at a time), screens every recipient against your compliance rules, and gives you one object to follow instead of hundreds of calls.

Before you begin

You needFor
campaigns:write, campaigns:readCreating, starting and reading campaigns
contacts:writeRecipients are contacts: you upsert them first
A ready agentIn test mode, the Sample agent agent_1ZltiWzxrXmMprGD6q63
webhooks:write (optional)To receive campaign.* events

The campaign lifecycle

Text
draft ──start──▶ running ──pause──▶ paused ──resume──▶ running │ │ ├──stop──▶ stopped ◀──stop──┤ ├──▶ completed (every recipient worked through) └──▶ failed (the run ended in failure) start again from paused, stopped, completed or failed ──▶ a new run (run_number + 1)
StatusMeaning
draftCreated, never started. Nothing has been dialled.
runningA run is dialling, up to concurrency_limit calls at a time.
pausedNo new calls start. Calls already connected finish normally (and are charged).
stoppedEnded by a stop request. No further calls start.
completedThe run worked through every recipient.
failedThe run ended in failure before finishing.
unknownThe state could not be determined. Not a success. Open enum.

Every campaign carries capabilities, which says what is allowed right now. Check it instead of hard-coding the table:

ActionAllowed fromcapabilities field
Startdraft, paused, stopped, completed, failedstart
Pauserunningpause
Resumepausedresume
Stopdraft, running, pausedstop
Edit (PATCH)every status except runningupdate
Add recipientsdraft onlyadd_recipients

A campaign can also be paused automatically, for example when its budget cap is reached, the workspace balance runs out, the telephony connection drops, too many consecutive calls fail, or a start was refused. The campaign.paused event's data.reason says why (budget_cap_reached, insufficient_balance, spend_limit_reached, telephony_disconnected, failure_streak, start_refused, account_inactive, requested, unknown). Fix the cause, then resume.

Create and start a campaign

Three steps: upsert the recipients as contacts, create the campaign with their ids, start it.

1. Upsert each recipient as a contact. Send one request per person (this one adds the test number that completes a call):

curl -X POST https://voice-api.relationsai.com/v1/contacts/upsert \ -H "Authorization: Bearer <token>" \ -H "Content-Type: application/json" \ -d '{ "phone_number": "+12025550100", "name": "Asha Rao" }'

Keep the id of each contact (ct_8Rw2Tq5Lm9Xc3Vb7Nz4Kp1 here). Add "consent_granted": true only when the person really agreed to be called.

2. Create the campaign with the contact ids. It starts as a draft; nothing is dialled:

curl -X POST https://voice-api.relationsai.com/v1/campaigns \ -H "Authorization: Bearer <token>" \ -H "Content-Type: application/json" \ -d '{ "name": "November renewals", "agent_id": "agent_1ZltiWzxrXmMprGD6q63", "concurrency_limit": 2, "contact_ids": [ "ct_8Rw2Tq5Lm9Xc3Vb7Nz4Kp1", "ct_2Mh7Yd4Qs8Fv1Kc6Bx9Tn3" ] }'

Response 201:

JSONJSON
{ "id": "cmp_2Kd7Fg4Hj9Lz3Xc8Vb5Nm1", "object": "campaign", "livemode": false, "name": "November renewals", "agent_id": "agent_1ZltiWzxrXmMprGD6q63", "status": "draft", "capabilities": { "start": true, "pause": false, "resume": false, "stop": true, "update": true, "add_recipients": true }, "concurrency_limit": 5, "budget_cap": { "amount": 500000, "currency": "INR" }, "recipient_count": 2, "progress": { "calls": 0, "connected": 0, "no_answer": 0, "failed": 0, "in_progress": 0 }, "spend": { "amount": 0, "currency": "INR" }, "run_number": 0, "dnc_screening": "enforced", "created_at": "2026-10-21T08:00:00.000Z" }

3. Start it, using the id from that response:

curl -X POST https://voice-api.relationsai.com/v1/campaigns/cmp_2Kd7Fg4Hj9Lz3Xc8Vb5Nm1/start \ -H "Authorization: Bearer <token>"

Response 200:

JSONJSON
{ "id": "cmp_2Kd7Fg4Hj9Lz3Xc8Vb5Nm1", "object": "campaign", "livemode": false, "name": "November renewals", "agent_id": "agent_1ZltiWzxrXmMprGD6q63", "status": "running", "capabilities": { "start": false, "pause": true, "resume": false, "stop": true, "update": false, "add_recipients": false }, "concurrency_limit": 5, "budget_cap": { "amount": 500000, "currency": "INR" }, "recipient_count": 2, "progress": { "calls": 0, "connected": 0, "no_answer": 0, "failed": 0, "in_progress": 0 }, "spend": { "amount": 0, "currency": "INR" }, "run_number": 1, "dnc_screening": "enforced", "created_at": "2026-10-21T08:00:00.000Z" }

If a job stops halfway, check what already exists before sending a step again: every request is executed, so resending a create makes a second campaign. The complete example below looks for its campaign by name first, creates it only if it is missing, and starts it only while it is still a draft. An optional Idempotency-Key per step, saved with your batch, makes a re-run replay the steps that already happened instead (see Idempotency).

Complete runnable example

Create a test-mode campaign with three recipients and start it.

The same steps as a complete script, with error handling, tested against test mode on every docs build. Each script reads your test key from an environment variable named in its first lines.

The calls in order:

  1. POST/contacts/upsert for each person: recipients are always existing contacts (see Managing contacts).
  2. POST/campaigns with name, agent_id, optional concurrency_limit (default 1; lowered at start to your plan's limits.campaign_max_concurrency), optional budget_cap and up to 1000 contact_ids. The answer is 201 with the campaign in draft. Nothing is dialled yet.
  3. Optionally POST/campaigns/{campaign_id}/recipients to add more contacts, up to 1000 per request and 10,000 per campaign, only while the campaign is a draft. Contacts already in the campaign are skipped, and the response says how many were added.
  4. POST/campaigns/{campaign_id}/start.

What the start does

Before anything is dialled, the start checks the agent, the account, balance and spend limits, the campaign budget cap, calling hours and the telephony connection, then screens every recipient against the do-not-call list, consent and the per-number daily limit. Blocked recipients are left out of the run; if that leaves nobody, the start is refused with campaign_recipients_suppressed. After the start, recipient_count is the number of recipients this run calls.

  • 200: The run started; the campaign is running. Follow it.
  • 202: The start was sent but its confirmation has not arrived; the campaign shows running and is reconciled automatically. Read it again in a minute. Do not start it again.
  • 409 campaign_outcome_unknown: the start was sent and not confirmed; dialling may have begun. Wait Retry-After, then read the campaign: running means it started. Only if it did not, start it again (if you sent an idempotency key, use a new one: the same key returns this answer again).
  • 409 campaign_dnc_override_enabled: do-not-call screening was switched off for this campaign in the console (dnc_screening: "disabled_in_console"). The API never starts or resumes such a campaign. An admin switches the setting back on, or the campaign is started from the console.

In live mode a start counts every recipient against the workspace's daily limit of calls placed through the API, before dialling, because the run may call each of them. A start that would go past the limit is refused whole with daily_call_limit_reached (details.requested and details.remaining say by how much), so a campaign larger than the daily limit cannot be started through the API. A resume never counts. Test mode has no daily limit.

When the do-not-call list is checked. Recipients are screened when a run starts and again when it resumes, not before each call. A number added to the do-not-call list while the campaign is running is still called by that run if it has not been reached yet. To apply the new entry, pause and resume the campaign (the resume is then refused while that number is a remaining recipient, see below) or stop it. dnc_screening on the campaign is enforced for every campaign the API can start.

Follow results

Subscribe an endpoint to the campaign.* events (see Receiving webhooks) and read the campaign and its recipients when they arrive:

EventWhen
campaign.startedA run started dialling (also when a 202 start is confirmed)
campaign.pausedPaused by a request or automatically; data.reason says why
campaign.resumedA paused run resumed
campaign.stoppedStopped; data.reason is requested
campaign.completedThe run worked through every recipient. In live mode up to about 15 minutes after the last call ends.
campaign.failedThe run ended in failure. In live mode up to about 15 minutes after it ends.

Each call a campaign places is an ordinary call with source: "campaign" and campaign_id set, so call.ended and call.settled arrive for each one, and GET/calls with campaign_id lists them.

Read the campaign:

curl -X GET https://voice-api.relationsai.com/v1/campaigns/cmp_2Kd7Fg4Hj9Lz3Xc8Vb5Nm1 \ -H "Authorization: Bearer <token>"

And its recipients, 100 at a time (follow next_cursor as described in Pagination):

curl -X GET "https://voice-api.relationsai.com/v1/campaigns/cmp_2Kd7Fg4Hj9Lz3Xc8Vb5Nm1/recipients?limit=100" \ -H "Authorization: Bearer <token>"
Complete runnable example

Show a campaign's status and every recipient's result.

The same steps as a complete script, with error handling, tested against test mode on every docs build. Each script reads your test key from an environment variable named in its first lines.

A running campaign (200, with an ETag header):

JSONJSON
{ "id": "cmp_7Wq3Er9Ty2Ui5Op8As4Df6", "object": "campaign", "livemode": true, "name": "November renewals", "agent_id": "agent_3hT9kQ2mVx7RbN4pLc8Wz1", "status": "running", "capabilities": { "start": false, "pause": true, "resume": false, "stop": true, "update": false, "add_recipients": false }, "concurrency_limit": 5, "budget_cap": { "amount": 500000, "currency": "INR" }, "recipient_count": 1200, "progress": { "calls": 640, "connected": 410, "no_answer": 190, "failed": 35, "in_progress": 5 }, "spend": { "amount": 246000, "currency": "INR" }, "run_number": 1, "dnc_screening": "enforced", "created_at": "2026-10-20T08:00:00.000Z" }

progress counts the calls of the current or latest run (calls, connected, no_answer, failed, in_progress) and can lag by up to a minute. spend is what the run has cost so far (always zero in test mode).

GET/campaigns/{campaign_id}/recipients lists recipients in a stable order that does not change between pages (not by time), with what happened to each in the current or latest run:

statusMeaning
pendingNot called yet in this run
in_progressBeing called
completedCalled; see outcome (the call outcome values) and last_call_id
failedEvery attempt failed
suppressedLeft out by screening; suppression_reason is dnc_listed, consent_required, contact_daily_limit_reached or unknown
cancelledThe run stopped before reaching them
unknownNot known. Open enum.

A page of recipients from a test-mode campaign (200):

JSONJSON
{ "object": "list", "data": [ { "id": "rcp_3Gh8Jk2Lz5Xc9Vb4Nm7Qw1", "object": "campaign_recipient", "livemode": false, "campaign_id": "cmp_2Kd7Fg4Hj9Lz3Xc8Vb5Nm1", "contact_id": "ct_8Rw2Tq5Lm9Xc3Vb7Nz4Kp1", "phone_number": "+12025550100", "status": "completed", "attempts": 1, "last_call_id": "call_2Hd8Ks4Lq7Mw3Nx9Pz5Rc1", "outcome": "completed", "suppression_reason": null }, { "id": "rcp_6Tr1Yu4Io7Pa2Sd5Fg8Hj3", "object": "campaign_recipient", "livemode": false, "campaign_id": "cmp_2Kd7Fg4Hj9Lz3Xc8Vb5Nm1", "contact_id": "ct_2Mh7Yd4Qs8Fv1Kc6Bx9Tn3", "phone_number": "+12025550107", "status": "suppressed", "attempts": 0, "last_call_id": null, "outcome": null, "suppression_reason": "dnc_listed" } ], "has_more": false, "next_cursor": null }

Per-recipient results can be up to a minute behind the calls. One honest limit today: in live mode a recipient left out by screening at start is not yet recorded per recipient. It shows pending while the run is open and unknown once the run has ended (test mode records it as suppressed). The difference between the campaign's recipients and recipient_count after the start tells you how many were left out.

Start, pause, resume and stop

Read the campaign first and check capabilities. Then send the action. Pause:

curl -X POST https://voice-api.relationsai.com/v1/campaigns/cmp_2Kd7Fg4Hj9Lz3Xc8Vb5Nm1/pause \ -H "Authorization: Bearer <token>"

Resume:

curl -X POST https://voice-api.relationsai.com/v1/campaigns/cmp_2Kd7Fg4Hj9Lz3Xc8Vb5Nm1/resume \ -H "Authorization: Bearer <token>"

Stop:

curl -X POST https://voice-api.relationsai.com/v1/campaigns/cmp_2Kd7Fg4Hj9Lz3Xc8Vb5Nm1/stop \ -H "Authorization: Bearer <token>"

Each answers 200 with the campaign in its new status. After a timeout or a retry: "yes" error, read the campaign before sending the action again; after a retry: "after" error, wait Retry-After and send it again. On campaign_outcome_unknown, do not resend: wait Retry-After and read the campaign.

Complete runnable example

Pause, resume or stop a campaign, checking first that the action is allowed.

The same steps as a complete script, with error handling, tested against test mode on every docs build. Each script reads your test key from an environment variable named in its first lines.

  • Pause (POST/campaigns/{campaign_id}/pause, from running): no new calls start. Calls already connected finish normally and are charged. The campaign is reported paused only once the pause is confirmed; if it is not, you get campaign_pause_not_confirmed and the campaign may still be dialling. Calls placed meanwhile are charged as usual. Read the campaign; if it is still running, send the pause again or stop it.
  • Resume (POST/campaigns/{campaign_id}/resume, from paused): continues the same run with the settings it began with. Before anything is sent it repeats the account, balance, budget, calling-hours and telephony checks and screens every recipient the run could still call again. A resume cannot drop recipients from a run, so if any remaining recipient is now blocked (for example added to the do-not-call list after the start), nothing is resumed, the campaign stays paused, and you get campaign_recipients_blocked with details.blocked_total, details.by_reason and up to 100 details.contact_ids. Do not stop and start it again: a new start calls every recipient of the previous run again, including people already reached, and charges for those calls. To continue without the blocked people: list the recipients with status=pending (not reached yet), create a new campaign with those contacts minus the ones in details.contact_ids, start it, then stop this campaign (stopping does not hang up calls in progress). Never work around the refusal by removing do-not-call entries or recording consent nobody gave.
  • Stop (POST/campaigns/{campaign_id}/stop, from draft, running or paused): no further calls start. Calls already connected are not hung up; they finish normally. Recipients the run had not reached become cancelled.
  • Start again from paused, stopped, completed or failed begins a new run that calls the previous run's recipients again, including people already reached (and charges for those calls): from paused it abandons the paused run (use resume to continue it instead). run_number increases. Use it when you really want to call everyone again; to reach only the people not called yet, create a new campaign with them.

Every action is a POST. After a timeout read the campaign before resending (its status shows whether the action took effect); after a retry: "after" error wait and resend; stop on retry: "no"; and on campaign_outcome_unknown wait and read the campaign instead of resending.

Edit safely

PATCH/campaigns/{campaign_id} changes name, agent_id, concurrency_limit or budget_cap (null removes the cap). Edits are allowed in every status except running, and take effect at the next start: a resume continues with the settings its run began with.

Campaigns use optimistic concurrency. Every read returns an ETag header; send it back in If-Match. If the campaign changed since you read it (someone edited it in the console, or it was paused automatically), you get version_conflict (412): read it again, decide again, and send the new ETag. A PATCH without If-Match is precondition_required (428). See Concurrency.

Read the campaign and keep its ETag header, then send the change with it in If-Match:

curl -X PATCH https://voice-api.relationsai.com/v1/campaigns/cmp_2Kd7Fg4Hj9Lz3Xc8Vb5Nm1 \ -H "Authorization: Bearer <token>" \ -H 'If-Match: <etag>' \ -H "Content-Type: application/json" \ -d '{ "name": "November renewals (second wave)", "concurrency_limit": 3 }'
Complete runnable example

Rename a campaign and change its concurrency with If-Match.

The same steps as a complete script, with error handling, tested against test mode on every docs build. Each script reads your test key from an environment variable named in its first lines.

Budget, pace and limits

  • budget_cap, as {"amount": <minor units>, "currency": "INR"}, is the spend at which the campaign is paused (reason budget_cap_reached). It is not an exact ceiling: the check runs about every 15 minutes, so spend can exceed the cap by the calls placed in that time. Set it with that margin in mind; raise or remove it with PATCH and resume. A start or resume at the cap is refused with campaign_budget_reached.
  • concurrency_limit is how many calls run at once. A higher value is lowered at start to limits.campaign_max_concurrency on GET/workspace.
  • Starts and resumes use the dial rate-limit class (30 per minute per workspace); pause, stop and edits use write.
  • Calling hours apply to the whole campaign: a start or resume outside the calling window is refused with outside_calling_hours (the window and the timezone it is evaluated in are in details).

What can go wrong

  • campaign_state_conflict (409): The action is not allowed in the current status (details.status, details.action). Read the campaign and use capabilities.
  • campaign_operation_in_progress (409): Another start or resume of this campaign is running. Wait Retry-After, then send it again.
  • campaign_outcome_unknown (409): A start or resume was sent but not confirmed. Wait, then read the campaign, and send the action again only if it did not start (with a new idempotency key if you use keys). If details.needs_support is true, contact support.
  • campaign_dnc_override_enabled (409): Do-not-call screening is switched off for this campaign in the console. An admin switches it back on, or starts it from the console.
  • campaign_has_no_recipients (409): Starting a campaign with no recipients. Add recipients while it is a draft.
  • campaign_recipients_suppressed (422): Every recipient is blocked by screening. Add callable recipients; review consent records.
  • campaign_recipients_blocked (409): Resume found newly blocked recipients. Create a new campaign with the pending recipients minus the blocked ones, then stop this one.
  • campaign_not_resumable (409): The paused run can no longer continue. A new start calls every previous recipient again; to reach only the people not called yet, create a new campaign with the pending recipients.
  • campaign_recipients_unavailable (503): Recipients could not be read for screening; nothing was dialled. Wait Retry-After and send it again. If it keeps failing on a very large campaign, create a new campaign with the pending recipients instead.
  • campaign_too_large (422): Too many recipients for one run. Split the audience into several campaigns.
  • recipient_contacts_not_found (422): Some contact_ids are not contacts of this workspace and mode. Upsert them first; details lists them.
  • telephony_disconnected (422): The telephony connection is not working; the campaign was paused. Reconnect it in the console, then start or resume.
  • agent_not_ready, insufficient_balance, spend_limit_reached, account_inactive: Same checks as a single call. See Making an outbound call.

Check it works in test mode

  1. Run the create-and-start example. The campaign becomes running with recipient_count: 3.
  2. Follow it: within about a minute (with concurrency_limit: 2) the recipients are completed with outcomes completed, no_answer and completed, and the campaign becomes completed.
  3. Start another one with concurrency_limit: 1 and pause it while at least one recipient is still pending. Add that recipient's number to the do-not-call list and resume: you get 409 campaign_recipients_blocked listing it. Create a new campaign with the remaining pending recipients minus that one, start it, and stop the first.

Next steps

Managing contacts.