---
title: "Authentication for agents"
description: "How an autonomous agent obtains and uses credentials for the Flowie Exchange API: sandbox bootstrap with no signup, delegated handoff tokens, OAuth 2.0 with PKCE and client credentials, scopes, errors and revocation."
canonical: "https://docs.get-flowie.com/auth.md"
---

# Authentication for agents — Flowie Exchange API

How an autonomous agent obtains and uses credentials for the Flowie Exchange
API. Written to the [WorkOS `auth.md` draft](https://workos.com/auth-md).

Base URLs

| Environment | API base | Authorization server |
|---|---|---|
| Sandbox | `https://back.flowie.ink/exchange` | `https://login.flowieapp.io` |
| Production | `https://back.p2p-flowie.com/exchange` | `https://login.flowie.me` |

## Discover

Protected-resource metadata (RFC 9728) is published at:

- Production — <https://docs.get-flowie.com/.well-known/oauth-protected-resource>
- Sandbox — <https://docs.get-flowie.com/.well-known/oauth-protected-resource-sandbox>

Each document names the `resource`, its `authorization_servers`, and the
supported `scopes_supported` and `bearer_methods_supported`. The authorization
servers publish RFC 8414 metadata at
`/.well-known/oauth-authorization-server`, which is where you resolve the
`token_endpoint` and `authorization_endpoint` rather than hard-coding them.

The machine-readable API contract is
<https://docs.get-flowie.com/openapi.json> (OpenAPI 3.1, 95 paths).

### agent_auth

Every endpoint an agent needs, in one block. The authorization server does not
publish an `agent_auth` block of its own, so this is the authoritative list.
Each URI below was probed and the observed status is given — nothing here is
aspirational.

```json
{
  "agent_auth": {
    "sandbox_credential_endpoint": "https://back.flowie.ink/exchange/v1/sandbox/bootstrap",
    "handoff_exchange_endpoint": "https://back.flowie.ink/exchange/v1/oauth/handoff/exchange",
    "handoff_mint_url": "https://docs.get-flowie.com/share-access",
    "authorization_endpoint": "https://flowie.eu.auth0.com/authorize",
    "token_endpoint": "https://flowie.eu.auth0.com/oauth/token",
    "jwks_uri": "https://flowie.eu.auth0.com/.well-known/jwks.json",
    "protected_resource_metadata": "https://docs.get-flowie.com/.well-known/oauth-protected-resource",
    "registration_endpoint": null,
    "code_challenge_methods_supported": ["S256"],
    "scopes_supported": ["openid", "profile", "email", "offline_access"],
    "grant_types_supported": ["authorization_code", "refresh_token"]
  }
}
```

| Endpoint | Method | Observed | Meaning |
|---|---|---|---|
| `sandbox_credential_endpoint` | `POST` | `200` | Mints a sandbox key. No auth, no body required. **Start here.** |
| `handoff_exchange_endpoint` | `POST` | `400` on empty body | Redeems a user's handoff token. `400` is correct: it rejects a missing token. |
| `authorization_endpoint` | `GET` | redirect | PKCE authorization. `S256` only. |
| `token_endpoint` | `POST` | — | Code exchange and refresh. |
| `registration_endpoint` | — | **disabled** | Deliberately `null`. |

**On `registration_endpoint`:** the authorization server's RFC 8414 metadata
advertises `https://flowie.eu.auth0.com/oidc/register`, but posting to it
returns `400 dynamic client registration is disabled`. Do not build against it.
Production OAuth clients are registered by a human in the Flowie app. An agent
that needs credentials without a human should use the sandbox endpoint above,
and an agent acting for a real user should be handed a handoff token.

## Pick a method

Four credential types, in the order an agent should prefer them.

**1. Sandbox key — no signup, no human.** The fastest path, and the one
designed for agents evaluating the API. One unauthenticated call mints a
scoped, rate-limited key against sandbox data.

**1b. OAuth 2.0 client credentials — a server acting as itself.** The
machine-to-machine grant, for a backend integration that runs unattended and
acts for an organisation rather than for a person. This is the production
answer to "we have no user to sign in". See
[Machine-to-machine](#machine-to-machine-oauth-20-client-credentials) below.

**2. OAuth 2.0 authorization code + PKCE — acting for a human.** Use when the
agent acts on a real user's account. The flow lives under `/v1/oauth/` and is
served by the authorization server in the table above. This is the
`identity_assertion` shape: the end user consents, and the resulting token
carries their identity.

**3. Handoff token — a human hands an agent a session.** When a signed-in user
wants to delegate to an agent, the app issues a short-lived handoff token that
the agent redeems for its own credential.

There is no `anonymous` production credential: anything touching real invoices
is bound to an identity.

## Register

Sandbox — no registration, no client secret:

```http
POST https://back.flowie.ink/exchange/v1/sandbox/bootstrap
```

No `Authorization` header. Rate-limited. The response carries an API key of the
form `flw_test_…` plus a seeded company you can immediately send and receive
documents as.

Production OAuth clients are registered by a human in the Flowie app; there is
no open dynamic-client-registration (`register_uri`) endpoint. Do not advertise
one — an agent that probes for it will not find it.

## Claim

Handoff redemption, for the delegated case:

```http
POST https://back.flowie.ink/exchange/v1/oauth/handoff/exchange
Content-Type: application/json

{"handoff_token": "<token the user gave you>"}
```

For the PKCE flow, exchange the authorization code at the `token_endpoint`
resolved from the authorization server's metadata.

## Use the credential

Every authenticated request carries a bearer token:

```http
GET https://back.flowie.ink/exchange/v1/documents
Authorization: Bearer flw_test_…
```

The same header accepts either a Flowie JWT or an Exchange API key
(`flw_live_…`, `flw_test_…`, and the `flw_plat_…` / `flw_wl_…` platform and
white-label variants). Multi-organization callers add
`X-Flowie-Organization-Id`.

## Machine-to-machine (OAuth 2.0 client credentials)

For a backend that runs unattended: an ERP or DCS gateway, a nightly sync, a
webhook consumer. No browser, no consent screen, no user session to keep alive.

### How a client is provisioned

There is no self-service and no dynamic registration. Flowie creates, per
integration:

1. **A dedicated M2M application** — one per integration, not one per customer.
   A customer running two integrations gets two applications, so either can be
   revoked without taking the other down.
2. **A technical account** behind it (for example
   `acme-dcs-gateway@flowie.io`), made a **member of your organisation with a
   role**. *That membership is what grants access* — the organisations a machine
   token may act for are the ones its technical account belongs to. The
   membership is **not** carried in the token: a `client_credentials` token
   leaves `_permissions` at `{}` and names no organisation at all. You name the
   one you are acting for in the `X-Flowie-Organization-Id` header on every
   call, and the API checks it against that membership.

Staging and production are separate applications with separate credentials.
Ask for the production pair when you are ready; a staging client will not work
against production.

### Get a token

```http
POST https://login.flowieapp.io/oauth/token
Content-Type: application/json

{
  "grant_type": "client_credentials",
  "client_id": "<your client id>",
  "client_secret": "<your client secret>",
  "audience": "https://auth.flowie.me"
}
```

| Environment | Token endpoint |
|---|---|
| Sandbox / pilot | `https://login.flowieapp.io/oauth/token` |
| Production | `https://login.flowie.me/oauth/token` |

Those are the dedicated authentication domains Flowie moved to in June 2026.
The former tenant domains — `flowie-staging.eu.auth0.com` and
`flowie.eu.auth0.com` — still issue tokens the API accepts, so an integration
built against them keeps working and there is no forced migration. New ones
should use the dedicated domains: they are what the network prerequisites and
the SSO guide list, so they are the ones your IT will have allow-listed.

Better still, do not hard-code either. Both publish OIDC discovery, so the
token endpoint can be read at
`https://login.flowie.me/.well-known/openid-configuration` and followed from
there.

The response is the token itself:

```json
{
  "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9…",
  "token_type": "Bearer",
  "expires_in": 86400
}
```

Read `access_token`; that string is the JWT you put in the `Authorization`
header. There is no second call, no separate exchange, and nothing to decode —
the API verifies it against the authorization server's public keys.

The `audience` is not optional and is not the API base URL. Get it wrong and
**the token endpoint refuses outright** — there is no token to misuse:

```json
HTTP/1.1 403 Forbidden
{"error": "access_denied", "error_description": "..."}
```

`access_denied` reads like a credentials problem and is usually not one. It
means the client is not authorised for the audience you asked for, which happens
two ways: the audience is misspelled, or the client has not been granted access
to the Flowie API on the authorization server. Both produce the identical
response, and neither is fixed by re-checking the client secret — which is where
this error normally sends people first.

### Two grants, two different 403s

A machine client needs authorising twice, in two different places, and the
errors look alike:

| Missing | Where it fails | What you see |
|---|---|---|
| Client not authorised for the **audience** | The token endpoint, before any Flowie call | `403 access_denied` from the authorization server |
| Technical account not a **member of the organisation** you named | The first Flowie API call | `403 {"detail": "Token does not grant access to organization '…'. Available: none."}` |

The first is a setting on the OAuth client; the second is the organisation
membership described above. Getting a token proves only the first. Report which
of the two you are seeing — the messages are close enough that quoting the exact
one saves a round trip.

**Cache the token and renew it on `expires_in`** (currently 24 h) rather than
minting one per call. A token request per API call adds a round trip to the
authorization server on every request and will hit Auth0's own limits long
before it hits Flowie's.

### Use it

```http
GET https://back.flowie.ink/exchange/v1/documents
Authorization: Bearer <access_token>
X-Flowie-Organization-Id: 019b47ba-…
```

**The organisation header is not optional for a machine token.** For a user's
JWT it overrides a default; for a `client_credentials` token it is the only
place the tenant can come from, because the token carries none. Send it on
every call.

`X-Flowie-Organization-Id` is the header this API reads (header names are
case-insensitive, so `x-flowie-organization-id` is the same header);
`Organization-Id` is accepted as an alias. **`x-organization-id` is not read
here** — other Flowie services take that spelling, but a request that sends it
to the Exchange API is served as though no organisation had been named at all.
For a machine token that means a `403` on every call; for a multi-organisation
user JWT it means silently acting as the wrong one.

The organisation you name is checked against what your technical account
actually holds there — a real permission grant, not merely a name that exists.
Naming one it is not a member of gives:

```
403 Token does not grant access to organization '…'. Available: [...]
```

For a machine token `Available` reads `none`, because a `client_credentials`
token enumerates no organisations; that is expected and says nothing about the
membership. For a user JWT the list is the authoritative answer to what the
caller may act for. Either way the fix is the membership, not the request.

### `403 No organization found in token`

```json
{"detail": "No organization found in token. Machine-to-machine tokens carry no organization claim: send the target organization in the X-Flowie-Organization-Id header."}
```

A valid, correctly signed token with the right audience fails every call this
way when no organisation was named — which, for a machine token, means the
header was missing or misspelled. Add it. If you are already sending it and
still get this, check the spelling against the header names above; if you get
`Token does not grant access…` instead, the header arrived and the membership
is what is missing — that one is not fixed by retrying or re-minting, so report
it.

### Try it in Postman

The [Postman collection](https://docs.get-flowie.com/postman_collection.json)
carries the whole grant, so you can prove the credentials before writing any
code. Import it, then set five collection variables — `tokenUrl`, `clientId`,
`clientSecret`, `audience` and `organizationId` — and send **auth → Get an
access token (OAuth 2.0 client credentials)**. It stores the `access_token` in
the collection's `token` variable, which every other request sends as
`Authorization: Bearer {{token}}`, and the collection adds
`X-Flowie-Organization-Id: {{organizationId}}` to each call for you. If the
token request succeeds and the next call returns `403`, you are looking at the
second of the two grants above, not at a bad secret.

### The client secret

Auth0 displays a client secret **once**. Flowie sends it through a one-time
link that self-destructs when first opened, so whoever opens it must put it
straight into your own password manager for the rest of the team. A lost secret
cannot be re-displayed — it can only be regenerated, which invalidates the old
one. Rotation works the same way: ask, then swap.

### Rate limits

A machine token is budgeted **per OAuth client**, not per IP address, so two
integrations belonging to one customer do not throttle each other and one
misbehaving job cannot spend its sibling's allowance. The default is 600
requests/minute. Every response carries the live figures:

```
RateLimit-Limit: 600
RateLimit-Remaining: 597
RateLimit-Reset: 59
RateLimit-Policy: "default";q=600;w=60
```

Pace on `RateLimit-Remaining` rather than a hard-coded number: the budget is
configurable per organisation, so yours may not be 600. A `429` carries
`Retry-After` in seconds. Higher sustained throughput is arranged by asking for
the budget to be raised — a configuration change, not a code one.

### Which grant, again

| You have | Use |
|---|---|
| A server acting for an organisation, no user | **Client credentials** — this section |
| A user who can open a browser | Authorization code + PKCE |
| A signed-in user handing work to an agent | Handoff token |
| Nothing, and you are evaluating | Sandbox key |

## Errors

| Status | Meaning | What the agent should do |
|---|---|---|
| `401` | Missing, malformed, or expired credential | Re-run Claim. On a `WWW-Authenticate: Bearer resource_metadata="…"` header, fetch that URL and start again from Discover |
| `403` | Valid credential, insufficient scope or wrong organization | Do not retry blindly — the credential is wrong, not stale |
| `404` | Resource absent, or not visible to this credential | Treat as absent |
| `429` | Rate limited | Honour `Retry-After`; sandbox bootstrap is deliberately tight |

Failed requests are inspectable by `X-Request-Id` through the request
inspector, which is often faster than re-deriving a failure from the response
alone.

## Revocation

Sandbox keys expire on their own and can be left to lapse. Production keys are
revoked from the Flowie app, and revocation is immediate — a revoked key fails
closed on the next call rather than draining a cache. OAuth refresh tokens are
revoked through the authorization server's standard revocation endpoint, which
is listed in its RFC 8414 metadata.
