Skip to content

Campaigns & scrub

A campaign never launches on an unexamined list. The path is: import → scrub → acknowledge the scrub report → create → launch. Each arrow is a gate with a named refusal.

POST /v1/contact-imports (multipart CSV + column-mapping JSON) returns 202 with an import job. Poll GET /v1/contact-imports/{id} to a terminal status, because imports are not in the webhook catalog, so polling is the completion signal. Per-row failures (bad E.164, duplicate, missing required column) are reported at GET /v1/contact-imports/{id}/errors as JSON or CSV (Accept: text/csv). Rows land in a contact list (cl_…).

Provenance: two tiers, and only one of them is a refusal

Section titled “Provenance: two tiers, and only one of them is a refusal”

Scraped contacts - and anything else obtained by unauthorized extraction - are refused on the platform, however they arrive: upload, API, or CRM sync. No supplier agreement stands behind data that was taken, so there is nothing for an attestation to attest to.

Lists you purchased, rented, or had appended are a different tier. They are your data, acquired under your own supplier agreement, dialed from your own carrier account, so you may import and call them once your account carries the executed Cold Outreach & Data Provenance Waiver (published with the rest of the set at www.vocapable.com/legal). The same waiver covers outreach to people who have not consented to be called. You sign it, and it places the duty for that program on you: the platform’s controls are operational safeguards, and none of them is a determination that a given call is permitted.

Which tier you are in is not a field on the import. A contact list’s source describes how the rows arrived (upload or api) and never where they came from; there is no purchased value to send, and no endpoint asks the question at import time. It is answered once on your account’s onboarding screen, and the waiver is what carries the answer. Every import still sends consent_attestation (signer name, signer title, affirmation, and one consent_basis of pewc, pec, ebr, or transactional), which the list records permanently: PATCH /v1/contact-lists/{id} renames a list and deliberately cannot rewrite its basis or attestation ref. That basis is not paperwork. It is read on every attempt, and what it says decides whether the dial happens - see Dial time.

POST /v1/scrub-runs {"target": {"contact_list_id": "cl_…"}}202; poll the run until completed. The report carries totals (contacts/dialable/blocked), a blocked_breakdown by reason (federal DNC, internal DNC, reassigned, litigator, state-blocked, line type), the dataset_versions it scrubbed against, and an expires_at.

Scrub stages fail closed, with one documented exception: if the litigator-scrub vendor is down, the run completes flagged "risk_flags": ["litigator_unscreened"] rather than blocking.

A human or system must then acknowledge the report with POST /v1/scrub-runs/{id}/acknowledge, recording actor, timestamp, and the totals as seen. Acknowledging totals that do not match the run refuses with scrub_totals_mismatch: the acknowledgment attests to what you saw. Runs go stale at expires_at (scrub_run_expired).

POST /v1/campaigns names the published agent version, the target list, a number pool, a compliance profile, the schedule, retry policy, and the scrub_run_id. Caller ID is always a pool reference, and the API never accepts an inline array of numbers; rotation order and per-number daily caps are properties of the pool. Calling windows are evaluated in each contact’s timezone, and the gate may apply stricter policy restrictions for that contact. The current portal and API expose no calling-policy configuration. telephony_account_id defaults to your account; when supplied it must be the account behind every number in the pool.

POST /v1/campaigns/{id}/launch (with Idempotency-Key, required) returns 200 {"status": "running", "dialable_contacts": …}. The gate refuses with a 409 problem when:

At dial time each attempt still passes the ordered pacer governors, abandon-rate ceiling, policy re-check, per-account CPS budget, spend cap, caller-ID rotation caps. AMD-detected machines are hung up and recorded as attempts only.

Section titled “Dial time reads the consent basis you sent”

The waiver decides what your account may run. It does not change what the gate computes for an individual contact, and integrators are caught by that distinction more than by anything else on this page.

Every attempt resolves the campaign purpose to a required consent class and compares it against the basis on record for that contact:

  • a basis below the requirement refuses with consent_insufficient - this is also what an expired consent record produces, because an expired basis is held as none
  • a basis high enough but scoped to another purpose refuses with consent_scope_mismatch; consent to appointment reminders does not authorize a sales campaign

Both are policy refusals rather than scrub blocks, which is a distinction your reconciliation code can see: they land at GET /v1/campaigns/{id}/outcomes as disposition: "blocked_by_policy", where a contact stopped at materialization lands as scrub_blocked. On the ad-hoc path the same reasons ride the errors[] array of the 409 contact_not_dialable problem. Accepting the waiver clears neither, and neither is a defect: a cold program still has to describe the basis it holds, and pewc, pec, ebr, and transactional remain the only four values the API accepts.

The floor controls are in the same position. The AI-disclosure opener, internal-DNC suppression at batch and at dial time, and opt-out capture run on cold outreach exactly as on consented traffic; no agreement you sign and no field you send reaches them.

Poll GET /v1/campaigns/{id}/stats at 5 seconds (it carries an ETag, so unchanged polls are 304), follow row-level activity at GET /v1/campaigns/{id}/attempts?updated_at.gte=…, and reconcile one row per targeted contact at GET /v1/campaigns/{id}/outcomes, including contacts blocked at materialization (disposition: "scrub_blocked"). Bulk extraction is POST /v1/exports. Outcome pushes ride webhooks.