Conventions
The API is predictable on purpose: one set of conventions holds everywhere, so what you learn on one resource transfers to all of them.
IDs: prefixed ULIDs
Section titled “IDs: prefixed ULIDs”Every ID is a prefixed ULID: ten_… tenants, call_… calls, camp_…
campaigns, agnt_… agents, cont_… contacts, plus per-entity prefixes for
the rest (agv_ agent versions, tmpl_ templates, att_ contact attempts,
cl_ contact lists, scrun_ scrub runs, imp_ imports, exp_ exports,
evt_ events, whe_ webhook endpoints, tel_ telephony accounts, pn_
phone numbers, pool_ number pools, vn_ verified numbers). IDs are text,
k-sortable, and self-describing in logs and support tickets. Never parse
them, but do log them.
Pagination: cursors only
Section titled “Pagination: cursors only”All list endpoints paginate by cursor: ?limit=50&cursor=…, responses wrap
as {"data": […], "next_cursor": "…", "has_more": true}. There is no offset
pagination anywhere, and there is no exception to the envelope - including
GET /v1/api-keys, which answered a bare array until 2026-08-11 and broke a
generic list walker on the first resource most integrations touch. Cursors
are opaque, so pass back a verbatim next_cursor or get
invalid_cursor; never construct one.
limit defaults to 50 and caps at 200. Walk a collection until has_more
is false:
cursor=""while :; do page=$(curl -s "https://api.vocapable.com/v1/calls?limit=200&cursor=$cursor" \ -H "Authorization: Bearer vcp_test_...") echo "$page" | jq -c '.data[]' [ "$(echo "$page" | jq -r .has_more)" = "true" ] || break cursor=$(echo "$page" | jq -r .next_cursor)doneFiltering
Section titled “Filtering”Flat query params with operator suffixes: ?status=running,
?created_at.gte=2026-07-01T00:00:00Z. Comma lists are OR:
?sub_code=booked,interested. An unsupported field, operator, or value
returns invalid_filter naming the offender.
Idempotency
Section titled “Idempotency”Idempotency-Key: <uuid> is honored where an endpoint declares it. The key
and response body are stored for 24 hours; a replay returns the original
result with Idempotent-Replay: true. Reusing a key for a different
request is refused
(idempotency_key_reuse); a retry racing an
in-flight original gets
idempotency_key_in_flight, so wait and
retry.
Which endpoints honor it, today:
| Endpoint | Idempotency-Key |
|---|---|
POST /v1/campaigns/{id}/launch |
Required (idempotency_key_required without it) |
POST /v1/agents/{id}/test-call |
Optional |
POST /v1/messages |
Optional |
| Everything else | Not read |
The API reference is the authority: an endpoint that
honors the header declares an Idempotency-Key parameter, and one that does
not ignores it rather than honoring it silently. This page previously
said every POST accepted the header. It did not, and a client that retried a
timed-out POST /v1/contacts on that basis created a duplicate dial record.
Until the guard covers more of the surface, treat a timeout on any POST
without the parameter as unknown: reconcile by reading the collection
(GET /v1/contacts?phone_e164=…, GET /v1/campaigns) rather than by
retrying blind.
One endpoint will never accept it: POST /v1/agents/{id}/voice-session. Its
response carries a live single-use session token, and the idempotency ledger
persists response bodies.
Rate limits
Section titled “Rate limits”Token bucket per API key: 600 read requests/min and 120 write
requests/min by default; bulk endpoints (imports, exports) draw from a
separate budget. Exceeding the budget returns 429
rate_limited with Retry-After.
A rate-limited response carries RateLimit-Limit, RateLimit-Remaining, and
RateLimit-Reset. Honor those headers rather than hard-coding the numbers,
because per-plan multipliers exist - and treat their absence as “no
information”, not as “unlimited”. Every key-authenticated /v1 route runs
the limiter today, and the server refuses to start if one does not, so the
headers should be on every response you see; the guidance stands anyway
because a header you did not receive tells you nothing about the budget. A
client that back-offs only when RateLimit-Remaining reaches zero is
fragile; a client that also honors 429 and Retry-After is correct
everywhere.
Polling with ETag/304
Section titled “Polling with ETag/304”Hot read endpoints (GET /v1/campaigns/{id}/stats, /attempts,
/survey-rollup) carry an ETag; send If-None-Match and unchanged polls
return 304 with no body. A 5-second stats poll costs 12 req/min, well
inside the read budget. Use updated_at.gte delta filters on /attempts so
polls return only the rows that moved. This polling loop is the v1
realtime surface; there is no push channel at v1 beyond webhooks.
Formats
Section titled “Formats”- Timestamps are RFC 3339 UTC with
Z, as in2026-08-03T14:05:02Z. - Money is integer minor units plus a currency code, as in
{"amount_minor": 41250, "currency": "USD"}. Never floats. - Phone numbers are E.164 only, as in
+13125550188.
Versioning
Section titled “Versioning”The path carries the major version (/v1). Additive changes ship
unversioned; breaking changes require /v2 plus an api_version pin, per
tenant for request/response shapes, and per webhook endpoint for event
payload shapes, so a receiver never has to redeploy in lockstep with the
platform. Contract validation failures return 422
invalid_request with per-field entries in
errors[]; see Errors for the problem shape.