Headless mode
Vocapable has two first-class consumption modes on the same engine and the
same /v1, same compliance floor, same pricing. In Workspace mode you
send data and Vocapable is your calling system of record. In Headless
mode your stack stays the system of record and Vocapable is consumed as
/v1 + webhooks.
There is no mode flag in the product. A tenant simply uses the surfaces it wants, and may mix them freely: a campaign for the quarterly survey, API-driven dials from your own scheduler, both on one tenant.
What headless looks like today
Section titled “What headless looks like today”The loop below runs entirely on /v1 with no CSV, no bulk upload, and no
one opening the dashboard:
- Create the contact with
POST /v1/contacts, singly or in batches, as an operational dial record. Its initial consent lands atomically with the contact, and its timezone and jurisdictions are durable gate facts that the platform never guesses. - Queue one call with
POST /v1/calls, naming only that existingcontact_idand an existingagent_id. The server pins the agent’s latest published version, chooses the verified default route/caller configuration, and writes a campaign-less attempt. - Receive supported call-lifecycle events by webhook,
such as
call.ended.appointment.bookedis also emitted after a configured live phone call successfully writes through the tenant’s active direct Google Calendar connection;shift.confirmedandsurvey.completedremain schema-reserved. Your scheduler never polls for supported call outcomes.
For a live rehearsal of the dial pipeline without a campaign at all, use
POST /v1/agents/{id}/test-call against a number your tenant has verified.
On a vcp_test_ key that runs the full gate → materialize → seize →
originate → outcome path synchronously and for free
(Test mode).
curl -X POST https://api.vocapable.com/v1/contacts \ -H "Authorization: Bearer vcp_test_..." \ -H "Content-Type: application/json" \ -d '{ "phone_e164": "+13125550188", "first_name": "Dana", "timezone": "America/Chicago", "jurisdictions": ["US", "US-IL"], "consent": { "consent_basis": "pewc", "captured_at": "2026-08-04T16:11:09Z", "scope": {"purposes": ["marketing"], "channels": ["voice"]}, "evidence": { "artifact_type": "web_form", "artifact_ref": "crm://consent/lead-784", "content_hash": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" }, "disclosure_text_shown": "By checking this box you agree to receive automated calls..." } }'curl -X POST https://api.vocapable.com/v1/calls \ -H "Authorization: Bearer vcp_test_..." \ -H "Idempotency-Key: 8f14e45f-ceea-467a-9f6b-1c2d3e4f5a6b" \ -H "Content-Type: application/json" \ -d '{ "agent_id": "agnt_01...", "contact_id": "cont_01..." }'Idempotency-Key is required on this endpoint because a retry can dial
someone twice. The body is closed: it has no to, caller_id,
telephony_account_id, or variables field. To make many calls, use the
campaign route and its scrub workflow; this endpoint is the one-contact
headless counterpart, not a bulk-dial backdoor.
Consent attaches atomically to the contact
Section titled “Consent attaches atomically to the contact”Headless POST /v1/contacts requires an initial consent object. It includes
consent_basis (pewc, pec, ebr, or transactional), the actual
captured_at time, a non-empty purpose/channel scope, and durable evidence
(artifact_type, artifact_ref, and its SHA-256 content_hash). For PEWC,
include the verbatim disclosure_text_shown too. The server resolves the
current API Source Contact Consent Attestation itself and appends the matching
consent record in the same transaction; callers cannot supply a provenance or
attestation id.
An API key creating contacts needs the account agreements and current API Source Contact Consent Attestation. A live key additionally needs the production agreement set. This proves contact provenance; it never replaces the per-contact evidence above.
Provenance is delivery-mechanism-neutral, and so is the line drawn through
it. Scraped contacts are refused on the platform however they arrive -
one POST at a time changes nothing about data that was extracted without
authorization. Purchased, rented, and appended lists are a different
tier: they are yours under your own supplier agreement, and they run -
over the API exactly as over an upload - under the executed Cold Outreach &
Data Provenance Waiver your account signs, which is also what covers calling
people who have not consented to be called. Signing it puts that program’s
duty on you; the platform’s controls stay operational safeguards and clear
nothing on your behalf.
What the API asks of you is unchanged in both tiers. POST /v1/contacts
still requires the whole consent object shown above - one of the four
consent_basis values, a true captured_at, a non-empty scope, durable
evidence and its hash - and the waiver adds no fifth “none” value, no
provenance field, and no bypass flag to that body. That object is not a
provenance declaration; it is the input the dialability gate reads at dial
time, which is why cold outreach still has to describe the basis it holds.
A contact whose recorded basis or scope cannot carry the purpose is refused
there with consent_insufficient or consent_scope_mismatch among the
errors[] reasons of the 409 below, whatever your account has signed.
One asymmetry to design around: POST /v1/calls names no campaign, so the
attempt declares no purpose class and the gate applies the strictest one
rather than guessing a lenient one - an ad-hoc dial is evaluated as
marketing. A contact whose basis would clear a reminder campaign can
therefore still be refused on the ad-hoc path.
No side door
Section titled “No side door”API-driven dials pass the same dialability gate as every other dial:
internal DNC, scrub/policy verdict, consent basis, quiet hours, frequency
caps, and (for live calls) subscription, spend, and dunning gates. A blocked contact returns 409
contact_not_dialable with
field-level errors[] naming the blocked contact_id and reason.
Two fail-closed refusals matter more in headless mode because your records
may be thinner than an imported list: the gate needs a resolvable timezone
and jurisdiction for every dial, and it never guesses. The current portal
and API expose no calling-policy configuration; this guide describes the
gate’s evaluated state, not a determination of what the law requires.
contact_timezone_unresolved and
jurisdiction_unresolved are what you get
otherwise, so carry timezone and jurisdiction facts on your contacts - as the
example above does.
The compliance floor runs on every call, in every telephony mode, through
every entry point: the AI-disclosure opener, internal-DNC suppression, and
the platform kill switches. A configured live phone call durably records an
opt-out before it closes; browser and simulated test calls have no contact to
suppress. Each waived element is written into the attempt’s
policy_snapshot as an explicit election, so an evidence export shows which
checks ran, which were waived, and on whose signature.
Your Twilio, your production contract
Section titled “Your Twilio, your production contract”Every production dial rides your own verified Twilio subaccount, so
connect-and-verify is an onboarding step (POST /v1/telephony-accounts, then
…/verify, and PATCH /v1/telephony-accounts/{id} to rotate the
credentials later). An ad-hoc live call uses the tenant’s verified default
BYO account and only an active, tenant-assigned, carrier-confirmed voice
number on it; the request cannot select another route or caller ID. Headless
production uses the same Production Services Order as everything else: the
production agreements scope arms live ad-hoc calls, campaign create and
launch, telephony-account create, and imports of non-verified numbers
(agreements_required until it is executed).
Completing that gate set does not self-activate the workspace: the active
owner requests review and staff must complete the fresh-step-up, dual-control
promotion before the owner can claim the one-time live key.
Reading an agreements_required 403
Section titled “Reading an agreements_required 403”Every agreement gate answers in one shape, so an integrator can cure a whole
set in one pass instead of discovering it one refusal at a time. The type
resolves to agreements_required, detail names which set the surface
needs, and errors[] carries one entry per outstanding document:
{ "type": "https://docs.vocapable.com/errors/agreements_required", "title": "The current legal agreements have not been accepted", "status": 403, "detail": "This surface creates live production calling, so it requires the production document set (ADR-0008) on top of the account set. Accept the current version of each via POST /v1/agreements/accept. Outstanding: production_services_order, scrub_attestation.", "instance": "/v1/calls", "errors": [ { "field": "agreement_kind", "reason": "production_services_order" }, { "field": "agreement_kind", "reason": "scrub_attestation" } ]}Cure it with GET /v1/agreements → POST /v1/agreements/accept, echoing the
exact version and content_sha256 you were served; an acceptance of bytes
the API did not serve is refused, and any version or hash change re-arms the
affected gate automatically. The gate_scope on each document tells you
which surfaces it arms - account, production, headless (the API Source
Contact Consent Attestation), or commitment (the 3-year term). Branch on
errors[].reason, never on the prose in detail.
You decide who to call and under what basis; the API describes what it checked and refuses what it cannot clear.
The reference is the contract
Section titled “The reference is the contract”Every endpoint named on this site exists in the API reference, which is generated from the server’s own OpenAPI document. If a page here and the reference disagree, the reference is right - and tell us, because the disagreement is the bug.
Where a dashboard surface has no API behind it yet, the honest list is kept in
the API reference rather than implied here. The headless contact and one-call
flow above are available over /v1.