Authentication
Last updated 16 August 2026

Every API request acts as a user. Whatever that person can see and change, the request can see and change, and nothing more. Their name is what appears in the history of anything the request touches.
There are two ways to prove who you are.
The two credentials
| API key | OAuth | |
|---|---|---|
| Looks like | apikey: tCgjfl… |
Authorization: Bearer … |
| One per | user | user / integration |
| Expires | never | depends on the type |
| Revoke one integration | not possible, one key serves them all | yes, independently |
| Stored by Flexie as | the key itself | a fingerprint of the token |
| Best for | a quick script, a first integration | anything you intend to keep |
Both are accepted on every endpoint. You do not have to choose up front, a workspace can start with the key and move to OAuth later, per user, without a flag day.
Using the API key
Send it in the apikey header:
curl "https://your-subdomain.flexie.io/api/leads?limit=5" \
-H "apikey: YOUR_API_KEY"
Or as a query parameter, which is handy for a quick test in a browser:
curl "https://your-subdomain.flexie.io/api/leads?limit=5&apikey=YOUR_API_KEY"
The header name is apikey, lower case, one word. X-Api-Key, Api-Key and ApiKey are not recognised.
Where to find it: open the user in Settings → Users, then the API Settings tab. The key is shown there and can be regenerated. Regenerating takes effect once you click Update: and it breaks every integration using the old key at that moment, because a user has exactly one key.
Do not send an
Authorizationheader with an API key. A request carryingAuthorization: Bearer …is treated as an OAuth request, and your key is not consulted. Send one credential or the other, never both.
Using OAuth
OAuth issues a separate credential per integration, so you can switch one off without touching the others, and you can see which integration did what.
For a server-side integration
This is what most integrations want: no browser, no login screen, one long-lived token.
- Open the user the integration should act as, in Settings → Users
- Go to the API Settings tab
- Click Add API client, name it after the integration, and save
- Copy the token, it is shown once
Then send it as a bearer token:
curl "https://your-subdomain.flexie.io/api/leads?limit=5" \
-H "Authorization: Bearer YOUR_TOKEN"
Everything else about the request is identical. Same paths, same bodies, same responses.
For an app used by many people
An app that acts on behalf of whoever signs in uses the standard authorization-code flow against your workspace's OAuth server. It must request the api scope by name:
GET https://your-subdomain.flexie.io/oauth2/authorize
?response_type=code
&client_id=YOUR_CLIENT_ID
&redirect_uri=https://yourapp.example.com/callback
&scope=api
&state=RANDOM
&code_challenge=…&code_challenge_method=S256
PKCE with S256 is required. Exchange the code at POST /oauth2/token as usual.
A client that asks for no scope gets mcp only, access to Flexie's AI tools, not the REST API. The api scope is never granted by omission.
Discovery
The REST API publishes its own RFC 9728 metadata:
curl "https://your-subdomain.flexie.io/.well-known/oauth-protected-resource/api"
{ "resource": "https://your-subdomain.flexie.io/api",
"authorization_servers": ["https://your-subdomain.flexie.io"],
"scopes_supported": ["api"],
"bearer_methods_supported": ["header"] }
The document at the bare /.well-known/oauth-protected-resource path describes the MCP server instead. Follow the one the WWW-Authenticate header points you at.
Moving a user from the key to OAuth
The two credentials are deliberately exclusive per user, so there is never a moment where both work and you cannot tell which an integration is using.
As soon as a user has one active API client, their API key stops working. Their next key-authenticated request gets:
{ "error": "api_key_disabled",
"error_description": "This API key is disabled because an active OAuth client with the api scope exists for this user. Authenticate with OAuth instead, or deactivate that client to re-enable the key." }
Going back is one click. Switch the client off, or delete it, on that user's API Settings tab, and the key works again on the very next request. Nothing is lost, and no other user is affected: the switch is per person, and it is triggered by that person's own clients.
A sensible migration therefore looks like: create the client, move your integration over, confirm it works. If anything goes wrong, switch the client off and your old key is live again while you investigate.
Permission to use the API at all
Separately from which credential you hold, a user's role decides whether they may use the REST API. In Settings → Roles, under API Permissions, tick Access REST API.
A user whose role does not grant it is refused whichever credential they present:
{ "error": "access_denied",
"error_description": "This account does not have REST API access. Ask an administrator to enable \"Access REST API\" for its role." }
That refusal is a 403, not a 401, because presenting a different credential will not help. Administrators always pass.
Every refusal, and what to do
| Status | error |
What happened | What to do |
|---|---|---|---|
| 401 | invalid_grant |
The API key is missing or unknown | Check the apikey header spelling and the key itself |
| 401 | api_key_disabled |
The key is valid, but this user has an active API client | Use OAuth, or switch that client off |
| 401 | invalid_token |
The bearer token is unknown, expired, revoked, from an inactive client, or issued for a different resource | Get a fresh token; if it was issued for MCP it will never work here |
| 403 | access_denied |
The user's role does not grant Access REST API | Enable it on the role |
| 404 | none | The request never reached the API | See below |
The OAuth refusals carry a WWW-Authenticate header pointing at the discovery document, so a standards-aware client can restart the flow on its own:
WWW-Authenticate: Bearer resource_metadata="https://your-subdomain.flexie.io/.well-known/oauth-protected-resource/api", error="invalid_token"
A 404 on a path you know exists usually means no credential was sent at all. Requests to
/apiarrive through a proxy that expects either anapikeyor anAuthorization: Bearerheader, and turns anything else away before Flexie sees it. If a call returns 404 and an HTML page rather than JSON, check that your credential is actually being sent, some HTTP clients drop custom headers on redirect.
A token is bound to one resource
Flexie's OAuth server protects two things: the REST API (api) and the MCP server for AI agents (mcp). A client may hold both scopes. A token may not. Every token is bound to one of the two resources, and the other refuses it.
That is the part worth understanding, because it is what stops a token you hand to an AI agent being turned around and used against your records through the REST API.
So a client authorised for both asks for a token per resource, naming which one it wants with the resource parameter (RFC 8707):
POST /oauth2/token
...&scope=api mcp&resource=api -> a token the REST API accepts
...&scope=api mcp&resource=mcp -> a token the MCP server accepts
Ask for both scopes without naming a resource and you get a token neither will take. There is no sensible way to pick for you, so the server binds it to nothing rather than guessing, and both resource servers reject it. If a freshly issued token is refused everywhere, this is why: add resource.
A value the server does not recognise, or one naming a scope you were not granted, is ignored the same way, so resource=https://example.com also ends in a token bound to nothing.
A Service Token client is the exception, and it is enforced. Its token is minted the moment you save, with no request to carry a resource, so the form requires exactly one scope. Tick both and it refuses to save. If you want machine-to-machine access to both, create one Service Token client per resource.