---
title: "Agent onboarding"
description: "How an AI agent autonomously signs up for a Flowie Exchange API key — sandbox bootstrap (zero friction) and OAuth-style consent flow with PKCE for production-grade access."
canonical: "https://docs.get-flowie.com/build-with-ai/agent-onboarding"
source: "https://docs.get-flowie.com/build-with-ai/agent-onboarding.html"
---

# Agent onboarding

AI Agents

# Agent onboarding — sign up & sign in autonomously

**If the URL the agent received contains`?handoff=hand_…`, jump to [Handoff token](<#handoff>) first** — that's the fastest path and the user pre-approved your scopes. Otherwise, three paths exist depending on context:

Path| Human in the loop?| Issued key bound to| Best for  
---|---|---|---  
[Handoff token](<#handoff>)| Pre-approved by the user| **The user's real org** (`flw_test_…` or `flw_live_…`)| The user pasted you a personalized URL; you run as their account.  
[Sandbox bootstrap](<#sandbox>)| No| Fresh sandbox org · `flw_test_…`| Prototyping, demos, agent CI, MCP playground.  
[OAuth consent flow (PKCE)](<#oauth>)| Yes — one-time consent| Sandbox org · `flw_test_…` (production rolling out)| Agents that need to act on a specific user's data with explicit scope grants.  
  
v1 status

The OAuth flow currently issues sandbox `flw_test_…` keys (7-day expiry). Production `flw_live_…` issuance is gated on a dashboard-side consent UI; we'll announce in the [changelog](<../changelog.html>) when it ships. Until then: use OAuth for the consent ceremony but expect a sandbox- scoped key on the other end. 

## Handoff token — pre-approved personalized link

The fastest, most useful path. The user generates a single-use URL on the [home page](<../index.html#agent-handoff>) (or via `POST /v1/oauth/handoff` from any client they're already authenticated to) and pastes the URL to you. The URL embeds a token bound to their organization with a pre-approved scope set.

Why this is the right default

The minted key is bound to the **user's real organization** — not a fresh sandbox. So when you call `POST /v1/companies`, `POST /v1/documents`, etc., they land in their actual account. No consent UI, no PKCE round-trips: the human did the consent up front when they generated the link. 

**Step 1 — Detect the token.** If your URL contains `?handoff=hand_…`, extract it.
[code] 
    from urllib.parse import urlparse, parse_qs
    
    url = "https://back.flowie.ink/exchange/docs-public/agent-onboarding.html?handoff=hand_AbC..."
    token = parse_qs(urlparse(url).query).get("handoff", [None])[0]
[/code]

**Step 2 — Redeem it.** Single POST. No other auth required; the token is the credential.
[code] 
    curl -X POST https://back.flowie.ink/exchange/v1/oauth/handoff/exchange \
      -H "Content-Type: application/json" \
      -d '{"handoff_token":"hand_AbC..."}'
[/code]

Response (same shape as the OAuth `/token` endpoint):
[code] 
    {
      "access_token":   "flw_test_…",
      "token_type":     "Bearer",
      "scopes":         ["send","receive","documents.read","companies.read","stats"],
      "expires_in":     604800,
      "company_id":     "comp_…",
      "organization_id":"org_…"
    }
[/code]

Use `access_token` as your `Authorization: Bearer …` for every subsequent call.

**Constraints & security model:**

  * **Single-use:** a second exchange returns `400 Handoff token has already been used.`
  * **Short TTL:** default 10 min, max 60 min — the user controls this when generating the link.
  * **Scope-bounded:** the user can only pre-approve scopes their own token already holds. You can't escalate.
  * **Org-bound:** the issued key inherits the user's organization, company, and tier — it can't be used to access any other tenant.
  * **Default scopes** (when generated from the home page): `send`, `receive`, `documents.read`, `companies.read`, `stats`. The user can override via the API to grant fewer or more.



**If redemption fails** with `400 Invalid or expired handoff token` the URL was either reused, expired, or never valid. Ask the user to generate a fresh link from [the home page](<../index.html#agent-handoff>) — or, if they prefer, fall back to the [OAuth consent flow](<#oauth>) below.

⚠ Always send a JSON body, even if empty

The Flowie LB (Google Cloud HTTPS LB) returns `411 Length Required` on POSTs without a body. Browser `fetch(url, {method:"POST"})` with no body, Python `requests.post(url)` without `json=`, and `curl -X POST` without `-d` all hit this. Always include `-d '{}'` (or the language equivalent) when calling `/v1/oauth/handoff/exchange` or `/v1/oauth/handoff/sandbox`. The 411 is rejected at the LB before reaching the FastAPI app, so you won't see it in our logs. 

## Sandbox bootstrap — zero-friction path

The agent calls a public, rate-limited endpoint and gets a fresh test key plus a starter sandbox company. No auth, no consent, no human:
[code] 
    curl -X POST https://back.flowie.ink/exchange/v1/sandbox/bootstrap \
      -H "Content-Type: application/json" \
      -d '{"label":"my-agent"}'
[/code]

Response:
[code] 
    {
      "organizationId": "org_sbx_…",
      "apiKey":         "flw_test_…",
      "keyPrefix":      "flw_test_abc1",
      "keyType":        "personal",
      "company": {
        "id":         "comp_sbx_…",
        "peppolId":   "0208:0000000001",
        "vatNumber":  "BE0000000001",
        "name":       "Sandbox Test BVBA",
        "country":    "BE"
      },
      "expiresAt": "2026-05-12T…"
    }
[/code]

Constraints:

  * **Rate limit:** 120 calls per IP per hour.
  * **Key TTL:** 7 days.
  * **Test mode only:** the key talks to the sandbox host `back.flowie.ink`; using it against production `back.p2p-flowie.com` returns `401 INVALID_TOKEN`.
  * **Documents are not delivered** over real Peppol — they route to an internal echo recipient. See [Sandbox shortcuts](<../sandbox/index.html#sandbox-shortcuts>).



For more advanced sandbox shapes — platform / white-label keys, simulated errors, time-travel — see the [Sandbox guide](<../sandbox/index.html>).

## OAuth consent flow — agent acts on behalf of a user

When an agent needs to operate on a real user's account, the user must explicitly approve the scope list before the agent gets a key. Flowie implements a deliberately minimal slice of OAuth 2.1 for this:

  * **Public clients only.** Agents can't reliably keep secrets, so there's no `client_secret`.
  * **PKCE mandatory.** The agent generates a one-time `code_verifier`, hashes it with SHA-256, and sends only the hash up. The server checks the verifier against the hash on the token exchange. Protects the auth code in transit.
  * **One-time auth codes.** 5-minute TTL, single-use.
  * **OOB by default.** Agents that can't host a redirect URI use `urn:ietf:wg:oauth:2.0:oob` — the consent page shows the auth code on screen for the user to copy back.



### The four-step dance
[code] 
    ┌──────┐                              ┌──────────────────┐
    │agent │                              │ Flowie Exchange  │
    └───┬──┘                              └─────────┬────────┘
        │                                           │
        │  1. POST /v1/oauth/authorize             │
        │     {client_name, scopes,                │
        │      code_challenge=SHA256(verifier)}    │
        ├──────────────────────────────────────────►│
        │   ◄──── 200 {consent_url}                │
        │                                           │
        │  2. Show consent_url to user             │
        │                                           │
        │           User clicks link, lands on consent
        │           page, reviews scopes, clicks Approve
        │                                           │
        │  3. ◄── auth_code shown on screen        │
        │     (or redirected to your URI)          │
        │                                           │
        │  4. POST /v1/oauth/token                 │
        │     {grant_type, code, code_verifier}    │
        ├──────────────────────────────────────────►│
        │   ◄──── 200 {access_token: flw_test_…}  │
        │                                           │
[/code]

## Scope catalogue

Fetch the live catalogue at [`GET /v1/oauth/scopes`](<https://back.flowie.ink/exchange/v1/oauth/scopes>) — public, no auth. The minimum bar:

Scope| What it grants  
---|---  
`send`| Issue invoices, credit notes, orders over Peppol.  
`receive`| Configure inbound delivery + webhooks + SMP registration.  
`documents.read`| List, search, download XML / PDF / structured views.  
`documents.search`| Filtered search across the corpus.  
`documents.write`| Mark read / archive / tag / add notes.  
`companies.read`| Read sender / partner companies + Peppol registrations.  
`companies.write`| Update companies, register on the SMP.  
`directory`| Search the Peppol directory, verify reachability.  
`partners`| Manage trading partners and routing settings.  
`payments`| Record payments, manage terms, ISO 20022 / SEPA export.  
`lifecycle`| Approve / reject / mark as paid — drives PPF/SDI compliance reporting.  
`compliance`| Read compliance dashboard + report records.  
`stats`| Per-period sent / received / delivered / failed counters.  
  
Ask for less, not more

Agents that ask for `send` alone get approved more often than agents that demand the full scope list up-front. If you need extra access later, trigger a new consent flow with the additional scopes — the user knows what they're agreeing to. 

## PKCE walkthrough

RFC 7636. The agent generates two values once per authorization:

  1. `code_verifier` — a random 43-128 character string, base64url-safe. _This is the agent's secret. Never sends it until step 4._
  2. `code_challenge` = `BASE64URL(SHA256(code_verifier))` with no padding.



The challenge goes up in the `POST /v1/oauth/authorize` request. The verifier goes up in the `POST /v1/oauth/token` request. Server compares — if they don't match, the exchange fails.

## Claude Desktop recipe

An MCP-connected Claude Desktop agent that sets itself up. Prompt the user with the consent URL, accept the OOB code back, swap for an API key, then add it to the MCP config:
[code] 
    User: "Set up a Flowie sandbox account for me."
    
    Agent (internal, hidden):
      1. POST /v1/sandbox/bootstrap → flw_test_… key + sandbox company
      2. Update ~/Library/.../claude_desktop_config.json:
         {
           "mcpServers": {
             "flowie-exchange": {
               "url": "https://back.flowie.ink/exchange/mcp",
               "transport": "streamable-http",
               "headers": {"Authorization": "Bearer flw_test_…"}
             }
           }
         }
      3. Tell user to restart Claude Desktop.
    
    Agent (visible):
      "Done. I provisioned a sandbox account at organization
       org_sbx_…. After you restart Claude, you'll have access to 34 Peppol
       tools (send_document, list_documents, …). Try: 'List my last 5 invoices.'"
[/code]

This is the all-autonomous path — perfect for demoing or developing. For real production access (touching a user's actual Peppol traffic), use the OAuth flow below.

## Python recipe
[code] 
    """Self-onboarding Flowie agent — OAuth-style consent flow with PKCE."""
    import base64, hashlib, secrets, webbrowser
    import httpx
    
    BASE = "https://back.flowie.ink/exchange"
    
    def pkce_pair():
        verifier = secrets.token_urlsafe(48).rstrip("=")[:64]
        digest = hashlib.sha256(verifier.encode()).digest()
        challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode()
        return verifier, challenge
    
    # 1. Register intent + grab the consent URL.
    verifier, challenge = pkce_pair()
    r = httpx.post(f"{BASE}/v1/oauth/authorize", json={
        "client_name": "My Local Python Agent",
        "scopes": ["send", "documents.read", "documents.search"],
        "code_challenge": challenge,
        "code_challenge_method": "S256",
    })
    r.raise_for_status()
    auth = r.json()
    print(f"Open in your browser:\n  {auth['consent_url']}\n")
    webbrowser.open(auth["consent_url"])
    
    # 2. Wait for the user to paste the OOB code back.
    auth_code = input("Paste the auth code from the browser: ").strip()
    
    # 3. Exchange code + verifier for an API key.
    r = httpx.post(f"{BASE}/v1/oauth/token", json={
        "grant_type": "authorization_code",
        "code": auth_code,
        "code_verifier": verifier,
    })
    r.raise_for_status()
    token = r.json()
    print(f"Got key prefix {token['access_token'][:16]}…")
    print(f"Scopes: {', '.join(token['scopes'])}")
    print(f"Expires in {token['expires_in'] // 3600}h")
    
    # 4. Use it.
    api = httpx.Client(
        base_url=f"{BASE}/v1",
        headers={"Authorization": f"Bearer {token['access_token']}"},
    )
    print(api.get("/documents", params={"limit": 5}).json())
[/code]

## curl recipe

For the absolute lowest-level diagnostic. Generate verifier + challenge in any language; here we use OpenSSL:
[code] 
    # 1. PKCE pair
    VERIFIER=$(openssl rand -base64 48 | tr -d '+/=' | head -c 64)
    CHALLENGE=$(printf %s "$VERIFIER" | openssl dgst -sha256 -binary | openssl base64 | tr -d '+/=' | tr 'a-z' 'a-z')
    
    # 2. Authorize
    RESP=$(curl -s -X POST https://back.flowie.ink/exchange/v1/oauth/authorize \
      -H "Content-Type: application/json" \
      -d "{\"client_name\":\"curl agent\",
           \"scopes\":[\"send\"],
           \"code_challenge\":\"$CHALLENGE\",
           \"code_challenge_method\":\"S256\"}")
    CONSENT_URL=$(echo "$RESP" | jq -r .consent_url)
    echo "Open: $CONSENT_URL"
    
    # 3. After clicking Approve, paste the code:
    read -p "Auth code: " CODE
    
    # 4. Exchange
    curl -s -X POST https://back.flowie.ink/exchange/v1/oauth/token \
      -H "Content-Type: application/json" \
      -d "{\"grant_type\":\"authorization_code\",
           \"code\":\"$CODE\",
           \"code_verifier\":\"$VERIFIER\"}" | jq .
[/code]

## Step-up — when an agent needs more scope mid-session

The flow above is whole-cycle: agent gets a fresh key with N scopes. If the agent later needs an additional scope (e.g. it has `documents.read` but discovers it needs `payments` to mark an invoice paid), the recommended pattern is to **start a fresh consent cycle** with the additional scope, present the user the new consent URL, and replace the existing key. There is no append-scope-to-existing-key endpoint by design — keeping every issued key tied to exactly one explicit consent record makes audit trails clean.

## FAQ

### Why not just use the sandbox bootstrap for everything?

Sandbox bootstrap is anonymous. It works for prototyping, but the issued key is bound to a fresh empty sandbox org — not to the user's real Flowie account. The OAuth flow ties the key to a real user's consent, which is what you need for any agent that will touch production data.

### Why PKCE? My agent runs on a server, I can keep a secret.

If your agent is server-side and confidential, you'll be migrated to the production OAuth flow when it ships (with `client_secret` support). For the v1 sandbox-issuing flow, every client is treated as public to keep the surface honest and the rollout simple.

### What happens if the user closes the consent page before clicking Approve?

The consent request expires after 10 minutes (no auth code is ever issued). The agent gets a clean 400 on token exchange. Ask the user to retry.

### Can I get a key that lasts more than 7 days?

Not via the OAuth flow yet. Production OAuth (coming separately) will mint `flw_live_…` keys with the same TTL semantics as keys created through the dashboard (90 days default, configurable per org). Until then, the OAuth-issued sandbox keys auto-rotate every 7 days.

### How do I revoke a key the agent issued itself?

The user revokes from their dashboard; or the agent calls [`DELETE /v1/api-keys/{id}`](<../reference/index.html#revoke-api-key>) with its own key. Revocation is immediate.

### Does the OAuth flow ever return an existing key, or always a new one?

Always a new one. Each consent flow mints a new key + new sandbox org, deliberately — preserves the one-key-per-consent-record audit invariant.
