Outbound campaigns

Upload a list, and an agent calls everyone on it through your own telephony account. Paced, retried sensibly, and confined to the hours the law allows.

Calling hours are enforced, not documented

09:00–21:00 ISTIndia's TCCCPR 2018 rules confine unsolicited commercial calls to those hours. That is the default here and it is enforced: outside the window nothing is dialled, and work is pushed to the next opening rather than dropped, so a campaign left running overnight resumes in the morning with its list intact. The window is configurable with a timezone, because a customer calling their own staff in another country has a different obligation — but the default will not break the law for you.

How it works

  1. We POST to your provider: dial this number, and when it is answered fetch this URL.
  2. The person answers.
  3. Your provider fetches our URL and we return XML telling it to open a websocket.
  4. The call runs on the same engine as everything else.

Step 3 is the security-sensitive one. A provider cannot authenticate — it is a machine fetching a URL — so that endpoint is public, and whatever is in the URL is effectively public too. It therefore carries a single-use call token, valid for minutes, that grants permission to start exactly one call. An API key in that position would put full account access in every provider's request log. A replayed token is refused.

Uploading a list

curl -X POST https://api.latency.cam/v1/contact-lists \
  -H "Authorization: Bearer $LATENCY_KEY" -H "Content-Type: application/json" \
  -d '{"name": "September deliveries", "csv": "name,phone,order_id\nAsim,+91 90000 00011,ORD-1"}'

The phone column is found by name — number, phone, mobile, msisdn and others all work — because requiring an exact header means a customer's export fails for a reason they cannot see. Every other column becomes a variable, so a file with name and order_id columns produces an agent that can say both without any mapping.

Duplicates are collapsed rather than rejected. A list assembled from two exports routinely contains the same person twice, and refusing the whole file is unhelpful when the right behaviour — call them once — is obvious. Since numbers are matched on their last ten digits, +91…, 91… and 0… are recognised as one person.

Starting a campaign

curl -X POST https://api.latency.cam/v1/campaigns \
  -H "Authorization: Bearer $LATENCY_KEY" -H "Content-Type: application/json" \
  -d '{
    "name": "Delivery reminders",
    "agent_id": "ag_...",
    "list_id": "cl_...",
    "account_id": "pa_...",
    "concurrency": 3,
    "attempts_max": 2,
    "retry_after_s": 1800,
    "start": true
  }'

The agent's version is pinned at creation, for the same reason a live call pins one: a campaign running for two days must not change what it says because somebody edited the agent halfway through.

The four rules the dialer follows

RuleWhy it is this way
Nobody is called twiceEvery attempt is claimed from the database atomically, so two ticks of the loop — or two processes — cannot take the same contact. This is the worst bug available here, because its symptom is a person's phone ringing over and over.
Do-not-call is checked at dial timeNot at upload. Somebody who asks not to be called during a campaign has to stop being called by that campaign, and a list filtered at import cannot honour that.
A bad credential stops the campaignIt would fail identically for all ten thousand contacts, so retrying is a slower way to fail while spending your money. A busy line is retried; a refused credential is not.
Concurrency is capped at 6One GPU serves about eleven conversations. Outbound is the lowest-priority traffic on the box and must leave room for the inbound calls that are somebody's business.

Do not call

curl -X POST https://api.latency.cam/v1/dnc \
  -H "Authorization: Bearer $LATENCY_KEY" -H "Content-Type: application/json" \
  -d '{"numbers": ["+919876543210"], "reason": "asked not to be called"}'

Rotating the number you call from

Carriers and handset spam filters score a number by how much it calls. One number placing three thousand calls in a morning gets flagged and quietly stops connecting; six numbers sharing that load do not. You experience the difference as an answer rate that collapses for no visible reason, and the natural thing to blame is the agent.

curl -X PUT https://api.latency.cam/v1/provider-accounts/pa_3d81/numbers \
  -H "Authorization: Bearer $LATENCY_KEY" -H "Content-Type: application/json" \
  -d '{"numbers": ["+918000000001", "+918000000002", "+918000000003"]}'
Round-robin, not randomRandom selection is measurably uneven over a few hundred calls — with six numbers the busiest gets noticeably more than the quietest purely by chance, which is the one property rotation exists to remove. Round-robin is exactly even by construction, and the cursor is read and advanced inside a single transaction so two simultaneous dials cannot both take the same number. Verified: seven picks over a pool of three returned each number in order and wrapped, with no number used more than one time more than any other.

Every attempt records the number it went out on, so afterwards you can answer “which of my numbers stopped connecting” rather than guessing. Setting the list replaces it rather than merging, because the number you most want to remove is the one that just got blocked.

One call, without a campaign

Building a contact list and a campaign to make a single call is four steps of setup for one conversation, and a customer who cannot cheaply test an agent ships it untested. It is also the wrong shape for a call your own software triggers — an order shipped, a payment failed.

curl -X POST https://api.latency.cam/v1/calls/outbound \
  -H "Authorization: Bearer $LATENCY_KEY" -H "Content-Type: application/json" \
  -d '{
    "to_number": "+919876543210",
    "agent_id": "ag_7f3a2b",
    "account_id": "pa_3d81",
    "variables": {"name": "Asim", "order_id": "A-1029"}
  }'
No retries and no calling window here — on purposeThose belong to campaigns, which exist to call a lot of people responsibly. This is one call: if nobody answers, that is the answer. Adding retries behind a test button would quietly make it an auto-dialler with no calling hours. The do-not-call list is still checked, and a suppressed number is a 409. There is no call record until somebody answers, because until then nothing was said.

Watching it run

PathGives
GET /v1/campaignsAll of them, with progress and outcomes
GET /v1/campaigns/{id}One, plus whether the calling window is open right now
GET /v1/campaigns/{id}/callsEvery attempt, its outcome, and a link to the transcript
POST /v1/campaigns/{id}/pauseStop new calls. Conversations in progress finish
POST /v1/campaigns/{id}/cancelStop entirely

Pausing deliberately does not hang up on the person currently talking. You pressed pause to stop reaching new people.

It survives a restart

All pacing state lives in the database, so a deploy in the middle of a campaign resumes instead of re-dialling everybody, and attempts that were in flight when the process stopped are returned to the queue rather than sitting marked “dialing” forever, which would stop the campaign ever completing.