Skip to content

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.

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.

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:

Terminal window
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)
done

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-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.

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.

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.

  • Timestamps are RFC 3339 UTC with Z, as in 2026-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.

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.