---
title: "Authentication"
url: https://flexie.io/resources/rest-api/authentication
description: "The two ways to authenticate a REST API request, the per-user API key and OAuth 2.1, how to move from one to the other, and exactly what every refusal means."
---

# Authentication

Last updated 18 August 2026

![An apikey header beside an OAuth bearer token, with what each one is suited to](https://flexie.io/image/resources/rest-api-authentication.webp)

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:

```bash
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:

```bash
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 `Authorization` header with an API key.** A request carrying `Authorization: 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.

1. Open the user the integration should act as, in Settings → Users
2. Go to the **API Settings** tab
3. Click **Add API client**, name it after the integration, and save
4. Copy the token, it is shown **once**

Then send it as a bearer token:

```bash
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.

Name the scope. A request that names none is refused, and the `api` scope is never granted by omission:

```json
{ "error": "invalid_scope", "error_description": "A scope is required." }
```

**Client credentials are issued by an administrator, in Settings → OAuth Clients.** There is no self-registration endpoint: nothing writes a client row for a caller Flexie has not identified. An app distributed to many workspaces can instead publish a Client ID Metadata Document and use its URL as the `client_id`, in which case the row is created the first time somebody approves it on the consent screen.

### Discovery

The REST API publishes its own RFC 9728 metadata:

```bash
curl "https://your-subdomain.flexie.io/.well-known/oauth-protected-resource/api"
```

```json
{ "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:

```json
{ "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:

```json
{ "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", scope="api", error="invalid_token"
```

It names the scope as well as the document, so a client is told what to ask for rather than left to work it out.

> **A 404 on a path you know exists usually means no credential was sent at all.** Requests to `/api` arrive through a proxy that expects either an `apikey` or an `Authorization: Bearer` header, 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.

## One client, one resource

Flexie's OAuth server protects two things: the REST API (`api`) and the MCP server for AI agents (`mcp`). Every token is bound to one of them, 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 the scope is a property of the **client**, chosen once when you create it, and it is a single choice. A client is either a REST API client or an MCP client. If you need both, create two clients, which is also what lets you switch one off without touching the other.

A request naming both is refused outright, rather than issued as a token nothing accepts:

```json
{ "error": "invalid_scope",
  "error_description": "A token is issued for a single resource, so ask for one of mcp or api per request, not both." }
```

And a request naming a scope its client does not hold is refused the same way:

```json
{ "error": "invalid_scope", "error_description": "Client is not authorised for scope: api" }
```

### If your client sends `resource` instead of `scope`

MCP clients follow RFC 8707 and send a `resource` rather than a scope. That works here: a resource names exactly one of the two, so there is nothing to guess.

```
&resource=api      -> the same as &scope=api
```

A `resource` the server does not recognise is ignored. If that leaves the request with no scope at all, it is refused with `A scope is required.` rather than issued as something unusable, so `resource=https://example.com` on its own fails fast instead of producing a token nothing accepts.
