Skip to content

API conventions ​

Everything that is the same on every endpoint, so the reference pages do not repeat it. Read this once.

Base URL ​

Every path in the reference is relative to a base URL that already ends in /api/v1.

The samples in the reference use $CALLAGENT_API_URL for the base and $CALLAGENT_API_KEY for the key, so every one of them runs as printed once these two are exported:

bash
export CALLAGENT_API_URL="http://localhost:8000/api/v1"
export CALLAGENT_API_KEY="ca_sk_your_private_key_here"

Authentication ​

A bearer key on every request:

http
Authorization: Bearer ca_sk_…

Keys come in two prefixes — ca_sk_ (private, server-side) and ca_pk_ (public, browser-safe, exchanged at POST /client-tokens for a short-lived JWT). Scopes, origin and IP restrictions, rotation and client tokens are all on Authentication.

Tenancy ​

The organization is derived from the key. There is no organization_id parameter anywhere in this API, so there is nothing to tamper with. A resource belonging to another organization returns 404 — identical to an id that never existed, because a 403 would confirm the resource exists.

Pagination ​

List endpoints return a Laravel paginator:

json
{
  "data": [ … ],
  "links": { "first": "…", "last": "…", "prev": null, "next": "…" },
  "meta": { "current_page": 1, "per_page": 25, "total": 412, "last_page": 17, "…": "…" }
}

Follow links.next rather than incrementing a page number.

Two page-size parameters

The parameter is not the same everywhere, and the reference page for each endpoint names the one it reads:

  • limit on the Engage surface — campaigns, suppression lists, contacts.
  • per_page on Platform and Billing — API keys, webhooks, customers, invoices.

Both default to 25 and cap at 100. Sending the wrong one is not an error; it is silently ignored and you get 25 rows.

Field naming ​

The API is snake_case (first_message_mode, phone_number_id). The @callagent/contracts package and the config-as-code YAML files are camelCase (firstMessageMode, phoneNumberId), and the CLI converts between them.

Key spaces you own — metadata, variable_values, HTTP headers, tool parameters — are passed through untouched, so an order_id metadata key stays order_id.

Three places where the wire is camelCase anyway

These are real and current. Send what the reference page says, not what the convention implies:

  • POST /telephony/calls takes an entirely camelCase body — assistantId, phoneNumberId, customSipHeaders, idempotencyKey and the rest — while returning snake_case. POST /calls is the snake_case equivalent of the same operation.
  • POST /campaigns/{id}/pause and /cancel read endLiveCalls. The rest of both bodies (reason, farewell) is snake_case.
  • Every telephony write endpoint — phone numbers, SIP trunks, gateways, DIDs, inbound and outbound routes, telephony accounts — validates camelCase request keys (friendlyName, outboundCallerIdNumber, inboundSourceAcl) and returns snake_case. Contact-centre extensions are the exception and are snake_case throughout.

A second naming trap, not about case: POST /campaigns/{id}/contacts takes each contact's number as phone, while the contact list and the CSV export return it as phone_number.

Errors ​

One shape everywhere, keyed on error.type:

json
{
  "error": {
    "type": "validation_error",
    "message": "The request payload failed validation.",
    "errors": { "name": ["The name field is required."] },
    "documentation_url": "https://docs.callagent.dev/api/errors#validation",
    "request_id": "01JQ…"
  }
}

Branch on the HTTP status and error.type. error.code is a second, larger vocabulary that appears on provider failures, call events and webhook payloads — see errors, which explains the difference and lists both.

Send X-Request-Id on a request and it is echoed back in the error body. That is the reliable way to correlate a failure with your own logs, because request_id is absent from 2xx responses and from the errors raised by authentication middleware.

Idempotency ​

One endpoint honours the header

Idempotency-Key is read by POST /calls and nowhere else. Sending it to any other endpoint has no effect, and a retried POST there will act twice.

Two endpoints take an idempotency key as a body field instead: POST /calls (idempotency_key, which wins over the header) and POST /billing/credits (idempotency_key, forwarded to Stripe). POST /telephony/calls takes idempotencyKey, camelCase.

http
Idempotency-Key: 6f1c0a5e-…

Replaying a key on POST /calls returns the original call, with 201 again, rather than dialling a second time. There is no expiry — the key lives as long as the call row — and no request fingerprinting, so replaying a key with a different body silently returns the first call rather than reporting a conflict. Use a key derived from your own idempotent unit of work.

Everywhere else, make retries safe by checking before you create, or by using a PUT where one exists.

Rate limits ​

300 requests per minute per API key by default, varying by plan, with the budget on every response:

http
X-RateLimit-Limit: 300
X-RateLimit-Remaining: 287

A 429 carries Retry-After in seconds. The real numbers, the per-plan ceilings and a retry helper are on rate limits.

Versioning ​

The path carries the version (/api/v1); the OpenAPI document carries a dated spec version, which is also echoed on webhook deliveries as X-CallAgent-Api-Version.

Reading the generated reference ​

The reference pages are generated from docs/api/openapi.json, which the control plane exports from its own routes, form requests and API resources. Four things follow from that, all of which the pages account for:

  1. Every endpoint listed exists. A check reconciles the spec against the live route table on every run and fails on an operation that no route serves, or a route no operation documents. Nothing is documented that has not been confirmed.

  2. The "Scope" line is what the handler enforces, not what it should enforce. Scopes are checked per-endpoint rather than by a blanket middleware, and a good number of endpoints enforce none. Where a page says so, that is a fact about the code. See the caveat on Authentication.

  3. The "Errors" table is what the handler can actually reach. The exporter infers failures from type hints and misses everything raised inside a service, so those tables are written from the controllers. 401 and 429 are omitted from every one of them because they are global — any endpoint can return either.

  4. Untyped arrays are JSON objects. The exporter types a PHP array cast as a JSON array with no item schema. In every such case the model declares array<string, mixed> — an associative array, which encodes as a JSON object. The reference therefore renders those fields as object (transcriber, model, voice, the *_plan blocks, metadata). The authoritative shapes for them are the zod schemas in @callagent/contracts, summarised on Assistants.

Keeping it honest

node scripts/check-api-drift.mjs re-exports the spec from the routes, diffs it against the committed document, and reconciles both against php artisan route:list. It fails on a stale spec, an undocumented route or a documented route that does not exist.

The raw document ​

Download openapi.json — OpenAPI 3.1, the same file every SDK generator reads. The reference index carries the current path, operation and schema counts.

Documented against the code. Anything not yet implemented is labelled, never described as working.