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
Four ways in, all on the Authorization header:
- Static key —
Authorization: Bearer flx_live_…. Created in the dashboard under Settings → API keys. 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. - Signing in — the console on this page. You are sent to the dashboard, you choose an organization and confirm what the console may do, and you come back holding a one-hour token. Nothing is pasted anywhere and no key is created.
- Connecting an AI assistant — an MCP client such as Claude or Cursor. The person picks an organization and the assistant acts *as them*: it sees what they see and may do what their role may do, within the permissions they tick. Any active member can connect one, not only an owner. The connection lasts up to ninety days on a rotating refresh token, and is listed — and ended — under Account Settings → Connected apps.
Signing in and connecting an assistant are both the authorization_code grant with PKCE (RFC 7636). The console is registered for this page and no other. An assistant registers itself: POST /v1/oauth/register (RFC 7591 — open, public clients only) answers a client_id, or the client presents the https URL of its own metadata document as its client_id and we read the document from there. It then asks for resource=https://api.fellix.ai/mcp: a registered application is for the MCP server and cannot ask for an organization credential. Everything an assistant needs to find is in /.well-known/oauth-authorization-server, so a person configuring one types the server's address and nothing else. The MCP server answers at https://api.fellix.ai/mcp (Streamable HTTP): a connected assistant arrives there as its person. A static key in the header works there too and acts as the organization, the way it does here; so does a client-credentials token asked for with resource=https://api.fellix.ai/mcp. Its tools read what the resources below read, under the same scopes and the same rate limits. A product that needs a "Connect Fellix" button for the REST API still writes to [email protected].
A worked exchange, for the client-credentials grant:
curl -s https://api.fellix.ai/v1/oauth/token \
-H 'Content-Type: application/json' \
-d '{"grant_type":"client_credentials","client_id":"flxci_…","client_secret":"flxcs_…"}'
# → {"access_token":"…","token_type":"Bearer","expires_in":3600,"scope":"customers:read …"}
curl -s https://api.fellix.ai/v1/account \
-H 'Authorization: Bearer <access_token>'
Two things about that first response. Its body is the RFC's, not this API's error envelope — a failure is {"error":"invalid_client","error_description":"…"}, so an OAuth library reads it as-is. And expires_in is the whole story for this grant: there is no refresh token to keep, so when an hour is up, ask again with the same secret. An assistant's connection is the exception — its token response also carries a refresh_token, single use and rotated on every refresh, and presenting one twice ends the connection.
Signing in grants exactly the scopes shown on that screen, and only an owner can approve it — a delegated token opens what a key opens, so it costs the same permission. An assistant's connection is approved by the member it will act as, who can hand over only the permissions their own role can exercise. Signing out ends either for real: POST /v1/oauth/revoke (RFC 7009) with the access token or the refresh token, which the console does for you. A key you created yourself is not revocable that way — it is yours to withdraw in the dashboard, and a leaked one must not double as a switch for taking your integration offline.
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.
| Scope | Grants |
|---|
customers:read | GET /v1/customers and /v1/customers/{id} |
customers:write | POST /v1/customers and PUT /v1/customers/{id} |
conversations:read | GET /v1/conversations and /v1/conversations/{id}; also required to update conversation tags |
conversations:write | POST /v1/conversations, PUT /v1/conversations/{id} and /v1/conversations/{id}/lead_status |
messages:read | GET /v1/messages and /v1/messages/{id} |
messages:write | POST /v1/conversations/{id}/messages and /v1/conversations/{id}/messages/template |
tags:read | GET /v1/tags and /v1/tags/{id} |
tags:write | PUT /v1/conversations/{id}/tags (also requires conversations:read) |
appointments:read | GET /v1/appointments, /v1/appointments/{id}, /v1/appointments/availability and /v1/appointment_settings |
appointments:write | POST /v1/appointments, PUT /v1/appointments/{id} and GET /v1/appointments/{id}/manage_link |
webhooks:read | GET /v1/webhook_endpoints, /v1/webhook_endpoints/{id} and /v1/webhook_events |
webhooks:write | POST /v1/webhook_endpoints and DELETE /v1/webhook_endpoints/{id} |
imports:read | GET /v1/import_sources and /v1/import_sources/{id} |
imports:write | POST /v1/import_sources/{id}/scan |
settings:read | GET /v1/configuration (owners and managers; record read abilities apply) |
settings:write | PUT /v1/configuration/{area}/{id} (owners and managers may hold the scope; each save still requires its dashboard record ability) |
channels:read | GET /v1/channels and /v1/channels/{id} |
leads:read | GET /v1/leads and /v1/leads/{id} |
voice_calls:read | GET /v1/voice_calls and /v1/voice_calls/{id} |
analytics:read | GET /v1/analytics/pipeline, /v1/analytics/trends and /v1/analytics/tags |
team:read | GET /v1/users, /v1/teams, /v1/team_members and their /{id} |
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, keeping the same sort. The default is newest-created first (sort=-created_at), with id descending to break ties. A cursor we did not issue, or one issued under another sort, answers 400 with code: invalid_cursor.
Each endpoint documents its allowed sort values. Use one field ascending (sort=name) or prefix it with - for descending (sort=-updated_at). The id breaks ties in the same direction; null values come last in both directions. An unsupported sort answers 400 and names the allowed values. Sorting uses keyset pagination, with no offset or page number.
For the next upcoming booking, use sort=starts_at, filters[starts_at_gteq] set to the current ISO8601 timestamp and limit=1. Conversations support sort=-last_activity_at for the most recently active threads.
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.
A relation of a relation is a dotted path, up to 3 levels deep:
GET /v1/messages/42?include=conversation.customer.accounts
→ { "data": { …, "conversation": { …, "customer": { …, "accounts": [ … ] } } } }
A path carries its parents with it, so ?include=customer.accounts embeds customer as well and you do not have to name both. The reverse is not true: naming a parent embeds only the parent, and ?include=customer carries no accounts.
Two things a path cannot do, both 400:
- Go deeper than 3 levels. The trees we preload terminate, and a contract promising depth seven promises a response nobody meant to send.
- Come back to a resource it already passed through. From a message,
conversation.last_message is the message you are already holding — or a sibling of it — and serving it would cost a query per row instead of one per page.
The accepted list on each endpoint is exhaustive: if a path is not in it, it is a 400, not an empty key. Depth is not what costs you — every embedded relation is one extra query per page, however deeply it is nested, so one call with include=conversation.customer is always cheaper than a page of messages followed by a call per conversation.
Filtering
?filters[<field>_<predicate>]=<value>, combined with AND:
GET /v1/customers?filters[created_at_gteq]=2026-08-01T00:00:00Z&filters[name_cont]=acme
Which predicates a field takes depends on its type:
| Field type | Predicates | Notes |
|---|
| text | eq cont start | cont and start match a substring and a prefix, both case-insensitive. |
| number, id | eq in gt gteq lt lteq | Whole numbers. in takes a comma-separated list. |
| decimal | eq gt gteq lt lteq | Fractional values. No in. |
| boolean | eq | true, false, 1 or 0. |
| timestamp | gt gteq lt lteq | ISO8601 only, and no eq — see below. |
| enum | eq in | One of a fixed set, listed with the field. in takes a comma-separated list. |
Each endpoint's own filterable fields are listed with it, one line per accepted key — generated from the same allowlist the runtime checks, so the list cannot go stale.
Worth knowing before you build a query:
- Two predicates on one field make a range.
filters[created_at_gteq]=… with filters[created_at_lteq]=… is the window query; there is no between. - A timestamp has no
eq. Equality on a moment is almost never what anyone means, and the value must be ISO8601 — we would rather refuse last tuesday than silently pick a window you did not ask for. - Some fields filter themselves until you say otherwise.
filters[is_archived_eq] defaults to false wherever it exists, so archived rows are absent unless you ask for them; naming the field at all replaces the default rather than adding to it. in is a comma-separated list (filters[status_in]=open,closed), which means a value containing a comma cannot be expressed with it. Use several requests.- No
OR or negation in v1. Clauses always AND; filter narrowly and combine on your side when you need alternatives. - Filters read the resource's own columns, never an embedded relation's.
?include= and ?filters[] are independent: embedding a customer does not let you filter on their name.
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 default order is newest-created 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.
Zapier, Make and n8n want the same things of us, and all of them exist today. Authenticate with a static key — the auth type those platforms call "API Key" — and there is no app to install and nothing to wait for.
- The connection test is
GET /v1/account. It needs no scope, so any key can call it, and it answers with that key's scopes, its credential_type, when it expires and the rate_limits in force. Add ?include=organization and you have a name to label the connection with. - Check those scopes at connect time. A platform cannot ask for a scope the way a consent screen does, so a key holding only
customers:read connects happily and then fails on the first step that writes. Read the scopes array in that first response and refuse the connection when something you need is missing: the same mistake is far cheaper here than in a run at three in the morning. - An instant trigger subscribes itself.
POST /v1/webhook_endpoints with the catch URL the platform hands you, DELETE /v1/webhook_endpoints/{id} when the workflow is switched off — which is exactly the shape Zapier calls a REST hook and n8n a trigger node. Ask GET /v1/webhook_events for the event names first; they are the same vocabulary as the resources here, so conversation.created needs no translation table before you GET /v1/conversations/{id}. Needs webhooks:write (and webhooks:read to list). - A polling trigger is still the right answer for some things, and the window rule above is the one to use: newest first,
id stable, no page number to persist. Poll what has no event — a nightly reconciliation, or a resource whose changes you want in batches. Push what does: an event that must move within seconds should not be discovered up to fifteen minutes late. - Polling spends allowance; a subscription does not. Every credential in an organization draws on one bucket, so a flow checking each minute and a nightly export share it, and ten workflows polling every minute spend a tenth of a default allowance before the customer makes a call of their own.
X-RateLimit-Remaining is on every response; when one integration is the noisy one, ask for that credential to be held below the organization's ceiling rather than raising the ceiling for all of them.
A credential sees only the subscriptions it opened itself. It cannot list, and cannot delete, an endpoint created in the dashboard or one belonging to another credential — so a platform switching its own workflow off can never close the customer's ERP feed. The organization sees yours in the dashboard under Settings → Webhooks, named after the key that opened it and read-only there; withdrawing that key under Settings → API keys is how an operator stops it.
One thing a subscription opened this way cannot do: verify its own signatures. Deliveries are HMAC-signed like any other, but this API never returns the signing secret and the dashboard does not hand out one it did not issue to a person. If you need to verify X-Fellix-Signature, have the organization create the endpoint in the dashboard, where the secret is shown once, and point it at your catch URL. Otherwise treat the payload as a notification and read back through this API before acting on anything that matters.
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 on a different request (another record, another endpoint or another 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 and receives HMAC-signed JSON — see WebhookEnvelope below for the wrapper.
Subscribe from here or from the dashboard. POST /v1/webhook_endpoints opens a subscription and returns its id; DELETE /v1/webhook_endpoints/{id} closes it. That is what an automation platform needs: a workflow switched on opens one, switched off closes it, with nobody at a keyboard. A credential addresses only the subscriptions it opened itself — it cannot see, and cannot delete, an endpoint someone created in the dashboard or one that belongs to another credential.
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. Set it with payload_includes when you subscribe, or in the dashboard.
Signing. Every delivery carries X-Fellix-Signature — base64 HMAC-SHA256 over "POST" + url + body + X-Fellix-Timestamp — plus X-Fellix-Event, -Event-Id, -Delivery-Id, -Attempt and -Webhook-Id. The signing secret is shown once, when the endpoint is created in the dashboard; a subscription opened through this API has one too, but this surface never returns it, so verify by the URL you were given instead. A failed delivery is retried eight times over roughly four hours, and a 410 Gone disables the subscription immediately.
Some events describe resources this surface has no GET for yet — voice_call.*, whatsapp_call.*, lead.created, team.*, form_request.answered. Their shapes are PublicVoiceCall, PublicWhatsappCall, PublicWhatsappCallForward, PublicLead, PublicTeam, PublicTeamMember and PublicFormRequest, 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, plan_limit_reached. plan_limit_reached is a 422: the organization's plan has no room for more knowledge. Retrying will not help until the plan changes or knowledge is removed.
A record validation failure includes fields, an object mapping input field names to arrays of messages. Association errors use the corresponding foreign-key name, such as customer_id.
Text follows Accept-Language where we have a translation; en-US otherwise.