Campaigns
A campaign calls a list of contacts with one published agent, inside a calling window you choose. You create a campaign as a draft, add contacts, then start it.
Lifecycle
Section titled “Lifecycle”| Status | Meaning |
|---|---|
draft |
Being set up. Only a draft can be edited or receive contacts. |
scheduled |
Waiting for its start time. |
running |
Dialing contacts inside the calling window. |
paused |
Paused. Calls in progress finish. |
completed |
Every contact has reached a final result. |
stopped |
Stopped for good. |
Contacts are dialed only inside the calling window (window_start to window_end, on the days in window_days, in the campaign’s time_zone). A contact that is not answered is retried up to max_attempts times, retry_minutes apart.
The campaign object
Section titled “The campaign object”{ "id": "camp_a91c33", "name": "November follow-ups", "agent_id": "agent_8f3a1c", "agent_version_id": null, "status": "draft", "outcomes": [ { "key": "interested", "label": "Interested", "description": "Wants to hear more, asked for details, or agreed to a next step." }, { "key": "not_interested", "label": "Not interested", "description": "Declined, politely or otherwise." }, { "key": "callback", "label": "Call back later", "description": "Asked to be called at another time." }, { "key": "wrong_number", "label": "Wrong number", "description": "Not the person we meant to reach." }, { "key": "do_not_call", "label": "Do not call", "description": "The person asked not to be called again." }, { "key": "unclear", "label": "Unclear", "description": "The conversation did not reach a clear result." } ], "window_start": "10:00", "window_end": "19:00", "window_days": "123456", "time_zone": "Asia/Kolkata", "max_attempts": 2, "retry_minutes": 90, "starts_at": null, "started_at": null, "finished_at": null, "created_at": "2026-11-04T09:00:00", "updated_at": "2026-11-04T09:00:00", "counts": {}, "contacts_total": 0}| Field | Description |
|---|---|
outcomes |
The buckets Sahay sorts each answered call into. do_not_call and unclear are always present. When a contact’s outcome is do_not_call, the number is added to your organization’s blocklist. |
window_days |
Calling days as digits, where Monday is 1 and Sunday is 7. 123456 is Monday to Saturday. |
counts |
Number of contacts in each contact status. |
contacts_total |
Number of contacts. |
outcome_counts |
Contacts per outcome. Returned by GET /campaigns/{campaign_id} once the campaign has left draft. |
talk_seconds |
Total talk time. Returned by GET /campaigns/{campaign_id}. |
List campaigns
Section titled “List campaigns”GET /campaigns
Lists campaigns, newest first.
| Parameter | In | Description |
|---|---|---|
status |
query | One or more of draft, scheduled, running, paused, completed, stopped, comma separated. |
limit |
query | Page size, 1 to 100. Default 50. |
cursor |
query | The next_cursor of the previous page. |
curl "$BASE_URL/campaigns?status=running,paused" \ -H "Authorization: Bearer $SAHAY_API_KEY"Response 200:
{ "data": [ { "id": "camp_a91c33", "name": "November follow-ups", "agent_id": "agent_8f3a1c", "agent_version_id": null, "status": "running", "window_start": "10:00", "window_end": "19:00", "window_days": "123456", "time_zone": "Asia/Kolkata", "max_attempts": 2, "retry_minutes": 90, "counts": { "pending": 40, "answered": 8, "no_answer": 2 }, "contacts_total": 50 } ], "next_cursor": null}Each item has the full campaign object. It is shortened here.
Errors: 400 invalid_cursor, 422 invalid_request (for example limit above 100).
Create a campaign
Section titled “Create a campaign”POST /campaigns
Creates a draft campaign for a published agent. It answers 201 with the campaign.
| Field | Type | Required | Description |
|---|---|---|---|
name |
string | yes | 1 to 120 characters. |
agent_id |
string | yes | An active agent with a published version. |
outcomes |
array | no | Your own outcome buckets, replacing the defaults. Each is {"key", "label", "description"}. key is lowercase letters, digits and underscores, starting with a letter, 2 to 32 characters. label is 1 to 40 characters, description up to 200. Keys must be unique, at most 8, and cannot be do_not_call or unclear, which Sahay adds. Default is interested, not_interested, callback and wrong_number. |
window_start |
string | no | HH:MM, 24-hour. Default 10:00. |
window_end |
string | no | HH:MM, 24-hour. Must be after window_start. Default 19:00. |
window_days |
string | no | Digits 1 to 7, Monday is 1. Default 123456. |
max_attempts |
integer | no | Tries per contact, 1 to 5. Default 2. |
retry_minutes |
integer | no | Minutes before a retry, at least 5. Default 90. |
curl -X POST "$BASE_URL/campaigns" \ -H "Authorization: Bearer $SAHAY_API_KEY" \ -H "Content-Type: application/json" \ -d '{"name": "November follow-ups", "agent_id": "agent_8f3a1c", "window_start": "10:00", "window_end": "19:00", "window_days": "123456"}'Response 201: the campaign object shown above, in the draft status.
Errors: 409 agent_not_published (pick an active agent with a published version), 422 invalid_request (for example window_start not before window_end, or too many outcomes).
Get a campaign
Section titled “Get a campaign”GET /campaigns/{campaign_id}
Returns one campaign. counts and contacts_total show its progress.
curl "$BASE_URL/campaigns/camp_a91c33" \ -H "Authorization: Bearer $SAHAY_API_KEY"Response 200 for a running campaign (outcomes shortened):
{ "id": "camp_a91c33", "name": "November follow-ups", "agent_id": "agent_8f3a1c", "agent_version_id": null, "status": "running", "window_start": "10:00", "window_end": "19:00", "window_days": "123456", "time_zone": "Asia/Kolkata", "max_attempts": 2, "retry_minutes": 90, "starts_at": null, "started_at": "2026-11-04T10:00:00", "finished_at": null, "created_at": "2026-11-04T09:00:00", "updated_at": "2026-11-04T10:00:00", "counts": { "pending": 40, "answered": 8, "no_answer": 2 }, "contacts_total": 50, "outcome_counts": { "interested": 3, "not_interested": 5 }, "talk_seconds": 640}Errors: 404 campaign_not_found.
Update a campaign
Section titled “Update a campaign”PATCH /campaigns/{campaign_id}
Updates a draft campaign. Every field is optional, and a field you leave out is unchanged. The fields are the same as for Create a campaign, and you can also change agent_id and name.
curl -X PATCH "$BASE_URL/campaigns/camp_a91c33" \ -H "Authorization: Bearer $SAHAY_API_KEY" \ -H "Content-Type: application/json" \ -d '{"name": "November follow-ups, batch 2"}'Response 200: the updated campaign object.
Errors: 404 campaign_not_found, 409 not_draft (only a draft can be changed), 409 bad_window, 409 agent_not_published, 422 invalid_request.
Add contacts
Section titled “Add contacts”POST /campaigns/{campaign_id}/contacts
Adds contacts to a draft campaign, up to 500 per request.
| Field | Type | Required | Description |
|---|---|---|---|
contacts |
array | yes | 1 to 500 contacts. |
contacts[].phone |
string | yes | E.164, for example +919876543210. |
contacts[].name |
string | no | Up to 200 characters. |
contacts[].variables |
object | no | Values the agent can use in the call. Each value is a string, number, boolean or null. |
curl -X POST "$BASE_URL/campaigns/camp_a91c33/contacts" \ -H "Authorization: Bearer $SAHAY_API_KEY" \ -H "Content-Type: application/json" \ -d '{"contacts": [{"phone": "+919876543210", "name": "Asha", "variables": {"plan": "gold"}}, {"phone": "+919812345678", "name": "Ravi"}]}'Response 200:
{ "accepted": 2, "rejected": 0, "leads_created": 2, "errors": [], "max_per_request": 500}A bad row is rejected on its own and listed in errors. The other rows are still added. Each entry in errors looks like this:
{ "index": 3, "phone": "12345", "code": "invalid_phone", "message": "phone must be E.164, e.g. +919876543210"}index is the position of the row in your contacts array. The codes are:
| Code | Meaning |
|---|---|
invalid_phone |
The number is not a dialable E.164 number. |
duplicate_in_request |
The same number appears earlier in this request. |
already_in_campaign |
The number is already a contact of this campaign. |
blocklisted |
The number is on your organization’s blocklist. If it belongs to an existing lead it is added as a skipped contact and never dialed, but it is still reported here and counted in rejected. |
leads_created is the number of new Sahay leads created for contacts that were not already leads.
Errors: 404 campaign_not_found, 409 not_draft, 422 invalid_request (more than 500 contacts, none, an unknown field, or a phone longer than 32 characters).
List contacts
Section titled “List contacts”GET /campaigns/{campaign_id}/contacts
Lists a campaign’s contacts and their call results, 100 per page.
| Parameter | In | Description |
|---|---|---|
page |
query | Page number, starting at 0. Default 0. |
status |
query | Only contacts with this status: pending, dialling, answered, no_answer, busy, failed, skipped_blocklist or skipped. |
curl "$BASE_URL/campaigns/camp_a91c33/contacts?status=answered&page=0" \ -H "Authorization: Bearer $SAHAY_API_KEY"Response 200:
{ "data": [ { "id": "cc_01", "phone": "+919876543210", "name": "Asha", "lead_id": "lead_77c1e9", "status": "answered", "attempts": 1, "outcome": "interested", "summary": "Wants a quote for 20 units.", "callback_at": null, "duration_seconds": 74, "variables": { "plan": "gold" }, "created_at": "2026-11-04T09:05:00", "updated_at": "2026-11-04T10:12:00" } ], "total": 1, "page": 0, "page_size": 100}outcome and summary are null until the answered call has been classified. callback_at is set when the outcome is a request to call back at a given time. Note that the contact status is spelled dialling, while a call status is spelled dialing.
Errors: 404 campaign_not_found.
Start a campaign
Section titled “Start a campaign”POST /campaigns/{campaign_id}/start
Starts dialing a draft campaign. It needs at least one contact. There is no request body.
curl -X POST "$BASE_URL/campaigns/camp_a91c33/start" \ -H "Authorization: Bearer $SAHAY_API_KEY"Response 200: the campaign object with status set to running and started_at filled in.
Errors: 404 campaign_not_found, 409 not_draft, 409 no_contacts, 409 no_caller_id, 409 agent_number_paused, 409 agent_not_published, 409 tick_not_scheduled.
Pause a campaign
Section titled “Pause a campaign”POST /campaigns/{campaign_id}/pause
Pauses a running or scheduled campaign. Calls in progress finish. There is no request body.
curl -X POST "$BASE_URL/campaigns/camp_a91c33/pause" \ -H "Authorization: Bearer $SAHAY_API_KEY"Response 200: the campaign object with status set to paused.
Errors: 404 campaign_not_found, 409 not_pausable.
Resume a campaign
Section titled “Resume a campaign”POST /campaigns/{campaign_id}/resume
Resumes a paused campaign. There is no request body.
curl -X POST "$BASE_URL/campaigns/camp_a91c33/resume" \ -H "Authorization: Bearer $SAHAY_API_KEY"Response 200: the campaign object with status set to running.
Errors: 404 campaign_not_found, 409 not_resumable.
Stop a campaign
Section titled “Stop a campaign”POST /campaigns/{campaign_id}/stop
Stops a running, scheduled or paused campaign for good. It cannot be restarted. There is no request body.
curl -X POST "$BASE_URL/campaigns/camp_a91c33/stop" \ -H "Authorization: Bearer $SAHAY_API_KEY"Response 200: the campaign object with status set to stopped.
Errors: 404 campaign_not_found, 409 not_stoppable.
Results
Section titled “Results”To follow a campaign without polling, subscribe to the campaign.contact.completed and campaign.completed events. See Webhooks.