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_basisMeaning
existing_customerPeople with an active relationship with your business
explicit_consentPeople 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_zone is an IANA time zone such as America/New_York. Case does not matter, and another spelling of a zone is stored as the zone it names: US/Eastern and america/new york become America/New_York, Europe/Kiev becomes Europe/Kyiv, and EST becomes America/Panama, which is what EST means in the IANA database (UTC−5 all year, no daylight saving). An offset such as UTC-5 is refused, and the row's reason gives an example. Left empty, it comes from the number when the number has only one zone (+233… becomes Africa/Accra); set it per row when you know better. The same rules apply to a campaign's time_zone.

  • first_name is 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 from name and from any custom variable you also call first_name. On a call the agent reads it as the built-in call variable contact.first_name. In the workspace wizard a first_name (or given_name) column maps to it, and a sheet whose only name column is a first name fills both.

  • variables are 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/false is 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 obligations list. 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 still skipped_duplicates.

  • source records where the rows came from and is returned on the list: api, csv, xlsx, or mcp. It is a label, not a parser — send whichever one is true of your own import. It defaults to api on this endpoint and to mcp through the MCP tool.

  • A row's country is 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_duplicates and rejections[] 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/calls makes: 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" and next_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 (409 test_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}/results adds each contact's last-call business outcome.
  • Webhooks: campaign.launched, campaign.contact.completed, campaign.completed. Per-call events are unchanged and carry external_reference (the contact's reference) and external_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:

TabContact statuses
Allevery contact
To callpending, scheduled, dispatched
Donecompleted
Not calledfailed, 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_ids and language;
  • calling_windows, time_zone_mode and time_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_policy or retry_policy_preset, max_concurrency and reserved_concurrency;
  • ends_at, and scheduled_start_at while 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, launch and duplicate answer 404 afterwards. Export the CSV first if you need it.
  • Opt-outs. A caller who asked not to be contacted is recorded as a do_not_call outcome, 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, running or paused campaign answers 409 with "reason": "campaign_active" and its status. 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) or group (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 (enum choices, or a boolean), named with ASCII letters, digits and underscores. Give it result_value (a choice, or true / false) for the contacts whose last call's result is ready and states that answer, or result_not_stated=true for those whose result is ready and leaves the field empty. A field that was not stated never matches false.

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.csv

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, informational or terminal_note), workflow_outcome (the outcome step the conversation workflow landed on, for example promise_review) and workflow_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 empty workflow_status means 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 its workflow.interrupts;
  • extraction_status (empty when no extraction was requested for the call; otherwise pending, running, ready or failed), extraction_schema_version (the schema version the row's cells were extracted under; the column layout follows the AI agent's current schema) and one extraction.<field> column per field of the AI agent's post-call extraction schema, filled only when the status is ready. Extraction is advisory model output; an empty cell means the field was not extracted, never that it was extracted as empty;
  • recording_available and transcript_available (true/false): whether the call's recording and transcript exist, which GET /v1/calls/{call_id}/recording and 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's outcome (the business outcome above, including the extraction fields and the recording/transcript availability) and its resolution (null while open; id, note, resolved_at and resolved_by once resolved). counts gives the open and resolved totals; outcomes (outcome step id, label, count) and languages are the facets over the whole candidate set, whatever the page's filters; extraction_fields is the field list.

  • POST /v1/campaigns/{campaign_id}/reviews/{attempt_id}/resolve with an optional note (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 a campaign.review.resolved audit event with the campaign, call, workflow run, outcome and note, and returns the item as the inbox lists it. A candidate resolved before answers 409 and keeps the first reviewer's note; an attempt that is not a review candidate of this campaign answers 404.

    Send requeue_contact: true alongside 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. A requeue_contact that is not a boolean is refused with 400.

    To see how thin a particular wrong-party verdict was before you decide, read the call's call.wrong_party_signals event: 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 marked do_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 is running, scheduled or paused; 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_call rather 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 answers contact_requeued: false with an empty requeue_refused_reason, because nothing refused you — the contact was already back in play.

  • GET /v1/campaigns/{campaign_id}/reviews/export?state=resolved|open|all downloads the set as CSV: the contact, the attempt, the business-outcome and extraction columns of the results export, then resolved_at, resolved_by and note. 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.