Flowie
AI Agents

Agent onboarding — sign up & sign in autonomously

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

PathHuman in the loop?Issued key bound toBest for
Handoff tokenPre-approved by the userThe user's real org (flw_test_… or flw_live_…)The user pasted you a personalized URL; you run as their account.
Sandbox bootstrapNoFresh sandbox org · flw_test_…Prototyping, demos, agent CI, MCP playground.
OAuth consent flow (PKCE)Yes — one-time consentSandbox 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 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 (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.

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]

Step 2 — Redeem it. Single POST. No other auth required; the token is the credential.

curl -X POST https://back.flowie.ink/exchange/v1/oauth/handoff/exchange \
  -H "Content-Type: application/json" \
  -d '{"handoff_token":"hand_AbC..."}'

Response (same shape as the OAuth /token endpoint):

{
  "access_token":   "flw_test_…",
  "token_type":     "Bearer",
  "scopes":         ["send","receive","documents.read","companies.read","stats"],
  "expires_in":     604800,
  "company_id":     "comp_…",
  "organization_id":"org_…"
}

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

Constraints & security model:

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 — or, if they prefer, fall back to the OAuth consent flow 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:

curl -X POST https://back.flowie.ink/exchange/v1/sandbox/bootstrap \
  -H "Content-Type: application/json" \
  -d '{"label":"my-agent"}'

Response:

{
  "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…"
}

Constraints:

For more advanced sandbox shapes — platform / white-label keys, simulated errors, time-travel — see the Sandbox guide.

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:

The four-step dance

┌──────┐                              ┌──────────────────┐
│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_…}  │
    │                                           │

Scope catalogue

Fetch the live catalogue at GET /v1/oauth/scopes — public, no auth. The minimum bar:

ScopeWhat it grants
sendIssue invoices, credit notes, orders over Peppol.
receiveConfigure inbound delivery + webhooks + SMP registration.
documents.readList, search, download XML / PDF / structured views.
documents.searchFiltered search across the corpus.
documents.writeMark read / archive / tag / add notes.
companies.readRead sender / partner companies + Peppol registrations.
companies.writeUpdate companies, register on the SMP.
directorySearch the Peppol directory, verify reachability.
partnersManage trading partners and routing settings.
paymentsRecord payments, manage terms, ISO 20022 / SEPA export.
lifecycleApprove / reject / mark as paid — drives PPF/SDI compliance reporting.
complianceRead compliance dashboard + report records.
statsPer-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:

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.'"

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

"""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())

curl recipe

For the absolute lowest-level diagnostic. Generate verifier + challenge in any language; here we use OpenSSL:

# 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 .

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} 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.