Campaigns
Campaigns API
Create and run outbound dialing campaigns over HTTP. All endpoints are organization‑scoped under /api/orgs/{orgId}/campaigns.
Authentication
These are dashboard (org‑scoped) endpoints, authenticated with a user JWT. Send the bearer token plus the org id:
Authorization: Bearer eyJ…
X-Org-Id: {orgId}
See Authentication for how to obtain a token.
The flat, key‑authed /api/v1 surface also exposes campaigns — use that one for server‑to‑server work like a nightly job pushing rows out of your own database. See Pushing targets from your own system below.
Dashboard responses are wrapped: { "success": true, "data": { … } } on success, { "success": false, "error": "…" } on failure.
Roles: create / schedule / upload / start / pause require owner, admin, or developer. Cancel and retry‑failed require owner or admin. Reads (list / get / calls / export) are open to any member.
Pushing targets from your own system
Your leads may live somewhere this platform cannot reach — a database on a private network, a warehouse, an internal service. That is deliberate: outbound connector requests are validated and private addresses are refused, so nothing here can reach into your network.
So push instead. Your own job sends rows to us, and there is no firewall rule to negotiate.
POST /api/v1/campaigns/{id}/targets
X-API-Key: your_key
Content-Type: application/json
{
"source": "crm-nightly",
"targets": [
{ "id": "lead-8412", "phone": "+919876543210",
"variables": { "first_name": "Asha", "plan": "Gold" } },
{ "id": "lead-8413", "phone": "+919876543211" }
]
}
{ "added": 2, "duplicates": 0, "suppressed": 0, "skipped": 0, "without_id": 0, "source": "crm-nightly" }
Give every row a stable id
This is the whole thing. id is the row's identifier in your system — a primary key, a CRM record id. We store it as the target's address and skip any id we already hold.
That makes retrying safe. A cron that times out half‑way and runs again re‑sends the same ids and adds only what is genuinely new — duplicates tells you how many it skipped. Without an id a row can't be recognised on the next run, so every retry dials that person again; those rows are still accepted, but they're counted in without_id so you can see it happening rather than discovering it from a complaint.
id only has to be unique within one source. Two sources feeding one campaign never collide.
The rest of the contract
| Field | Notes |
|---|---|
source | Names the job (crm-nightly, warehouse). Scopes dedupe, and appears in the campaign's Sources list with when it last delivered. Defaults to api. |
phone | Also accepted as phoneNumber. Rows without one are counted in skipped. |
id | Also accepted as externalId. |
variables | Becomes the target's context variables. |
Limits
Send at most 1000 targets per request — page through larger loads. The whole request is processed before it responds, so a bigger page risks a timeout with no clear answer about how much landed.
Per API key, per hour: 200,000 rows and 1,200 requests. Both are runaway guards rather than pricing levers — 200k rows is a 200‑page backfill, far above any nightly sync. Exceeding either returns 429 with a Retry-After header saying how long the window has left; the ready‑made scripts read it and exit cleanly rather than sleeping through it inside a cron job. Nothing is lost — the next run continues where the last one stopped.
Pushed rows go through exactly the same path as every other source, so Do‑Not‑Call suppression (suppressed) and phone normalisation apply identically.
Every other ceiling that applies to an API caller — the HTTP rate limit these quotas sit on top of, page-size clamps, concurrency — is on Rate limits & quotas.
Write‑back does not apply to pushed sources. We hold no credential for your system and would not know where to put the answer — read outcomes back with GET /api/v1/calls or a call.ended webhook instead.
Getting outcomes back
Pushing alone is half a loop. Without writing outcomes back, your job keeps selecting the same rows every night — they de-duplicate so nobody is called twice, but your database never learns anything.
GET /api/v1/campaigns/{id}/results?since=2026-07-30T10:05:00Z&limit=1000
{
"results": [
{ "callId": "…", "id": "lead-8412", "phone": "+919876543210",
"outcome": "no-answer", "attempt": 2, "completedAt": "2026-07-30T10:09:00Z" }
],
"nextSince": "2026-07-30T10:09:00Z"
}
id is the id you sent, so an outcome maps straight onto a row in your table. outcome is the carrier's own verdict — answered, no-answer, busy or failed — the same value a spreadsheet write-back receives, not a flattened "failed": the difference between nobody picked up and the number is dead is usually why you wanted this.
Store nextSince and pass it back on the next poll. It's a watermark, not an offset — results arrive continuously, so an offset would silently skip rows that landed between two polls. Losing your watermark is safe: you re-read and re-apply the same updates to the same rows.
Why polling rather than a webhook. The call.ended webhook does not fire at all for a call nobody answered — exactly the row you most want back — and a database behind a firewall usually can't receive an inbound POST either. So this runs on the same cron that already pushes.
Ready-made sync scripts
You don't have to write the job. integration-examples/campaign-sync/ has working scripts for PostgreSQL, MySQL/MariaDB and MongoDB — copy one, edit the query, put it in cron:
export TELENOW_API_KEY=vai_live_... TELENOW_CAMPAIGN_ID=...
export DATABASE_URL=postgresql://readonly:[email protected]:5432/app
python3 postgres_sync.py --dry-run --limit 20 # check the query first
python3 postgres_sync.py # send it
They are two-way: push new rows, then pull outcomes back and write them into your own table, so tomorrow's query naturally selects only genuinely new leads. They handle keyset paging, watermark state, retry with backoff on 429/5xx, --dry-run, and exit codes for cron alerting. HTTP uses the Python standard library, so the database driver is the only dependency.
Any other database works the same way — the scripts differ only in the twenty lines that run the query. Snowflake, BigQuery, SQL Server, Oracle: copy the closest one and change the read.
Finding the campaign
| Method | Path | Purpose |
|---|---|---|
GET | /api/v1/campaigns | Campaigns for the key's org |
POST | /api/v1/campaigns | Create one (write‑scoped key) |
GET | /api/v1/campaigns/{id} | One campaign and its counters |
POST | /api/v1/campaigns/{id}/start | Start or resume dialing |
POST | /api/v1/campaigns/{id}/pause | Stop claiming new targets |
POST | /api/v1/campaigns/{id}/targets | Push targets |
GET | /api/v1/campaigns/{id}/results | Pull outcomes back |
Creating a campaign over the API
Use this whenever you want more than a handful of calls. The single-dial endpoint (POST /api/sessions/initiate-call) places exactly one call and refuses past your concurrency limits — so calling it 200 times in parallel gives you a couple of calls and 198 429s. A campaign takes the whole list at once and paces the dialing for you.
Step 1 — create it, with the first page of targets
curl -X POST https://api.telenow.ai/api/v1/campaigns \
-H "X-API-Key: your_key" -H "Content-Type: application/json" \
-d '{
"name": "October reactivation",
"agentId": "agt_…",
"concurrency": 5,
"timezone": "Asia/Kolkata",
"startTimeLocal": "09:00",
"endTimeLocal": "20:00",
"maxAttempts": 2,
"source": "crm-nightly",
"targets": [
{ "id": "lead-8412", "phone": "+919876543210", "variables": { "first_name": "Asha" } },
{ "id": "lead-8413", "phone": "+919876543211" }
]
}'
{
"campaign": { "id": "…", "status": "running", "total_targets": 2, "concurrency": 5, "…": "…" },
"targets": { "added": 2, "duplicates": 0, "suppressed": 0, "skipped": 0, "withoutId": 0, "source": "crm-nightly" }
}
Returns 201 Created.
Every campaign field from Create a campaign below is accepted here — same names, same defaults, same validation. Three extra fields belong to this endpoint:
| Field | Type | Default | Notes |
|---|---|---|---|
start | bool | true | Begin dialing immediately. Pass false to stage the campaign and release it later with …/start. |
targets | array | [] | Optional first page, same shape and same idempotency rules as pushing targets. Max 1000 per request. |
source | string | api | Names the job for dedupe and the campaign's Sources list. Also accepted as sourceName. |
startdefaults totrue— this endpoint exists to make calls, and an API caller has no dashboard step to press start. If your workflow reviews a list before it goes out, send"start": false.
Step 2 — page the rest in
One request carries at most 1000 targets. For a larger list, create the campaign and then push pages:
curl -X POST https://api.telenow.ai/api/v1/campaigns/{id}/targets \
-H "X-API-Key: your_key" -H "Content-Type: application/json" \
-d '{ "source": "crm-nightly", "targets": [ … ] }'
Give every row a stable id and pushing stays safe to retry — see Give every row a stable id.
Step 3 — control it
# stop claiming new targets — calls already in flight finish normally
curl -X POST https://api.telenow.ai/api/v1/campaigns/{id}/pause -H "X-API-Key: your_key"
# resume
curl -X POST https://api.telenow.ai/api/v1/campaigns/{id}/start -H "X-API-Key: your_key"
Both return the campaign, and both are idempotent — starting a running campaign or pausing a paused one changes nothing, so a retried automation step can't double-dial.
Step 4 — collect the outcomes
Poll GET …/results with a watermark, or subscribe to the call.completed webhook. Both report targets nobody answered, which a fire-and-forget dial never tells you.
How fast will it dial?
Two ceilings, and the smaller wins:
- the campaign's own
concurrency(1–50), and - your per-number concurrency limit — 2 by default, counted on each phone number.
A campaign set to concurrency: 10 on a single number capped at 2 runs two calls at a time. To genuinely go faster, spread the agent across more numbers or ask your platform admin to raise the per-number cap.
Limits on this endpoint
| Limit | Value |
|---|---|
| Campaign creates per key | 60/hour (CAMPAIGN_CREATE_PER_HOUR) |
| Targets per request | 1000 |
| Rows per key | 200,000/hour, shared with …/targets |
| Role | owner, admin or developer key |
Both quotas are charged before anything is written, so a request that exceeds them leaves no half-made campaign behind. See Rate limits & quotas for the limits that apply to every other endpoint.
Endpoints
| Method | Path | Purpose |
|---|---|---|
GET | /api/orgs/{orgId}/campaigns | List campaigns |
POST | /api/orgs/{orgId}/campaigns | Create a campaign (starts in draft) |
GET | /api/orgs/{orgId}/campaigns/{id} | Campaign detail |
PATCH | /api/orgs/{orgId}/campaigns/{id}/schedule | Update calling window, AMD, and/or retry policy |
POST | /api/orgs/{orgId}/campaigns/{id}/targets | Upload targets — raw CSV (multipart) |
POST | /api/orgs/{orgId}/campaigns/{id}/targets/json | Upload targets — mapped rows (JSON) |
POST | /api/orgs/{orgId}/campaigns/{id}/start | Start (draft/paused → running) |
POST | /api/orgs/{orgId}/campaigns/{id}/pause | Pause (running → paused) |
POST | /api/orgs/{orgId}/campaigns/{id}/cancel | Cancel (any non‑terminal → cancelled) |
POST | /api/orgs/{orgId}/campaigns/{id}/retry-failed | Re‑queue every failed target |
GET | /api/orgs/{orgId}/campaigns/{id}/calls | List campaign targets/calls (paginated) |
GET | /api/orgs/{orgId}/campaigns/{id}/calls/export | Export targets as CSV |
Create a campaign
curl -X POST https://api.telenow.ai/api/orgs/{orgId}/campaigns \
-H "Authorization: Bearer eyJ…" -H "X-Org-Id: {orgId}" \
-H "Content-Type: application/json" \
-d '{
"name": "Q1 outreach",
"agentId": "agt_…",
"concurrency": 5,
"startTimeLocal": "09:00",
"endTimeLocal": "18:00",
"timezone": "America/New_York",
"machineDetection": "off",
"maxAttempts": 3,
"retryBackoffSecs": 300,
"retryOnNoAnswer": true
}'
| Field | Type | Default | Notes |
|---|---|---|---|
name | string | — | Required. |
agentId | uuid | — | Required; must be an agent in this org. |
concurrency | int | 5 | Parallel calls. Clamped to 1–50. |
startTimeLocal / endTimeLocal | HH:MM | unset | Calling window. Send both or neither; one‑sided is a 400. Must differ. Overnight (start > end) is allowed. |
timezone | IANA tz | UTC | Validated; an unknown zone is a 400. |
machineDetection | string | off | off/false/none → off; true/voicemail/on → leave voicemail; hangup/drop → hang up. Other values 400. The voicemail words come from the agent, not this field — see Leaving a voicemail message. |
maxAttempts | int | 3 | Total attempts incl. the first. Clamped to 1–10. |
retryBackoffSecs | int | 300 | Base backoff; grown exponentially (base × 2^(attempt−1), capped 1h). Clamped to 5–3600. |
retryOnNoAnswer | bool | true | Redial unanswered/busy calls. false = only hard failures retry. |
The response data is the created campaign (snake_case fields: total_targets, completed_targets, failed_targets, start_time_local, machine_detection, max_attempts, retry_backoff_secs, retry_on_no_answer, status, …). It starts in draft.
Typical flow
- Create a campaign with the agent that will dial.
- Upload targets (CSV or JSON — see below).
- Schedule the calling window, AMD, and/or retry policy with
PATCH …/schedule. - Start it. Telenow dials through the list within the window and concurrency cap; pause/cancel any time.
- Poll
…/calls(or subscribe to webhooks) for per‑target outcomes, retry failures, and export when done.
Upload targets
Two ways to add targets — both return { "added": N, "suppressed": M }, where suppressed is the count dropped because the number is on your Do‑Not‑Call list. Targets are normalized on insert (keep a leading +, strip other non‑digits); rows with no digits are dropped.
Raw CSV (multipart)
POST …/targets takes a multipart/form-data upload. The field may be named file, csv, or targets. The CSV's first line is a header and must include a phone_number (or phone) column, case‑insensitive. Every other column becomes a per‑target variable under its header name.
curl -X POST https://api.telenow.ai/api/orgs/{orgId}/campaigns/{id}/targets \
-H "Authorization: Bearer eyJ…" -H "X-Org-Id: {orgId}" \
-F "[email protected]"
phone_number,customer_name,plan
+14155550123,Alex,Pro
+14155550199,Sam,Free
This is a lightweight server‑side parser (no embedded quotes/newlines). For Excel files or precise column→variable control, use the JSON endpoint — that's what the dashboard does.
Mapped rows (JSON)
POST …/targets/json takes already‑mapped rows. Each target needs a phoneNumber (aliases phone_number/phone accepted) and an optional variables object — these are the agent's context variables for that contact, stored as a JSON object per target. Rows with a blank phone are dropped.
curl -X POST https://api.telenow.ai/api/orgs/{orgId}/campaigns/{id}/targets/json \
-H "Authorization: Bearer eyJ…" -H "X-Org-Id: {orgId}" \
-H "Content-Type: application/json" \
-d '{ "targets": [
{ "phoneNumber": "+14155550123", "variables": { "customer_name": "Alex", "plan": "Pro" } },
{ "phoneNumber": "+14155550199", "variables": { "customer_name": "Sam", "plan": "Free" } }
] }'
Update schedule / settings
PATCH …/schedule updates the calling window, AMD, and/or retry policy on an existing campaign. It is presence‑sensitive: a field you omit is left unchanged, so the schedule editor and the retry editor can share one endpoint without clobbering each other.
- Calling window: send both
startTimeLocalandendTimeLocalto set it; send both empty (or both omitted) to clear it (dial any hour).timezoneis updated only when provided. machineDetection: omit to leave AMD untouched; sendtrue/hangup/off(or a friendly spelling) to change it.- Retry trio (
maxAttempts,retryBackoffSecs,retryOnNoAnswer): omit any to leave it as‑is; provided numeric values are clamped to the same bounds as create.
# Tighten the retry policy without touching the calling window.
curl -X PATCH https://api.telenow.ai/api/orgs/{orgId}/campaigns/{id}/schedule \
-H "Authorization: Bearer eyJ…" -H "X-Org-Id: {orgId}" \
-H "Content-Type: application/json" \
-d '{ "maxAttempts": 5, "retryOnNoAnswer": false }'
Lifecycle
curl -X POST https://api.telenow.ai/api/orgs/{orgId}/campaigns/{id}/start -H "Authorization: Bearer eyJ…" -H "X-Org-Id: {orgId}"
curl -X POST https://api.telenow.ai/api/orgs/{orgId}/campaigns/{id}/pause -H "Authorization: Bearer eyJ…" -H "X-Org-Id: {orgId}"
curl -X POST https://api.telenow.ai/api/orgs/{orgId}/campaigns/{id}/cancel -H "Authorization: Bearer eyJ…" -H "X-Org-Id: {orgId}"
Each returns { "success": true, "message": "campaign started|paused|cancelled" }. State transitions are guarded: start only acts on draft/paused, pause only on running, cancel on anything not already completed/cancelled (and also skips pending targets).
List targets / calls
GET …/calls returns one page of the campaign's targets plus per‑status counts.
| Query param | Default | Notes |
|---|---|---|
limit | 50 | Clamped to 1–500. |
offset | 0 | For paging. |
status | — | Optional filter: pending | dialing | completed | failed | skipped. |
curl "https://api.telenow.ai/api/orgs/{orgId}/campaigns/{id}/calls?status=failed&limit=100" \
-H "Authorization: Bearer eyJ…" -H "X-Org-Id: {orgId}"
{
"success": true,
"data": {
"calls": [
{
"id": "…", "campaign_id": "…", "org_id": "…",
"phone_number": "+14155550123",
"variables": { "customer_name": "Alex", "plan": "Pro" },
"status": "failed", "session_id": null, "attempt": 3,
"last_error": "call not answered: no-answer",
"scheduled_for": "2026-06-13T09:00:00Z",
"started_at": "2026-06-13T09:00:01Z", "completed_at": "2026-06-13T09:00:31Z"
}
],
"total": 12,
"statusCounts": { "pending": 0, "dialing": 0, "completed": 480, "failed": 12, "skipped": 20 }
}
}
total honors the status filter; statusCounts is the full per‑status breakdown (unfiltered), which the dashboard renders as filter chips.
Retry failed targets
POST …/retry-failed re‑queues every finalized‑failed target for a fresh round of attempts. It resets each target's attempt counter, clears the prior error/session, rolls the campaign's failed counter back, and reopens a completed campaign to running (a paused one keeps its state; the retried rows wait until you resume). A cancelled campaign is terminal and returns a 400.
curl -X POST https://api.telenow.ai/api/orgs/{orgId}/campaigns/{id}/retry-failed \
-H "Authorization: Bearer eyJ…" -H "X-Org-Id: {orgId}"
{ "success": true, "data": { "retried": 12 } }
Export targets (CSV)
GET …/calls/export streams the campaign's targets as a CSV download (honoring an optional status filter), capped at 100,000 rows. Columns: phone_number, status, outcome, attempt, last_error, scheduled_for, started_at, completed_at, duration_sec, session_id, variables (the per‑target variables flattened to JSON in one column so re‑imports and spreadsheet pivots both work).
Two of those are worth calling out:
outcome— the carrier's verdict:answered,no-answer,busy, or the failure reason. This is what separates the two very different thingsstatus = failedcovers, a number nobody picked up and a number that is wrong or dead. It is the same value a call destination writes into your spreadsheet and thatGET /v1/campaigns/{id}/resultsreturns, so one call reads the same way everywhere. Empty for a target that hasn't finished (pending,dialing) or was never dialed (skipped) — thestatuscolumn beside it says which.duration_sec— how long the call actually lasted, in seconds, taken from the call's session. Empty, not0, when there is no session: a target that never connected has no duration, and a zero would drag down an average as though it did. Also empty once the call's session has been deleted under your retention policy.
curl "https://api.telenow.ai/api/orgs/{orgId}/campaigns/{id}/calls/export?status=completed" \
-H "Authorization: Bearer eyJ…" -H "X-Org-Id: {orgId}" -o campaign-targets.csv
Choosing the columns
Pass fields — a comma‑separated subset of the column names above — to narrow the file. Omit it for every column, which is what the dashboard's export does unless you untick something in its Columns menu.
Your order is the file's order, so a two‑column "who answered" sheet is just the two names in the order you want to read them:
curl "https://api.telenow.ai/api/orgs/{orgId}/campaigns/{id}/calls/export?fields=phone_number,outcome,duration_sec" \
-H "Authorization: Bearer eyJ…" -H "X-Org-Id: {orgId}" -o answered.csv
An unrecognized name is a 400 listing the real ones, rather than a quietly missing column — on a campaign that genuinely has blanks, a dropped column and an empty one look identical.
When the file is incomplete
The 100,000‑row cap is the one failure a CSV cannot show you: 100,000 rows of real data look exactly like a finished campaign, and whoever you forward it to has no way to tell. So a capped export says so three ways.
Response headers:
| Header | Meaning |
|---|---|
X-Export-Truncated | true or false. Always sent — false is a positive "this file is complete", which is different from an older server that never reported at all. |
X-Export-Rows | Rows actually in the file. Only on a partial one. |
X-Export-Total | Rows the same filters match with no cap. Only on a partial one. |
The filename gains -partial-<rows>-of-<total>, e.g. campaign-…-targets-partial-100000-of-213456.csv — the only signal still attached once the file has been saved and mailed onwards.
The dashboard warns before you download (as soon as the target count exceeds the cap) and again afterwards, naming the file and how many rows are missing.
To get everything, export one status at a time — the cap applies per request, and the status counts are on the campaign detail page.
curl -sD - "https://api.telenow.ai/api/orgs/{orgId}/campaigns/{id}/calls/export" \
-H "Authorization: Bearer eyJ…" -H "X-Org-Id: {orgId}" -o targets.csv | grep -i x-export
# x-export-truncated: true
# x-export-rows: 100000
# x-export-total: 213456
Notes
- Numbers on your Do‑Not‑Call list are excluded at upload time (counted as
suppressed) and re‑checked at dial time, so a number added to the DNC list after upload won't be dialed either. See the Do‑Not‑Call API. - Each campaign call mints a session through the same path as a one‑off outbound call, so its recording, transcript, and analysis appear in your call history just like any other call.