Skip to main content

REST API overview

Converger exposes three groups of HTTP endpoints, all served by the Phoenix router in lib/converger_web/router.ex. This page covers what is shared between them: base URLs, authentication, the token kinds and their claims, error bodies, rate limiting, pagination, idempotency and CORS. The endpoint references are:

  • Tenant API: server-to-server API under /api/v1, for your backend and bots.
  • Client API: the Direct Line-inspired API under /api/v1/converger, for end-user clients (web chat, mobile apps).
  • Inbound webhooks: provider-facing endpoints under /api/v1/channels/:channel_id, called by WhatsApp, Infobip or your own systems.

:::info OpenAPI The API is described by an OpenAPI 3.1 document, committed as priv/static/openapi.json and served at /api/openapi.json, with Swagger UI at /api/docs (on in dev, opt-in in prod). See OpenAPI and Swagger UI. Changes follow the versioning policy. :::

:::tip SDKs Server-side SDKs for Python, Go and Elixir wrap the client and tenant APIs, normalize every error shape below into one error type, and verify webhook signatures. See Server-side SDKs. :::

Base URLs​

EnvironmentBase URLNotes
Development (mix phx.server)http://localhost:4000Default endpoint port.
Production releasehttps://<PHX_HOST>The endpoint URL is https://$PHX_HOST:443; the HTTP listener binds to PORT (default 4000) behind your proxy. See deployment.

All paths below are relative to the base URL. Every API path is versioned under /api/v1.

Path prefixFamilyAuthentication
/api/v1/tokens, /api/v1/conversations..., /api/v1/routing_rules...Tenant APIx-api-key (tenant API key) or x-channel-token (channel token, deprecated, see migrating from the legacy surfaces)
/api/v1/channels/:channel_id/inbound, /api/v1/channels/:channel_id/statusInbound webhooksPer-channel request signature, see inbound
/api/v1/converger/tokens/generateClient APIAuthorization: Bearer <channel secret>
/api/v1/converger/... (everything else)Client APIAuthorization: Bearer <converger token>

