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

FieldNotes
sourceNames the job (crm-nightly, warehouse). Scopes dedupe, and appears in the campaign's Sources list with when it last delivered. Defaults to api.
phoneAlso accepted as phoneNumber. Rows without one are counted in skipped.
idAlso accepted as externalId.
variablesBecomes 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

MethodPathPurpose
GET/api/v1/campaignsCampaigns for the key's org
POST/api/v1/campaignsCreate one (write‑scoped key)
GET/api/v1/campaigns/{id}One campaign and its counters
POST/api/v1/campaigns/{id}/startStart or resume dialing
POST/api/v1/campaigns/{id}/pauseStop claiming new targets
POST/api/v1/campaigns/{id}/targetsPush targets
GET/api/v1/campaigns/{id}/resultsPull 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:

FieldTypeDefaultNotes
startbooltrueBegin dialing immediately. Pass false to stage the campaign and release it later with …/start.
targetsarray[]Optional first page, same shape and same idempotency rules as pushing targets. Max 1000 per request.
sourcestringapiNames the job for dedupe and the campaign's Sources list. Also accepted as sourceName.

start defaults to true — 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:

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

LimitValue
Campaign creates per key60/hour (CAMPAIGN_CREATE_PER_HOUR)
Targets per request1000
Rows per key200,000/hour, shared with …/targets
Roleowner, 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

MethodPathPurpose
GET/api/orgs/{orgId}/campaignsList campaigns
POST/api/orgs/{orgId}/campaignsCreate a campaign (starts in draft)
GET/api/orgs/{orgId}/campaigns/{id}Campaign detail
PATCH/api/orgs/{orgId}/campaigns/{id}/scheduleUpdate calling window, AMD, and/or retry policy
POST/api/orgs/{orgId}/campaigns/{id}/targetsUpload targets — raw CSV (multipart)
POST/api/orgs/{orgId}/campaigns/{id}/targets/jsonUpload targets — mapped rows (JSON)
POST/api/orgs/{orgId}/campaigns/{id}/startStart (draft/pausedrunning)
POST/api/orgs/{orgId}/campaigns/{id}/pausePause (runningpaused)
POST/api/orgs/{orgId}/campaigns/{id}/cancelCancel (any non‑terminal → cancelled)
POST/api/orgs/{orgId}/campaigns/{id}/retry-failedRe‑queue every failed target
GET/api/orgs/{orgId}/campaigns/{id}/callsList campaign targets/calls (paginated)
GET/api/orgs/{orgId}/campaigns/{id}/calls/exportExport 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
  }'
FieldTypeDefaultNotes
namestringRequired.
agentIduuidRequired; must be an agent in this org.
concurrencyint5Parallel calls. Clamped to 1–50.
startTimeLocal / endTimeLocalHH:MMunsetCalling window. Send both or neither; one‑sided is a 400. Must differ. Overnight (start > end) is allowed.
timezoneIANA tzUTCValidated; an unknown zone is a 400.
machineDetectionstringoffoff/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.
maxAttemptsint3Total attempts incl. the first. Clamped to 1–10.
retryBackoffSecsint300Base backoff; grown exponentially (base × 2^(attempt−1), capped 1h). Clamped to 5–3600.
retryOnNoAnswerbooltrueRedial 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

  1. Create a campaign with the agent that will dial.
  2. Upload targets (CSV or JSON — see below).
  3. Schedule the calling window, AMD, and/or retry policy with PATCH …/schedule.
  4. Start it. Telenow dials through the list within the window and concurrency cap; pause/cancel any time.
  5. 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 startTimeLocal and endTimeLocal to set it; send both empty (or both omitted) to clear it (dial any hour). timezone is updated only when provided.
  • machineDetection: omit to leave AMD untouched; send true/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 paramDefaultNotes
limit50Clamped to 1–500.
offset0For paging.
statusOptional 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 things status = failed covers, 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 that GET /v1/campaigns/{id}/results returns, so one call reads the same way everywhere. Empty for a target that hasn't finished (pending, dialing) or was never dialed (skipped) — the status column beside it says which.
  • duration_sec — how long the call actually lasted, in seconds, taken from the call's session. Empty, not 0, 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:

HeaderMeaning
X-Export-Truncatedtrue 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-RowsRows actually in the file. Only on a partial one.
X-Export-TotalRows 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.