Guide
The Fellix REST API for your own systems. Everything here is scoped to the organization that issued the credential you send.
Base URL: https://api.fellix.ai — every path below hangs off it, so /v1/ping is https://api.fellix.ai/v1/ping.
Authentication
Two ways in, both on the Authorization header:
- Static key —
Authorization: Bearer flx_live_…. Created in the dashboard under Settings → API. Shown once, at creation; we store only a hash of it and cannot show it again. Lost means rotate. - OAuth2 client credentials —
POST /v1/oauth/token with grant_type=client_credentials, client_id and client_secret, in exchange for a one-hour access token. No refresh token is issued (RFC 6749 §4.4.3); ask again with the same secret.
Revoking a credential takes effect immediately, including for access tokens already issued from it — an access token carries an identifier, not a copy of your permissions.
Scopes
Every credential carries a set of <resource>:<action> scopes. A request for something the credential was not granted answers 403, and the body names the scope it wanted. customers:write implies customers:read. GET /v1/account tells you what you hold.
Rate limits
Per organization, per endpoint, per minute: every credential your organization holds draws on the same allowance, so minting a second key does not double it. Every response to a credential we accepted carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset (the Unix timestamp at which the current window ends); a 429 also carries Retry-After, in seconds. A 401 carries none of them — the bucket is named from the credential's organization, so a request we could not attribute has no bucket to report.
The window slides: the minute before the current one still counts, weighted by how much of it is still in view, so an allowance cannot be spent twice either side of a boundary. Heavier endpoints carry a proportionally lower ceiling; the one that applies is always the X-RateLimit-Limit that endpoint returns.
Two things are never charged for. A 5xx, because the failure is ours, and a 409 from an Idempotency-Key conflict, because nothing was performed — retry either without spending allowance.
A single credential can be held *below* the organization's ceiling, which is what to ask for when one integration is noisier than the rest. GET /v1/account reports the ceiling in force for the credential you are using.
List endpoints are cursor-paginated. There is no page number.
GET /v1/customers?limit=50
→ { "data": [...], "meta": { ..., "pagination": { "next_cursor": "Ij...", "has_more": true } } }
GET /v1/customers?limit=50&after=Ij...
→ { "data": [...], "meta": { ..., "pagination": { "next_cursor": null, "has_more": false } } }
The cursor is opaque and signed — do not parse or construct one, just send back what the previous response gave you. Ordering is newest first (created_at DESC). A cursor we did not issue answers 400.
Relations
Nothing is embedded unless you ask. ?include=customer,tags embeds those relations; each endpoint documents which names it accepts, and an unknown one answers 400 rather than being quietly dropped. The same names configure what your webhook payloads carry, so a webhook and a GET of the same resource can be made to agree exactly.
Filtering
?filters[<field>_<predicate>]=<value>, combined with AND:
GET /v1/customers?filters[created_at_gteq]=2026-08-01T00:00:00Z&filters[name_cont]=acme
Predicates depend on the field's type — text takes eq|cont|start (case-insensitive), numbers and ids eq|in|gt|gteq|lt|lteq, timestamps gt|gteq|lt|lteq (ISO8601 only), enums eq|in. in takes a comma-separated list. Each endpoint documents its own fields. An unknown field, an unknown predicate or a value we cannot read answers 400 and names every offending key — nothing is ever silently ignored.
Keeping a copy in sync
There is no "changes since" endpoint and you do not need one. Every list endpoint takes filters[updated_at_gteq], and the order is newest first:
GET /v1/customers?filters[updated_at_gteq]=2026-09-25T00:00:00Z&limit=100
Store the timestamp you started the run at, not the newest updated_at you saw, and overlap the next window by a minute — a row written while you were paging through belongs to the next run, and an overlap costs you a duplicate you can ignore instead of a row you never see.
Rows are never hard-deleted from under you, but a client can be merged into another: follow merged_into_id and stop writing to the old one.
Writing
Create with POST, change with PUT. Bodies are namespaced by resource ({"customer": {...}}), and a field this API does not return is a field it does not accept — an unrecognised one answers 400 and names it rather than being ignored.
Retry safely with Idempotency-Key. A timeout leaves you unable to tell "never arrived" from "arrived, answer lost"; send a key you generate and a retry returns the first answer with Idempotency-Replayed: true instead of writing again. Reusing a key with a different body is a 409, because that is a bug worth being told about rather than papered over.
Two rules worth knowing before you send a message:
- A thread a colleague has claimed refuses an API send (
422). Someone is typing in it, and two voices from one window is worse than a refusal you can see. - On WhatsApp, 24 hours after the customer's last message only a template is delivered. That is what
POST /v1/conversations/{id}/messages/template is for; a plain send after the window closes is accepted by nobody.
Messages sent this way carry no sender — the same shape the assistant's own replies use — and count as system messages, not as one of your agents' replies.
Reporting
Three aggregates over conversations: /v1/analytics/pipeline (where things stand), /v1/analytics/trends (the same won/lost as a series) and /v1/analytics/tags (volume and outcome per tag). All take start_date / end_date and default to the last 30 days, and every answer carries the window it used in meta.period — a figure without its window is not a figure.
They are narrow on purpose. Our own dashboard computes more than this, in shapes that change whenever a panel changes; what is published here is the stable core of each. They are also the most expensive thing on this API, so they carry a lower rate limit than a list.
leads and voice_calls are ordinary list endpoints rather than aggregates, each behind its own scope: a lead carries the campaign and ad ids you join your spend data against, and a call carries its transcript — which is what a real person said, and a different thing to be trusted with than a count.
Webhooks
The push half of this API. An organization subscribes its own HTTPS endpoints in the dashboard and receives HMAC-signed JSON — see WebhookEnvelope below for the wrapper.
data.attributes is the same shape as a GET of that resource. Not approximately: the two are serialized from one definition, so conversation.created carries exactly what GET /v1/conversations/{id} returns for the same relations. That is why you can act on a payload directly and only call back when you want something the event did not carry.
Embedded relations are per endpoint, and nothing is embedded by default. Each endpoint chooses which relations its payloads carry, per resource, from the same list ?include= accepts — so an endpoint configured with conversation: [customer, tags] receives exactly what ?include=customer,tags returns. Configure it in the dashboard; an endpoint that chooses nothing gets ids and attributes only, which is the cheapest thing to receive and usually enough to decide whether to call back.
Some events describe resources this surface has no GET for yet — voice_call.*, whatsapp_call.*, lead.created, team.*. Their shapes are PublicVoiceCall, PublicWhatsappCall, PublicWhatsappCallForward, PublicLead, PublicTeam and PublicTeamMember, documented here for that reason.
Dedupe on the envelope id (stable across endpoints and across redeliveries) and order on data.attributes.updated_at — delivery order is not guaranteed.
Errors
Everything except the OAuth token endpoint (which follows RFC 6749) answers in one shape:
{ "errors": [ { "title": "...", "detail": "...", "code": "not_found", "status": 404 } ] }
code is the stable, machine-readable part: auth-blank, auth-invalid, forbidden, not_found, invalid_cursor, invalid_parameter, too_many_requests.
Text follows Accept-Language where we have a translation; en-US otherwise.