Skip to main content

Overview

A run is one agent dialling a list of contacts. It is the batch primitive: instead of creating a conversation per person and tracking hundreds of ids, you launch a run and read one aggregate. A run does not replace the conversation, it batches it. Launching mints one conversation per contact, and the call attempts hang off that conversation exactly as they do for a conversation you created on its own. So everything the Conversations guide says about transcripts, analysis, analytics and webhooks holds for a conversation a run produced. What the run adds is a lifecycle and a roll-up.

Building and running one

Three requests, and none of them can call anybody twice:
Then pause, resume and cancel drive it, and GET /runs/{id} reports where it has got to.
Create takes no contacts, on purpose. If it did, a request that timed out after the server committed would leave you retrying into a second draft holding the same list — dialling nothing, but sitting there launchable. A retried create leaves an empty draft instead, which holds nothing anyone can launch by mistake. It is also why this API has no Idempotency-Key header: the one endpoint that would have needed one does not, once create is empty.

Resource Structure

scheduledAt is when a SCHEDULED run starts dialling, null on one that launched immediately. scheduleError is why a scheduled run did not start at its time — see Starting later. The run carries no caller ID, retry policy or call window. Those are the agent’s, and the agent is the single source of them.

Required Scopes

The run scopes are new and have no legacy aliases — runs had no public surface before, so there is nothing to stay compatible with. Keys that already existed were granted all three, and new keys are issued with all three, so there is nothing to add. delete:runs is separate from write:runs deliberately: a key that may build and drive a run need not also be able to remove it. The conversations drill-down needs read:conversations as well because its rows are conversation resources, with the same analysis and analytics GET /conversations returns. read:interviews satisfies that half, as it does everywhere else.

Progress

Every conversation the run has produced lands in exactly one bucket, so the six counts sum to total: Two of them are worth reading carefully. inProgress is not derived from the conversation’s stored status. Nothing flips that row while a call is being placed and ringing, so a contact whose phone is ringing right now would otherwise report as queued. Such a call is moved out of queued rather than added on top, which is why the buckets still sum to total. unreachable is its own bucket rather than part of failed because nothing malfunctioned — the contact simply never picked up within the retry budget. Folding the two together would also mean an entirely unreachable run never reached 100%. completion is the fraction of conversations in a terminal state (completed + failed + cancelled + unreachable), between 0 and 1.

What a launch actually promises

A 2xx from launch means the conversations are queued, not dialled. The provider is called later by the dispatch worker, outside your request, so:
  • per-contact outcomes never come back in the response. They arrive as conversation.* webhooks, each carrying runId
  • the batch is all-or-nothing. The whole contact list is admitted against your billing before any of it is dispatched; a run you cannot afford is refused whole with a 402, nothing is queued, and the run stays a draft. Partial admission was rejected because it turns one request into an outcome nobody can predict or undo — you cannot un-call the contacts who already went out
Admission is a preflight, not a reservation. Nothing is held, so two different runs launched at the same moment can both pass and your balance can move between admission and the dial. What protects the money is the per-call reservation before each individual call, which fails closed. What admission buys you is a single refusal up front instead of a run that fails one call at a time and reads like a telephony problem.
Rate limits do not bound any of this. Your API key’s per-key and per-company limits bound requests, not the calls one request fans out into. Admission is what bounds dialling; the rate limiter never was.

Starting later

POST /runs/{id}/launch with a scheduledAt books the run instead of dispatching it: it moves to SCHEDULED, queues nothing, and starts on its own at that time — up to 30 days ahead, the same ceiling a single conversation’s scheduleTime has always had. It fires through the same code an immediate launch runs, so a run that starts on Monday morning is indistinguishable from one launched by hand on Monday morning. It may begin up to a minute late. Until it fires it is still an ordinary draft: attach more contacts, or cancel it and the appointment goes with it. Contacts attached in the meantime are held rather than dialled — a run booked for Monday has not started, so a contact added on Saturday waits and goes out with the rest.
A booking is not a reservation. The batch is admitted against billing when you schedule it and again when it fires, and the second one decides. Nothing is held in between.A run refused at its time stays SCHEDULED and is retried, with the reason in scheduleError. Topping up is enough to make it go. But that field is the only place the refusal is visible — a run whose scheduledAt has passed while its status is still SCHEDULED has been refused, and nothing else will tell you.

Lifecycle

Cancel is reachable from every non-terminal status, not only from PAUSED. Cancelling a scheduled run clears its appointment as well as setting the status, so it never starts.
COMPLETED is not terminal. Attaching a contact to a finished run reopens it to RUNNING, which is what makes a run usable as a rolling sequence rather than a one-shot batch. If your integration stops polling on COMPLETED, it will miss anything added afterwards.
CANCELLED is terminal. PAUSED is reversible, and it is a real stop:
  • Nothing further is dialled, including calls already queued when you paused. They are withdrawn from the dispatch queue, and anything the queue had already picked up is refused at the call itself.
  • The work is held, not discarded. The conversations stay in place and resume puts back exactly what pause held — a contact already reached during the running window is not called again, and none of the waiting ones are lost.
  • A contact attached to a paused run is not called either. The conversation is created, the run stays paused, and they go out with everything else on resume.
CANCELLED differs on the last two: it closes the un-dialled conversations out, and there is no way back. A call already connected when you cancel is left to finish; its retry is refused rather than rescheduled.

Correlating a Conversation Back to Its Run

Every conversation carries a nullable runId:
It is null for a conversation created on its own, and the run’s id for one a run produced. The webhook payloads carry it too, beside the ids they already send, so a conversation.completed handler attributes the call to its batch without reading the conversation back at all:
Like the other payload ids, runId does not depend on the webhook’s vocabulary — it is always present, and null where there is no run.

Monitoring a Run

Then drill in when you need per-contact detail:
Prefer webhooks to polling where you can: each conversation in a run fires the ordinary conversation.* events, and runId in the payload attributes it to the run without a request of your own.

Company Isolation

You can only read runs belonging to your own API key’s company. A run in another company returns 404 Not Found, the same as one that does not exist — the API does not confirm that a resource you cannot read exists.

Get Run

Status and aggregate progress

List Run Conversations

The per-contact drill-down

Conversations

What each conversation in a run carries

Agents

The agent a run dials with