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. Skysay
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 Skysay 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.skysay.ai/v1/contact-lists \
-H "Authorization: Bearer $SKYSAY_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"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_zoneis an IANA time zone such asAmerica/New_York. Case does not matter, and another spelling of a zone is stored as the zone it names:US/Easternandamerica/new yorkbecomeAmerica/New_York,Europe/KievbecomesEurope/Kyiv, andESTbecomesAmerica/Panama, which is whatESTmeans in the IANA database (UTC−5 all year, no daylight saving). An offset such asUTC-5is refused, and the row's reason gives an example. Left empty, it comes from the number when the number has only one zone (+233…becomesAfrica/Accra); set it per row when you know better. The same rules apply to a campaign'stime_zone. -
first_nameis the contact's own first name: at most 80 characters, no{or}, and an empty value clears it. It is stored and returned with the contact, separately fromnameand from any custom variable you also callfirst_name. On a call the agent reads it as the built-in call variablecontact.first_name. In the workspace wizard afirst_name(orgiven_name) column maps to it, and a sheet whose only name column is a first name fills both. -
variablesare at most 32 keys and 4 KB, never secrets: a row that would not fit is rejected at import, never at dial time. They are the call's per-contact data. The campaign, its run, the attempt number and the contact's name reach the call as built-in call variables (campaign.id,campaign.run_id,campaign.attempt,contact.name), not as keys among yours, so no variable name is 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. The
workspace Contacts page shows every list, lets you edit and delete
single contacts, and exports a list as CSV.
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.
On an agent whose engine picks its voice and transcription per language, the
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.
A voice-to-voice engine has no language setting to publish: one voice speaks
every language, so there is no per-language profile for a contact to be
matched against. A contact language that Skysay supports is accepted as given
and the call opens in it, with no publishing step first. A language Skysay
does not support still ends pre_dial_failed with language_not_published.
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). Only you add numbers. A hosted agent's do_not_call outcome (from the
set_call_disposition tool), and a workflow cease_contact interrupt when it
is the last request the caller made, record that the caller asked not to be
contacted and finish that contact for the campaign, but they never add the
number to the register: a caller can say anything, so review those calls and
add the numbers you decide to. A call says so in its
workflow:
"cease_contact" is in interrupts, even when a later request followed. GET /v1/do-not-call lists the register;
DELETE /v1/do-not-call/{phone_number} removes one entry. The
Contacts page manages the same register.
Create and launch
curl -X POST https://api.skysay.ai/v1/campaigns \
-H "Authorization: Bearer $SKYSAY_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"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).
Test call on a draft
POST /v1/campaigns/{campaign_id}/test-call with {"contact_id": "contact_123"}
places one real, billed call to one contact of a draft's list before you
launch. It uses the campaign's AI agent, the caller ID its first attempt would
use, and that contact's own values, so the call says what the campaign's call
would say.
- It is an ordinary outbound call with every check
POST /v1/callsmakes: the do-not-call register (403), your workspace's policy and caps, and the balance. - The campaign's calling hours are checked first, in the contact's zone. Outside
them the answer is 409 with
reason: "outside_calling_window"andnext_window_at. - The campaign stays a draft. There is no run and nothing is counted, and the contact is not marked done, so launching calls them again unless you remove them from the list.
- Only a draft can do this (409
campaign_not_draft). A draft has one test call at a time: while one is unfinished, another, to any contact, is refused (409test_call_in_progress). - The call carries the campaign id as its
external_group_reference, so the Calls page's campaign filter finds it.
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
when the whole list is in one (Ghana: 08:00–19:00 Monday to Saturday; Sri
Lanka: 09:00–17:00 Monday to Friday and 09:00–13:00 on Saturday); anywhere
else, 09:00–18: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. So is an attempt whose call was never dialled because
every agent line stayed busy (its call ends outbound_capacity_unavailable):
the contact is scheduled again a few minutes later, with skip_reason
outbound_capacity_unavailable.
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.
A conversation workflow also decides a disposition,
from the last global interrupt it took. When that is cease_contact, the
attempt is do_not_call even if the line then failed or the agent declared
another outcome: the contact is closed and not dialed again by this campaign,
and nothing is added to the register. When it
is wrong_party or callback, the attempt is connected_wrong_party or
connected_callback_requested unless the agent declared an outcome. A
workflow callback carries no time, so the contact is finished with that
disposition rather than dialed again; only an agent's declared callback with
callback_at schedules a call. The last request wins: a caller who asked not
to be contacted and then asked to be called back is recorded as a callback
request, not do_not_call. Every interrupt the call took stays on its
workflow.interrupts
if you want a stop request to win. Only interrupts the workflow runner took
count: a route you named like an interrupt decides nothing, and a run is read
only from your own workspace.
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).
On the campaign page
The workspace campaign page shows the same run. Before launch, a draft counts the contacts in its list, the number the wizard's review step showed; once launched it counts the run's contacts.
The contacts table has four tabs, each with its count:
| Tab | Contact statuses |
|---|---|
| All | every contact |
| To call | pending, scheduled, dispatched |
| Done | completed |
| Not called | failed, skipped, pre_dial_failed, do_not_call |
Every row shows its status and, when there is one, the reason
(skip_reason), for example that the number is on your do-not-call register,
that no calling hours were left, or that the AI agent does not publish the
contact's language.
The Result column shows the AI agent's
result fields that
have a closed set of answers, read from the contact's last call: a field with
enum choices, or a yes/no (boolean) field. Required fields come first, up
to three; the export carries every field. A field the AI agent left empty
shows not stated, never "no". A call whose result is still being written
shows Result pending, and a call made before a field existed shows a dash.
Click a call outcome or a result answer to show only the contacts with it; click it again, or the chip above the table, to clear it. Filters combine with the tab. Export CSV then downloads exactly the rows the table shows, in the same order, and its label gives the count.
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.
Delete a campaign
DELETE /v1/campaigns/{campaign_id} deletes a campaign that can no longer
place calls: a draft, needs_review, blocked, completed or cancelled
one. It answers 200 with {"id": "...", "deleted": true} and the campaign
leaves every campaign listing and read.
- What stays. Its calls, recordings, transcripts, billing records and call evidence stay in Calls and Billing, and a call it placed still opens. Contact lists, contacts and your do-not-call register are not changed. Calls already in progress finish normally.
- What goes. The campaign, its per-contact results and its review inbox
are no longer shown or returned:
GET, the contact page,/results,/reviews,/export,PATCH,launchandduplicateanswer404afterwards. Export the CSV first if you need it. - Opt-outs. A caller who asked not to be contacted is recorded as a
do_not_calloutcome, never added to the register automatically. Check those numbers are on your register before you delete the campaign. - A campaign that can still dial is refused. A
launching,scheduled,runningorpausedcampaign answers409with"reason": "campaign_active"and itsstatus. Cancel it first (POST /v1/campaigns/{campaign_id}/cancel), then delete it. - A repeat is a
404, the same answer as an id that never existed or belongs to another workspace. Scope:calls:write.
A deleted campaign's id is never reused as an ordinary call-group label: a
direct call that names it as its campaign_id is refused.
In the workspace, Delete sits next to Duplicate on the campaign. It lists what stays and asks you to confirm; on a campaign that can still place calls it explains that you cancel it first.
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-Skysay-Row-Count
carries the count and X-Skysay-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.
The export takes the same filter as the campaign page's contacts table, so a file holds exactly the rows the table showed, in the same order and under the same cap. Every parameter is optional and they combine:
status(one contact status) orgroup(to_call,done,not_called), not both;disposition: the contact's latest call outcome;result_field: one of the AI agent's result fields with a closed set of answers (enumchoices, or aboolean), named with ASCII letters, digits and underscores. Give itresult_value(a choice, ortrue/false) for the contacts whose last call's result is ready and states that answer, orresult_not_stated=truefor those whose result is ready and leaves the field empty. A field that was not stated never matchesfalse.
An unknown value, a field without a closed set of answers, or a
result_value outside the field's choices answers 400.
curl -G https://api.skysay.ai/v1/campaigns/camp_123/export \
-H "Authorization: Bearer $SKYSAY_TOKEN" \
--data-urlencode "group=done" \
--data-urlencode "result_field=payment_outcome" \
--data-urlencode "result_value=promised" \
-o promised.csvBusiness 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 most recent global interrupt the workflow took, if it took one (wrong_party,cease_contact,dispute,hardship,callback,human_request,language_change). An empty cell with an emptyworkflow_statusmeans the call ran no workflow, not that the caller made no request; with a status, it means the workflow took no interrupt. For every interrupt a call took, read itsworkflow.interrupts;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.Send
requeue_contact: truealongside the note when you disagree with the outcome and want the contact called again. The commonest case is a wrong party: that verdict comes from one reading of one caller reply and it closes the contact, so a reviewer who can see the call was misjudged can put the contact back in the queue. Nothing is dialed by resolving. Arequeue_contactthat is not a boolean is refused with400.To see how thin a particular wrong-party verdict was before you decide, read the call's
call.wrong_party_signalsevent: it records which route reached the verdict, the turn it landed on, and how many words the caller had actually said.The response tells you what happened, because the audit event is not something you can read back. Three fields are on every resolve, requeue or not:
requeue_requested— what you asked for.contact_requeued— whether the contact was actually put back in the queue.requeue_refused_reason— why it was not, or empty.
Three reasons refuse a requeue. In each the resolution still succeeds — the note and the audit event are recorded — and the contact keeps the status and the suppression record it had:
do_not_call_register— the number is on your do-not-call register, or the contact is already markeddo_not_call. A reviewer cannot undo an opt-out.contact_phone_unknown— the contact has no readable number, so there is no way to establish it has not opted out. A contact with no number cannot be dialed either way.campaign_run_not_dispatchable— its campaign run can no longer reach the dispatcher. A run is dispatchable while it isrunning,scheduledorpaused; a completed or cancelled run is not.
Otherwise the contact becomes due again and the next dispatch decides, applying every rule it normally does — so a number added to the register after the requeue is marked
do_not_callrather than called, and a contact outside its calling window waits for the window. A contact a dispatcher has already claimed keeps that claim and is not reset: that answerscontact_requeued: falsewith an emptyrequeue_refused_reason, because nothing refused you — the contact was already back in play. -
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.
An item also offers Add to do-not-call. It adds that contact's number to your do-not-call register, the same as adding it on the Contacts page, and the item then shows On do-not-call list. It is a person's decision: no outcome, review or call adds a number on its own. Resolving an item and adding its number are separate steps.
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, delete_campaign,
list_campaigns and get_campaign, and contact lists are readable with
list_contact_lists, get_contact_list and list_contacts (calls:read). The CSV exports, the results page and
the review inbox are REST-only.