Skip to content

API versioning

What /api/v1 promises, which changes we may ship without notice, what would require a new version, and how to write client code that survives both.

/api/v1 is the published, stable surface. Everything documented on this site lives under it, and the contract on this page is what you can build against.

There is currently one version. There is no version header, no Accept-Version, and no /api/v2. The version lives in the path and nowhere else, so a request that works today works tomorrow without you sending anything extra.

What is public and what is not #

Surface Prefix Supported?
Public developer API /api/v1 Yes. Documented here, authenticated with an amk_… key, stable under the rules below.
The console's own API Everything else No. Private, undocumented, authenticated differently, and changed whenever the console needs it to change.

The console is a client of an internal API that exists to serve the console. Its routes are not a supported external surface: they carry no compatibility promise, they can be renamed or removed in any deploy, and they authenticate with a session rather than a key. If you find one in a browser's network tab and build against it, it will break, and it will break without warning because nobody was told it had users.

Anything the console can do that you need programmatically is either on /api/v1 already or is not available over an API. Creating keys and webhook endpoints is the clearest example: both are done only on the Developers page, on purpose, because minting a credential from a credential is a design decision we have not made.

A machine-readable description of the public surface is served, without authentication, at:

GET/api/v1/openapi.json

The info.version inside that document describes the document. The API version is the v1 in the path.

Changes we may make without notice #

These are additive. Client code written to the rules further down absorbs all of them without a deploy.

Adding a field to a response. A new field can appear on any object at any time — a conversation, a ticket, a call, a webhook payload. Existing fields keep their names, types, and meanings.

Adding an endpoint. A new path under /api/v1, or a new method on an existing path. Nothing you already call changes.

Adding an optional request field. New optional parameters with defaults that preserve current behaviour. A request that does not send them behaves exactly as it does today.

Adding a value to an enum-like field. A new status, a new outcome, a new channel, a new event type. This is the one that breaks naive clients most often — see below.

Adding a new details.reason. The nine error.code values are fixed. The reason strings inside details are not: a condition that today produces a generic error may later produce one with a more specific reason, so that you can handle it better. New refusals tied to your workspace's state, such as plan allowances, credits or a channel your plan no longer includes, also arrive this way: allowance_exhausted, wallet_empty and channel_dormant were added under the existing 412. Your default branch is what makes that a free improvement rather than an incident.

Adding a response header. Diagnostic or informational headers may appear.

Changes that would require a new version #

We would not ship any of these into /api/v1. If one became necessary, it would arrive as a new versioned namespace, and /api/v1 would keep behaving as documented.

Removing or renaming a field. If preview exists on a conversation today, it exists on a conversation in /api/v1 tomorrow.

Changing a field's type. A string does not become an object. An integer does not become a string. A single value does not become an array.

Changing the error code for an existing condition. A condition that returns 412 failed_precondition today will not start returning 409 conflict in v1. The reason inside details may become more specific; the code and status will not move underneath you.

Tightening validation. A request that is accepted today will not start being rejected. Loosening — accepting something we currently reject — is additive and may happen.

Changing the meaning of an existing value. Not just the name or the type: if status: "ringing" means the phone is ringing, it will not come to mean something else.

Removing an endpoint, a scope, or an event type.

There is no deprecation timeline to plan around, because there is nothing deprecated

Nothing in /api/v1 is scheduled for removal. If that ever changes, it will be a new version alongside this one rather than a countdown inside it.

What this means for your code #

Five habits. Each one costs nothing to adopt now and each one prevents a specific outage.

Tolerate unknown fields #

Parse permissively. Read the fields you need and ignore the rest.

Concretely: do not use a strict schema validator that rejects unknown keys, do not use a deserialiser configured to fail on unrecognised properties, and do not assert on the exact shape of a response in a production code path. If your language's JSON library has a "fail on unknown" mode, this API is not the place for it.

