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:
| Path | Human in the loop? | Issued key bound to | Best for |
|---|---|---|---|
| Handoff token | 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 | No | Fresh sandbox org · flw_test_… | Prototyping, demos, agent CI, MCP playground. |
| OAuth consent flow (PKCE) | 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. |
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.
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:
- 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 — or, if they prefer,
fall back to the OAuth consent flow below.
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:
- 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 productionback.p2p-flowie.comreturns401 INVALID_TOKEN. - Documents are not delivered over real Peppol — they route to an internal echo recipient. See Sandbox shortcuts.
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:
- 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
┌──────┐ ┌──────────────────┐
│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:
| 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. |
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:
code_verifier— a random 43-128 character string, base64url-safe. This is the agent's secret. Never sends it until step 4.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.