Two WebSocket endpoints complement the REST API: /socket/converger/websocket (the Converger client socket, joined with a converger token; its URL is returned as streamUrl; clients can also send activities on it with postActivity) and /socket/websocket (legacy conversation channel, joined with a conversation token from POST /api/v1/tokens; deprecated, see migrating from the legacy surfaces). See WebSocket. The wire protocol for the client socket is Converger Protocol v1 (spec in progress, #21, #63).

Requests and responses are JSON (content-type: application/json), except the multipart upload endpoint and the attachment download endpoint of the client API. The JSON routes go through Phoenix's accepts ["json"]; a request with an Accept header that excludes JSON is rejected with 406.

Authentication​

Credentials at a glance​

CredentialWhere it comes fromSent asUsed by
Tenant API key (primary)Created with the tenant (admin panel or admin API); format cvg_live_ + 43 URL-safe Base64 chars; every scopex-api-key: <key>Tenant API and management API
Scoped API keyPOST /api/v1/manage/api_keys or the admin API; same format; scopes read, write, manage, optional expiry (management API)x-api-key: <key>What its scopes allow
Admin API tokenADMIN_API_TOKEN (configuration)Authorization: Bearer <token>Admin API /api/v1/admin only, from the admin IP allowlist
Channel token (deprecated)Shown (and copyable) per channel in the admin panel, Channels pagex-channel-token: <jwt>Tenant API: create conversation, issue conversation token, and accepted by every tenant-authenticated route
Conversation token (deprecated)POST /api/v1/tokenstoken param of the legacy socket /socket/websocketLegacy WebSocket channel conversation:<id>
Channel secretGenerated per channel (admin panel)Authorization: Bearer <secret>POST /api/v1/converger/tokens/generate only
Converger tokenPOST /api/v1/converger/tokens/generate, /tokens/refresh, /conversationsAuthorization: Bearer <jwt>Client API and /socket/converger/websocket

Channel tokens, conversation tokens, POST /api/v1/tokens and the legacy socket are deprecated (#23) and will be removed no earlier than two minor releases and six months after it. They keep working unchanged until then. The tenant API key is not deprecated. See migrating from the legacy surfaces.

API keys and channel secrets are stored hashed (and the channel secret also encrypted) and looked up by hash; see ADR-0012. Rotating a tenant API key keeps the previous key valid for a grace period (default 24 hours, config :converger, :api_key_rotation_grace_period in seconds).

warning

The tenant API key and channel secrets are server-side credentials. Never ship them to a browser or mobile app. End-user clients get short-lived converger tokens from your backend, which exchanges the channel secret for them.

Tenant authentication (x-api-key / x-channel-token)​

ConvergerWeb.Plugs.TenantAuth checks, in this order:

  1. x-api-key: the key is hashed and matched against the scoped keys (not revoked, not expired), then against the tenant's primary key or its previous key while the grace period lasts. The tenant must have status active.
  2. x-channel-token: the token must be a channel token. Every token type is signed with the same key, so the claims decide: a channel token has typ: "channel" (tokens issued before that claim existed are recognised by sub: "channel_<channel_id>" and the absence of conversation_id and type). Its channel must exist, be active and belong to the token's tenant, and the tenant must be active. Conversation tokens and Converger client tokens (which end-user browsers hold) are rejected with Invalid token, even though they carry a tenant_id.

Failures return 401 with code unauthorized (error envelope):

{ "error": { "code": "unauthorized", "message": "Invalid or inactive API Key", "details": null, "request_id": "..." } }
messageCause
Missing authentication headersNeither header present
Invalid or inactive API KeyUnknown key, expired previous key, revoked or expired scoped key, or tenant not active
Invalid tokenx-channel-token signature or expiry invalid, not a channel token, or its channel or tenant does not exist
Channel is not activeChannel token valid, channel deactivated
Tenant is not activeChannel token valid, tenant suspended

Then the request must have the scope its action needs: read for GET, write for other methods, manage for configuration changes (scopes). Without it the answer is 403 insufficient_scope. The primary key and channel tokens have every scope.

Some endpoints apply stricter rules on top. GET /api/v1/conversations requires x-api-key and answers 403 to a channel token. POST /api/v1/conversations and POST /api/v1/tokens do not run TenantAuth at all and authenticate with x-channel-token only. See the tenant API for each route.

Client authentication (Authorization: Bearer)​

ConvergerWeb.Plugs.ConvergerAuth reads Authorization: Bearer <value> and runs in one of two modes:

  • Secret mode (/tokens/generate only): the value is first looked up as a channel secret of an active channel. If it is not one, the plug falls back to token mode, so a valid converger token is authenticated too and the controller rejects it with 400 ("Token generation requires channel secret, not a token").
  • Token mode (all other client routes): the value must be a valid converger token (signature, exp, type: "converger"), and the channel in its channel_id claim must still exist and be active.

Errors use the error envelope:

{ "error": { "code": "unauthorized", "message": "Invalid or expired token", "details": null, "request_id": "..." } }
Statuscodemessage
401unauthorizedMissing or malformed Authorization header
401unauthorizedInvalid or expired token (bad signature, wrong token type)
401token_expiredToken expired (refresh before exp, or issue a new token)
403channel_inactiveChannel not found or inactive (token valid, channel disabled or deleted)

A disabled channel therefore invalidates every outstanding converger token for it immediately, even before exp.

Token kinds and claims​

All tokens are HS256 JWTs signed with the endpoint secret_key_base (Converger.Auth.Signer), so rotating SECRET_KEY_BASE invalidates every issued token. Each also carries Joken's default registered claims: exp, iat, nbf, jti, and iss / aud (both "Joken"), all validated on verification.

TokenModuleClaimsTTL
Channel tokenConverger.Auth.Token.generate_channel_token/1typ: "channel", channel_id, tenant_id, sub: "channel_<channel_id>"3600 s (default exp)
Conversation tokenConverger.Auth.Token.generate_token/3conversation_id, tenant_id, sub: <user_id>3600 s (expires_in: 3600 in the response)
Converger tokenConverger.Auth.ConvergerTokentype: "converger", channel_id, tenant_id, sub: "converger_<channel_id>", optional conversation_id, optional user_id1800 s (expires_in: 1800)

Every converger token is bound to one channel: conversations (and their activities, uploads and attachments) on any other channel are reported as 404, even within the same tenant. A converger token without conversation_id is unscoped: it was issued by /tokens/generate, can create conversations and resume (GET /conversations/:id) conversations of its channel, but cannot join a conversation over the WebSocket; the socket needs the conversation token those endpoints return. A token with conversation_id is conversation-bound: requests for any other conversation id are answered 403 forbidden, and attachments of other conversations are reported as 404. user_id is copied from user.id at generation time and carried through refreshes; it identifies the end user's socket (see ADR-0020).

The channel token shown in the admin panel is regenerated on every render and expires one hour after it was displayed.

Error responses​

Every error response, on every family, uses one envelope (errors, ADR-0050):

{ "error": { "code": "conversation_closed", "message": "Conversation is closed", "details": null, "request_id": "GBHDfGZ0bW5Sl2YAAAEB" } }

Branch on code, never on message. details carries the errors per field for 422 (invalid_message, validation_failed) and retry_after_ms for 429 / 503. request_id is the request's x-request-id.

Status codes​

StatusCodesWhen
400bad_request, invalid_watermark, channel_inactive, inbound_not_supportedA request-level problem (Missing x-channel-token header, Invalid cursor, Invalid channel_id, Missing file in upload, malformed JSON or UUID, missing required body params), a bad watermark, a disabled channel, inbound to an outbound-only channel
401unauthorized, token_expiredMissing or invalid credential, invalid inbound signature, expired Converger token
403forbidden, insufficient_scope, insufficient_permission, channel_inactiveAuthenticated but not allowed (wrong conversation for the token, channel token where an API key is required, IP outside the admin allowlist); the API key lacks the scope or the Converger token the permission; the token's channel is disabled
404not_foundUnknown resource, or a resource of another tenant (tenant isolation never reveals existence), unknown route
406not_acceptableThe Accept header excludes JSON
409conversation_closed, not_failedActivity posted to a closed conversation (ADR-0017); replay of a delivery that is not failed (tenant API)
412precondition_failedA management call's If-Match ETag is stale (management API)
413payload_too_largeUpload over the size limit (File too large (max 10.0MB)), or a raw inbound body over the limit
415unsupported_media_typeUpload whose sniffed type is not allow-listed
422invalid_message, validation_failed, unsupported_channel_type, invalid_channel_configValidation failed (details per field), or the channel cannot do the operation
429rate_limitedRate limit exceeded
500internalUnhandled exception (no exception text is returned)
502upstream_unavailableStorage backend or provider failure (File could not be stored, please retry, Attachment storage unavailable)
503unavailableThe activity was rolled back because its delivery jobs could not be enqueued (transactional outbox, ADR-0001; details.reason: "delivery_enqueue_failed", safe to retry with an idempotency key), the database is unavailable, or the node is draining

The endpoint references show an error as code or code: message; the body is always the envelope above.

Every response carries an x-request-id header (Plug.RequestId), which is also attached to the server log lines of that request. Quote it when reporting a problem.

Responses to deprecated requests (POST /api/v1/tokens and any request authenticated with x-channel-token) also carry the RFC 9745 header Deprecation: @<unix time> and Link: <https://converger.aimtune.dev/api/migrating-from-legacy>; rel="deprecation". See the versioning and deprecation policy (Sunset headers, 12-month support window).

Rate limiting​

Hot paths are guarded by ConvergerWeb.Plugs.RateLimit on top of Hammer (ADR-0013). Windows are fixed and aligned to wall-clock time.

BucketApplies toCounted perDefault
activity_createPOST /api/v1/conversations/:id/activities, POST /api/v1/converger/conversations/:id/activitiestenant (shared by both APIs and the socket postActivity event)100 per 1 s
uploadPOST /api/v1/converger/conversations/:id/uploadtenant10 per 1 s
inboundPOST /api/v1/channels/:channel_id/inbound and /statuschannel (path parameter, checked before the channel is loaded)500 per 1 s
token_generatePOST /api/v1/converger/tokens/generate, POST /api/v1/converger/tokens/refreshchannel10 per 60 s
token_createPOST /api/v1/tokensclient IP10 per 60 s

Limits are resolved from, in order: a per-tenant override in tenants.limits (for activity_create, upload, inbound, token_generate), config :converger, Converger.RateLimit, limits: %{...}, then the defaults above. With several nodes, set RATE_LIMIT_BACKEND=cluster so counters are shared; see rate limiting.

A rejected request gets:

HTTP/1.1 429 Too Many Requests
retry-after: 1
content-type: application/json; charset=utf-8

{"error":{"code":"rate_limited","message":"Too many requests. Please try again later.","details":{"retry_after_ms":830},"request_id":"GBHDfGZ0bW5Sl2YAAAEB"}}

retry-after is in whole seconds (at least 1) until the current window ends. No X-RateLimit-* headers are sent. retry-after is not in the CORS exposed headers, so browser code on another origin cannot read it; use error.details.retry_after_ms from the body instead.

Pagination​

Every list endpoint is bounded (ADR-0018, Converger.Pagination). Two strategies are used.

Keyset cursors (conversations)​

GET /api/v1/conversations is ordered newest first on (inserted_at, id) and continues strictly after the last row of the previous page, so rows inserted meanwhile never shift pages.

ParamMeaning
limitPage size. Default 50, capped at 500. Missing, non-numeric or non-positive values use the default.
cursorThe previous response's meta.next_cursor. Omit (or send empty) for the first page. A malformed cursor returns 400 bad_request: Invalid cursor.
{
"data": [ { "id": "..." } ],
"meta": { "next_cursor": "dHM6MjAyNi0xMC0wOVQxMjowMDowMC4xMjM0NTZafDNmNmQ...", "has_more": true, "limit": 50 }
}

next_cursor is null and has_more is false on the last page. The cursor is opaque. It is currently URL-safe Base64 of ts:<ISO 8601 timestamp>|<uuid>, but clients must not build or parse it.

Sequence watermarks (activities)​

Activities are paged oldest first on the per-conversation seq that the server assigns under the conversation row lock (ADR-0006). The position is the watermark, the integer seq itself (ADR-0068):

ParamMeaning
limitDefault 100, capped at 1000 (same clamping rules).
watermarkThe previous response's watermark: return activities with a higher seq. Omit to start at the beginning of the conversation.

The watermark is the seq of the last activity returned, a JSON number (42), the same number Protocol v1 frames carry. Resuming needs no database lookup. When a page is empty, the position you sent is returned as an integer (null if you sent none). Before v3.0 the watermark was opaque (URL-safe Base64 of seq:<n>, c2VxOjQy for seq:42); for one release both APIs still accept that form, its decimal string and legacy Base64 activity-id watermarks, and log them as the deprecated opaque_watermark surface (migration). The tenant API answers a malformed watermark with 400; the client API starts from the beginning instead. The response shapes differ per family; see tenant API and client API.

Configuration​

Key (config :converger, :pagination)Env varDefault
default_limitPAGINATION_DEFAULT_LIMIT50
max_limitPAGINATION_MAX_LIMIT500
activity_default_limitPAGINATION_ACTIVITY_DEFAULT_LIMIT100
activity_max_limitPAGINATION_ACTIVITY_MAX_LIMIT1000
ws_replay_limitPAGINATION_WS_REPLAY_LIMIT100
lookup_limitPAGINATION_LOOKUP_LIMIT1000

GET /api/v1/routing_rules is not paginated. It returns the whole list, sorted by name and hard-capped at lookup_limit rows.

Idempotency​

Activity creation is idempotent per conversation when the caller supplies an idempotency key. The key is stored on the activity, and (conversation_id, idempotency_key) is unique. A repeated request with the same key returns the original activity with the same success status, and no second activity or delivery is created, even if the first request's response was lost or the conversation has since been closed.

EndpointWhere the key goes
Tenant API POST /api/v1/conversations/:id/activitiesx-idempotency-key header. A body field idempotency_key is ignored.
Client API POST /api/v1/converger/conversations/:id/activitiesx-idempotency-key header
Generic webhook POST /api/v1/channels/:id/inboundidempotency_key field in the JSON body
WhatsApp (Meta, Infobip) inboundAutomatic: the provider message id (wamid..., messageId)

The upload endpoint takes no idempotency key. Use a key whenever you retry after a timeout, a 5xx or a 503. Inbound batches are de-duplicated per message; see inbound webhooks and ADR-0015.

Mass assignment​

Clients can only set an activity's type, text, attachments, metadata and reply_to_id. The server sets tenant_id, conversation_id, sender, idempotency_key, seq and inserted_at, ignoring any such fields in a body (ADR-0005). Every API renders activities from one canonical serializer, so REST, WebSocket and outbound webhook payloads cannot drift (ADR-0004).

Activity limits (config :converger, :activity_limits), violations of which return 422:

LimitDefault
typeOne of message, event, typing, messageReaction, messageUpdate, messageDelete, conversationUpdate, endOfConversation
max_text_bytes65,536 bytes of text
max_attachments10 entries in attachments
max_attachment_bytes4,096 bytes per attachment object, measured as JSON
max_metadata_bytes16,384 bytes of metadata, measured as JSON

CORS​

CORS is handled by CORSPlug in the endpoint, before routing, so it applies to every route including error responses (ADR-0010).

  • Allowed origins come from CORS_ORIGINS (comma-separated; * allows any origin) and are read on every request, so a release picks them up without recompiling. Without it, the compile-time default is http://127.0.0.1:5500 and http://localhost:5500.
  • Allowed request headers: x-channel-token, x-api-key, authorization, plus CORSPlug's defaults (Authorization, Content-Type, Accept, Origin, User-Agent, DNT, Cache-Control, X-Mx-ReqToken, Keep-Alive, X-Requested-With, If-Modified-Since, X-CSRF-Token).
  • Allowed methods: GET, POST, PUT, PATCH, DELETE, OPTIONS. Preflights are answered 204.
  • No response headers are exposed.
note

x-idempotency-key is not in the allowed headers list, so a browser on another origin cannot send it: the preflight does not allow it. Cross-origin browser clients currently post activities without an idempotency key, or go through a same-origin backend.