Two endpoints make this non-negotiable rather than merely advisable. POST /tickets and GET /tickets/{ticket_id} return the ticket record, which carries more than the fields documented in Tickets — assignment state, escalation bookkeeping, internal versioning. POST /contacts returns a contact object whose full shape is owned elsewhere. Read the documented fields from both; treat everything else as absent.

Python
# Fine — reads what it needs.
ticket_ref = response.json()["public_ref"]
# Fragile — breaks the first time a field is added.
Ticket(**response.json())

Do not switch exhaustively on a status #

Several fields carry values from systems that own their own vocabularies. Write a default branch on every one of them.

Call status is the important case. It is typed as a plain string with no declared enum. You will observe ringing, in_progress, completed, and failed, and that list is an observation rather than a contract — the call lifecycle can write a state you have not seen. Code that raises on an unrecognised call status is code that raises during an incident, which is the worst possible moment to lose your call tracking.

Node.js
// Correct: known states get behaviour, everything else is recorded and ignored.
switch (call.status) {
case "ringing":
case "in_progress":
return keepPolling(call);
case "completed":
return finalise(call);
case "failed":
return markFailed(call);
default:
log.info({ status: call.status }, "unrecognised call status");
return keepPolling(call);
}

The same applies to:

  • status on a message.status webhook. WhatsApp owns that vocabulary — sent, delivered, read, failed — and we pass it through unchanged rather than mapping it into one of our own.
  • channel on a conversation. New channels are additive.
  • status on a conversation or a ticket. Lifecycle states your team drives in the console.
  • type on a webhook envelope. There are twenty event types you can subscribe to today. An endpoint subscribed to all events (no boxes ticked when you added it) receives any new type the moment it exists. The console's Test button sends a ping event, which is not one of the twenty, so a handler with no default branch has a bug you can trigger yourself from the console. See Changelog for when each type arrived.

Treat a missing field as absent, not as an error #

Webhook payload projections omit keys whose value is null rather than sending them as null. A call.outcome event without metadata is a call your integration didn't place, not a malformed delivery. Read with a default (data.get("metadata"), data.metadata ?? null) rather than an index that throws.

REST responses do the opposite for documented fields — they send null explicitly. Both shapes exist; code that handles either handles both.

Do not depend on field ordering or whitespace #

JSON objects are unordered. The order keys appear in a response is an artifact of serialisation and carries no meaning; do not compare responses as strings, and do not hash a response body to detect change.

There is one place where bytes genuinely matter, and it runs in the opposite direction. Webhook signatures are computed over the exact bytes we sent, so your verifier must read the raw body and must not re-serialise a parsed object first. And idempotency matching hashes the raw request bytes, so a retry must resend the identical string — serialise once, keep the string, resend it. In both cases the rule is "use the bytes as they are", never "reproduce the bytes yourself".

Regenerate clients deliberately #

If you generate a client from /api/v1/openapi.json, regenerate on your schedule rather than at build time from a live fetch. A build that reaches out to a remote document is a build that fails when the network does, and a generated client that silently changes shape between two deploys of the same commit is worse than a hand-written one.

Generated clients also tend to default to strict deserialisation, which is exactly the setting to turn off. Check what yours does with an unexpected field before you trust it in production.

Timestamps: two shapes, both stable #

Not a versioning rule but the same class of assumption, and worth stating because it surprises people:

Where Shape Example
REST resources ISO 8601 with an offset and microseconds 2026-08-01T10:02:55.118307+00:00
Webhook envelope created_at ISO 8601 with second precision and a literal Z 2026-08-01T10:02:55Z

Both are stable. Parse them with a real ISO 8601 parser rather than a format string, and normalise on the way into your store if you keep both. A field that cannot be formatted comes back as null rather than as an error.

Errors

The nine codes — the fixed part of the error contract, and the reason strings that can grow.

Overview

The API surface — every endpoint under /api/v1 in one place.

Changelog

What has actually shipped, and how this changelog is maintained.

Going live

The pre-launch checklist — where forward-compatible error handling becomes a tick box.