Building on YieldFabric — single-read entry for LLMs
Wire-level reference for LLMs (and humans) building apps on YieldFabric. Auth flows, the five most-common operations, error handling, common pitfalls. Pragmatic companion to the alignment-economy thesis.
You are an LLM tasked with building an app on top of YieldFabric. This file is the wire-level reference — auth flows, endpoints, mutation shapes, error handling, common pitfalls. Pragmatic and HTTP-shaped. Read it once; then build.
Read the alignment-economy guide first if you haven't. It's the lens for why the platform is shaped the way it is — the five operational roles (reason → communicate → negotiate → structure → execute), what role AI plays in each, and why the Intent → Confirm boundary is the most important architectural element in any application you build on YF. This file tells you how to call the API; that guide tells you what you're calling it for.
What YieldFabric is
YieldFabric is infrastructure for the alignment economy — a substrate where humans align on new agreements through AI-augmented conversation, with the moment of agreement performed by the humans (via the Intent → Confirm boundary) and AI doing translation, knowledge access, and proposal composition on the edges.
Concretely: confidential on-chain payments and obligations, multi-agent orchestration, working-group collaboration. AI proposes, humans confirm, the system executes. The financial primitives (payments, swaps, repos) and the agentic primitives (workspaces, threads, knowledge graphs) are not two separate stacks — they are the same alignment process at different operational stages.
A YieldFabric app talks to four public hosts:
auth.yieldfabric.compay.yieldfabric.com/api/users/{id}/messages/{id}api.yieldfabric.com/graphqlinstant, deposits, withdrawals, obligations, swaps, and composed payment operations.agents.yieldfabric.comThe rule of thumb: api.yieldfabric.com/graphql for every
GraphQL operation, auth REST for sign-in, payments REST for
settlement polling, agents REST/SSE for conversation state.
That's the whole map.
One alignment process at five operational stages. The financial primitives and the agentic primitives are not parallel stacks — they are the same alignment process at different stages (see the alignment-economy guide). Reason (knowledge graphs, agentic pipelines) and communicate (working groups, threads, agents-as-edge-translators) and negotiate (intents, drafts, Studio) live primarily in the agents service; structure (contracts, swaps, repos) and execute (payments, settlement) live primarily in the payments service. The Intent → Confirm boundary is the structural locus where wanting collapses into a definite joint prompt — the most important UI element in any application you build.
Before wiring the financial-side endpoints below, read
/docs/guides/agents-and-workspaces
for the reason/communicate/negotiate side. That's where most
applications spend most of their time.
The URLs your app calls — and the ones it shouldn't
A consumer app uses these public surfaces, and nothing else:
POST https://auth.yieldfabric.com/auth/** (REST)/auth/login, /auth/signature/signin, /auth/<provider>/exchange), token refresh, GET /protected/jwt to resolve the caller, and invitations (/auth/invitations/** — create / list / accept; GET /auth/invitations/{token} is public for the magic-link landing).POST https://api.yieldfabric.com/graphql (GraphQL gateway)GET https://pay.yieldfabric.com/api/users/{user_id}/messages/{message_id} (payments REST)executed is non-null.https://agents.yieldfabric.com/** (agents REST/SSE)Do not route payments work through raw service-to-service endpoints. Use the public GraphQL gateway mutation, then poll the message-status endpoint.
pay.yieldfabric.com/api/mq/submit (raw queue submission)403 Forbidden — Insufficient permissions: CryptoOperations otherwise). Use the public GraphQL gateway mutations instead.pay.yieldfabric.com/api/chain/** (chain reads)agents.yieldfabric.com/api/mcp/** (MCP transport)Authenticate in 30 seconds
Four sign-in paths. Browser clients use email/password, wallet signature, or provider exchange (#1, #2, #4). Backend services (app backends, scheduled workers, integration scripts) use API keys (#3).
1. Email + password (easiest)
LOGIN=$(curl -s -X POST https://auth.yieldfabric.com/auth/login \
-H 'Content-Type: application/json' \
-d '{"email":"user@example.com","password":"…"}')
TOKEN=$(echo "$LOGIN" | jq -r .token)
REFRESH=$(echo "$LOGIN" | jq -r .refresh_token)
USER_ID=$(echo "$LOGIN" | jq -r .user.id)
Access tokens last 15 min. Refresh tokens last 30 days, single-use, rotated on every refresh.
2. Wallet signature (for wallet-led users)
# 1. Nonce
NONCE=$(curl -s https://auth.yieldfabric.com/auth/signature/nonce | jq -r .nonce)
# 2. Wallet signs $NONCE off-chain (EIP-191 personal_sign)
# 3. Exchange
curl -X POST https://auth.yieldfabric.com/auth/signature/signin \
-H 'Content-Type: application/json' \
-d "{\"public_key\":\"0x04…\",\"signature\":\"0x…\",\"message\":\"$NONCE\",\"nonce\":\"$NONCE\"}"
3. API key (for backend services — what your server-side code should use)
For non-browser callers — your app's backend, scheduled workers, batch jobs, anything that doesn't log a human in at boot — don't store an email + password in env vars. Instead:
- Authenticate once, manually, as the service-account user with any of the user-facing flows (email/password is fine here — it's one-time).
- Mint a long-lived API key for that user:
The rawcurl -X POST https://auth.yieldfabric.com/auth/api-key/generate \ -H "Authorization: Bearer $ONE_TIME_TOKEN" \ -H 'Content-Type: application/json' \ -d '{"service_name":"my-app-backend","description":"Application backend"}' # → { "api_key": "yf_api_…", "service_name": "…", … }yf_api_…value is returned only at creation time; the server stores its hash only. Save it; store it asAPI_KEYin your service's env. - At runtime, exchange the API key for a short-lived JWT on every
boot and on every refresh:
curl -X POST https://auth.yieldfabric.com/auth/api-key \ -H 'Content-Type: application/json' \ -d '{"api_key":"yf_api_…"}' # → same AuthResponse shape as /auth/login
Why this matters: API keys can be revoked individually
(POST /auth/api-keys/{key_id}/revoke) and audited
(GET /auth/api-keys) without rotating the underlying user's
password. They're also the canonical answer to "how does my server-side
code authenticate" — this is the recommended pattern for any
non-browser service that needs to call YF.
Full reference: the API-keys section of the auth reference.
4. Provider exchange (Averer / MetaMask / Email / WebAuthn)
# Discover available providers in this deployment
curl https://auth.yieldfabric.com/auth/providers/config
# Per-provider exchange — see docs at /docs/api/auth (on the running website)
curl -X POST https://auth.yieldfabric.com/auth/metamask/exchange \
-H 'Content-Type: application/json' \
-d '{"address":"0x…","signature":"0x…","message":"<contains nonce>","chain_id":1}'
All four token exchange paths return the same LoginResponse shape:
{
"token": "<access JWT, 15min>",
"refresh_token": "<refresh, 30d, single-use>",
"token_type": "Bearer",
"expires_in": 900,
"user": { "id": "…", "email": "…", "role": "…" }
}
Make your first authenticated request
Use the JWT as Authorization: Bearer <token> on any public
surface. Same JWT works for auth REST, the GraphQL gateway, payments
status polling, and agents REST.
# Auth: get current user
curl https://auth.yieldfabric.com/auth/users/me -H "Authorization: Bearer $TOKEN"
# Gateway GraphQL: list payments visible to your entity
curl -X POST https://api.yieldfabric.com/graphql \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"query":"query { paymentsByEntity { id status amount assetId } }"}'
# Agents: list working groups
curl https://agents.yieldfabric.com/working-groups -H "Authorization: Bearer $TOKEN"
The seven things you'll do most
1. Refresh the token
Access tokens expire in 15 min. Rotate before they do:
NEW=$(curl -s -X POST https://auth.yieldfabric.com/auth/refresh \
-H 'Content-Type: application/json' \
-d "{\"refresh_token\":\"$REFRESH\"}")
TOKEN=$(echo "$NEW" | jq -r .token)
REFRESH=$(echo "$NEW" | jq -r .refresh_token)
Single-use refresh. If you get a 401 here, the user must re-sign-in.
2. Send a payment
curl -X POST https://api.yieldfabric.com/graphql \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"query": "mutation($input: InstantSendInput!) { instant(input: $input) { success messageId paymentId } }",
"variables": { "input": {
"destinationWalletId": "<recipient wallet UUID>",
"assetId": "aud-token-asset",
"amount": "10",
"idempotencyKey": "<fresh UUIDv4>"
}}
}'
destinationWalletId is the canonical recipient identifier when you
have a wallet UUID. If you only have a name or email, use destinationId
as an entity-name lookup. Do not pass a raw 0x… address to
destinationId; resolve the wallet first and pass destinationWalletId.
The full input shape (incl. destinationWalletId,
contractId, requireManualSignature, …) lives in
the GraphQL schema (browseable in /docs/api/payments)
under input InstantSendInput.
Idempotency key rule — omit the field and let the resolver
generate one, or pass a fresh UUIDv4 for each submission. Same key +
same body = same response (no double-charge); same key + different
body = 400 IDEMPOTENCY_CONFLICT. Never use a deterministic key.
3. Mint and accept an obligation (zero-payment agreement flow)
An Obligation is a counterparty agreement that can carry payments or be a pure agreement (T&C, attestation, role acceptance). Mint from the admin / issuer side, accept from the counterparty side. Both go through public GraphQL gateway.
# 1. Mint — admin's Bearer. The resolver derives the minter's account
# from the JWT, picks the obligation contract address from the
# chain registry, generates the on-chain token id, AND auto-generates
# a fresh idempotency_key. You only supply the counterpart and the
# off-chain `data` blob.
CREATE_RESP=$(curl -s -X POST https://api.yieldfabric.com/graphql \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"query": "mutation($input: CreateObligationInput!) { createObligation(input: $input) { success contractId obligationResult messageId message } }",
"variables": { "input": {
"counterpartWalletId": "<UUID from /protected/jwt default_wallet_id>",
"data": { "kind": "tnc", "version": "v1.0", "title": "...", "body_sha256": "..." }
}}
}')
# → { "createObligation": { "success": true, "contractId": "CONTRACT-OBLIGATION-...",
# "obligationResult": null, "messageId": "<uuid>", ... } }
# 2. Wait for the on-chain mint to settle. The mutation returns as
# soon as the message is enqueued; the contract isn't readable by
# acceptObligation until the executor finishes. Poll every 2s
# until `executed` is set (matches the YF Python SDK's
# poll_message_completion).
MSG_ID=$(echo "$CREATE_RESP" | jq -r .data.createObligation.messageId)
ADMIN_USER_ID=$(curl -s https://auth.yieldfabric.com/protected/jwt -H "Authorization: Bearer $ADMIN_TOKEN" | jq -r .user_id)
until curl -s -H "Authorization: Bearer $ADMIN_TOKEN" \
"https://pay.yieldfabric.com/api/users/$ADMIN_USER_ID/messages/$MSG_ID" \
| jq -e '.executed' > /dev/null; do sleep 2; done
# 3. Accept — counterpart's Bearer (the user). Keys off `contractId`
# from the create response.
CONTRACT_ID=$(echo "$CREATE_RESP" | jq -r .data.createObligation.contractId)
ACCEPT_RESP=$(curl -s -X POST https://api.yieldfabric.com/graphql \
-H "Authorization: Bearer $USER_TOKEN" \
-H 'Content-Type: application/json' \
-d "{
\"query\": \"mutation(\$input: AcceptObligationInput!) { acceptObligation(input: \$input) { success message obligationId messageId } }\",
\"variables\": { \"input\": { \"contractId\": \"$CONTRACT_ID\" } }
}")
# 4. Wait for the accept message to settle the same way, this time
# polling under the user's identity since the message belongs to
# their entity.
ACCEPT_MSG_ID=$(echo "$ACCEPT_RESP" | jq -r .data.acceptObligation.messageId)
USER_USER_ID=$(curl -s https://auth.yieldfabric.com/protected/jwt -H "Authorization: Bearer $USER_TOKEN" | jq -r .user_id)
until curl -s -H "Authorization: Bearer $USER_TOKEN" \
"https://pay.yieldfabric.com/api/users/$USER_USER_ID/messages/$ACCEPT_MSG_ID" \
| jq -e '.executed' > /dev/null; do sleep 2; done
Things that trip people up — all of these are pitfalls real apps have hit during their first integration:
counterpartis an entity name, not an address. Passing0x…fails with"No entity found with name '0x…'". UsecounterpartWalletIdwhenever you have the wallet UUID (every authenticated caller does —/protected/jwtreturnsdefault_wallet_id). Same caveat applies toobligor/obligorWalletId.- Don't pass a deterministic
idempotencyKeyfrom app code (e.g.sha256(user || version)). The resolver dedupes on it; a retry after a stuck message just returns the stuckmessageIdagain without re-enqueueing. Either omit it (the resolver auto-generates a fresh one from timestamp+user) or use a UUIDv4 per submission. contractId≠obligationResult≠ on-chain id.contractIdis the YF-side identifier; that's whatacceptObligationkeys off. The on-chain BigUint obligation id comes back asobligationResulton create (oftennullpre-settle) and asobligationIdon accept. Save both — the on-chain id is your durable receipt, the contractId is the YF handle.- Zero-payment obligations omit
denomination,notional,obligor/obligorWalletId, andinitialPayments. The resolver treats this as a pure agreement. - The caller minting the obligation needs
CryptoOperationspermission, since the executor's vault retrieves the minter's default key pair from/keys/users/{id}/default-key, which gates on that permission. For users created withrole: SuperAdmin/Admin/Manager, the JWT mint falls back to role-default permissions whenuser_permissionsis empty; for narrower roles (Viewer,ApiClient, …) you need to grantCryptoOperationsexplicitly viaPOST /auth/users/{id}/permissions.
4. List a wallet's activity (history feed)
Unified, paginated feed merging the wallet's payments and on-chain
messages, newest first. Use this instead of stitching
payments.byWallet and /api/users/.../messages client-side.
WALLET_ID="..." # /protected/jwt returns default_wallet_id
curl -X POST https://api.yieldfabric.com/graphql \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"query": "query Activity($walletId: String!, $limit: Int, $cursor: String) {
walletFlow {
activity(walletId: $walletId, limit: $limit, cursor: $cursor) {
nextCursor
items {
__typename
... on PaymentActivity { timestamp payment { id status amount assetId } }
... on MessageActivity { timestamp message { id messageType status executed } }
}
}
}
}",
"variables": { "walletId": "'"$WALLET_ID"'", "limit": 25 }
}'
Pagination contract:
cursoris opaque. Don't parse it. PassnextCursorfrom the previous page back in as the nextcursor.nextCursor: nullmeans the feed is exhausted — stop.limitdefaults to 25, clamped to 200.- The same
(walletId, limit, cursor)triple is guaranteed to return the same page regardless of which replica answers (stateless, no sticky sessions).
The feed is sorted by timestamp DESC across both streams. Within
a microsecond tie, items break by id lexicographically — stable
but arbitrary across __typename. Don't depend on relative order
of items that share a timestamp.
5. Poll for message status
Long-running on-chain operations (deposit / send / swap / create
obligation) submit a message, return a messageId, then
execute asynchronously. Poll:
curl "https://pay.yieldfabric.com/api/users/$USER_ID/messages/$MESSAGE_ID" \
-H "Authorization: Bearer $TOKEN"
Status field progression: Pending → Validating → Executing →
Completed (or Failed / ManualSignature). See
MessageStatus enum.
6. Subscribe to live events (SSE)
For real-time UI, open a Server-Sent Events stream rather than polling:
# Working-group chat stream (new messages, agent responses, typing indicators)
curl -N "https://agents.yieldfabric.com/working-groups/$GROUP_ID/chat/stream" \
-H "Authorization: Bearer $TOKEN"
# Working-group workflow stream (step start / complete / fail)
curl -N "https://agents.yieldfabric.com/working-groups/$GROUP_ID/workflows/$WORKFLOW_ID/stream" \
-H "Authorization: Bearer $TOKEN"
# Pipeline events (KG mutations, intent confirmation prompts)
curl -N "https://agents.yieldfabric.com/pipelines/$PIPELINE_RUN_ID/events" \
-H "Authorization: Bearer $TOKEN"
Threads + workflows are scoped to a working group; the SSE routes
follow that nesting. The full event surface is enumerated in
docs/getting-started/webhooks-and-events.md.
Server sends event: <type>\ndata: <json>\n\n frames. Reconnect on
drop; use polling on reconnect to catch up missed state.
7. Invite someone to join (and optionally execute an agreement)
A magic-link invitation onboards a new user and routes them into a typed
action on accept. target_kind is one of none | obligation | deal_flow | group_join | connection; the optional target_payload carries the terms
(e.g. an obligation's amount / denomination / expiry).
# 1. Inviter creates the invitation. The email is sent automatically; the
# response also returns the magic link to share out-of-band.
INV=$(curl -s -X POST https://auth.yieldfabric.com/auth/invitations \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"invitee_email": "them@example.com",
"message": "Join me on YieldFabric",
"target_kind": "connection"
}')
INVITE_URL=$(echo "$INV" | jq -r .invite_url) # {app}/invite/{token}
TOKEN_VALUE=$(echo "$INV" | jq -r .invitation.token)
# 2. The recipient previews the invitation BEFORE signing up — public, no auth.
curl -s "https://auth.yieldfabric.com/auth/invitations/$TOKEN_VALUE"
# 3. The recipient opens INVITE_URL, onboards (becomes a user), and obtains a
# JWT ($INVITEE_TOKEN). Their account email must match the invited address.
# 4. Accept — returns the typed next action to route the new user into.
curl -s -X POST "https://auth.yieldfabric.com/auth/invitations/$TOKEN_VALUE/accept" \
-H "Authorization: Bearer $INVITEE_TOKEN"
# => { "invitation": {…}, "next_action": { "kind": "connection", "payload": {…} } }
Things to know:
GET /auth/invitations/{token}is public — it returns a redacted view (inviter name, message, a curated target preview) and marks the invitationopened. Never send a bearer token to it.- Accept requires the caller's email to match
invitee_email(the link is bound to that inbox) — otherwise403. Onboard the invitee with the invited address; the app prefills + locks it in the onboarding wizard. - Follow
next_actionafter accept:obligation→ the accept-obligation surface,deal_flow→ the deal,group_join→ the group,none/connection→ home / contacts.connectionalso forms the inviter↔invitee connection server-side. - Lifecycle is
pending → opened → accepted(terminalrevoked/expired). Resend is rate-limited;DELETE /auth/invitations/{token}is an inviter-only soft revoke.
8. Handle errors
{ "error": "<string>" }. Route on HTTP status.errors[].extensions.code in envelope. Switch on code, not message.Status codes (consistent across services):
200/201/204— success400— validation failed, idempotency conflict, business-rule violation401— missing / expired / wrong-audience JWT (re-refresh)403— JWT valid but role/scope lacks permission404— resource doesn't exist (or visibility-scoped to a group you're not in)429— rate-limited (login failures only; the response is delayed but still returns 401)5xx— server-side; retry with same idempotencyKey
GraphQL error codes you'll see most:
INSUFFICIENT_BALANCE, IDEMPOTENCY_CONFLICT,
CONTRACT_STATE_INVALID, SWAP_STATE_INVALID,
DEAL_STATE_INVALID, COUNTERPART_NOT_FOUND, FORBIDDEN.
Service URLs at a glance
https://auth.yieldfabric.com/auth/**https://api.yieldfabric.com/graphqlinstant, obligations, swaps, deposits, withdrawals) and cross-subgraph reads.https://agents.yieldfabric.com/**https://pay.yieldfabric.com/api/users/{user_id}/messages*Where to find detailed reference
/docs/api/auth/docs/api/payments/docs/api/agentsThe OpenAPI specs (3 of them) are the source of truth. Browse
them in the API explorer at /docs/api/<service>, or download the
bundled JSON for each service to generate SDKs from.
Type-safe clients
Generate TypeScript types from the served specs:
# Once
npm i -g openapi-typescript
# Download the bundled specs
curl -o /tmp/yf-auth.json https://yieldfabric.com/api/openapi/auth.json
curl -o /tmp/yf-payments.json https://yieldfabric.com/api/openapi/payments.json
curl -o /tmp/yf-agents.json https://yieldfabric.com/api/openapi/agents.json
# Generate types
npx openapi-typescript /tmp/yf-auth.json -o src/types/yf-auth.ts
npx openapi-typescript /tmp/yf-payments.json -o src/types/yf-payments.ts
npx openapi-typescript /tmp/yf-agents.json -o src/types/yf-agents.ts
Then use openapi-fetch for typed fetch calls:
import createClient from 'openapi-fetch'
import type { paths } from './types/yf-auth'
const auth = createClient<paths>({
baseUrl: 'https://auth.yieldfabric.com',
})
const { data, error } = await auth.POST('/auth/login', {
body: { email, password },
})
Other languages: see sdks.md.
Common pitfalls (and how to avoid them)
errors[].extensions.code inside the body.message textcode for routing; message may change between releases. Same for error strings in REST — those are human-readable.idempotencyKey with different bodiesIDEMPOTENCY_CONFLICT.idempotencyKey (e.g. sha256(user_id, action)) from app codemessageId without re-enqueueing — the executor never gets a fresh message to run. Either omit idempotencyKey (the resolver auto-generates one per call from timestamp+user) or use a fresh UUIDv4.acceptObligation immediately after createObligation returns success"Contract not found: CONTRACT-OBLIGATION-...". Poll GET /api/users/{user_id}/messages/{message_id} every 2s until executed is set first (or run a workflow that waits for it).acceptObligation after a self-counterpart payment-less createObligation (caller is also the counterparty AND no initialPayments)safeMint inlines the acceptance for this shape — both ObligationCreated and ObligationAccepted fire in the same receipt, and the contract row lands at COMPLETED in one step. A follow-up acceptObligation call is now an idempotent no-op success — the resolver detects the COMPLETED status and returns a synthesized success response without enqueueing a duplicate submission. Callers can keep their existing two-call pattern; the resolver handles the idempotency.403 Insufficient permissions: CryptoOperationsuser_permissions (this happens to anyone created via POST /auth/users on YF versions before the auto-grant landed). The JWT-mint path now falls back to role defaults when the table is empty, so users with SuperAdmin/Admin/Manager work out-of-the-box. For narrower roles, grant explicitly via POST /auth/users/{id}/permissions.0x… address as counterpart / obligor on createObligation (or as destinationId on instant)entitiesByName), not address matches. The resolver returns "No entity found with name '0x…'". Use the matching *WalletId field (counterpartWalletId, obligorWalletId, destinationWalletId) — pass the wallet UUID, e.g. from /protected/jwt's default_wallet_id.api.yieldfabric.com/graphql for every public GraphQL operation. Service hosts are REST/SSE surfaces./api/events/stream) or WebSocket (/ws/messages) to wait for message completion from a backend service/api/users/{user_id}/messages/{message_id} — simpler, no missed-event race, no streaming state to manage. Match the Python SDK's poll_message_completion: 2s interval, 300s ceiling, terminal when executed is set.pay.yieldfabric.com/api/mq/submit to mint obligations, send payments, etc.403 Insufficient permissions: CryptoOperations otherwise). Use the public GraphQL gateway mutations (createObligation, acceptObligation, instant, …) — they call into the same pipeline with proper input normalisation and entity resolution.Completed status, balance state may take a few seconds to converge in the consumer pipeline. Re-query payments after a 2-3s delay.acting_as), not a header. Mint a delegation JWT via POST /auth/delegation/jwt.payments-service audience) are for service-to-service calls only. Client apps use user tokens."Help, I want to build X"
/docs/api/auth/guides/getting-started.mdagents-and-workspaces.md — threads + streaming sectionsdms.md and the obligation operations in /docs/api/payments@yieldfabric/wallet SDK (drop-in React components)createObligation with counterpart set to the same user and no initialPayments. The on-chain safeMint auto-accepts this shape in a single call — ObligationCreated and ObligationAccepted fire in the same receipt and the contract lands at COMPLETED immediately. No follow-up acceptObligation needed.In one sentence
Get a JWT from auth.yieldfabric.com/auth/login; use api.yieldfabric.com/graphql
for all public GraphQL reads and mutations, pay.yieldfabric.com/api/users/.../messages to poll
settlement, and agents.yieldfabric.com for agents REST/SSE. Omit
idempotencyKey or use a fresh UUIDv4 per logical mutation. Switch on
errors[].extensions.code (GraphQL) or HTTP status (REST). Treat
refresh tokens as single-use.
That's the platform.