Authorization: Bearer <token> header. The token is one of two things, and the API picks the right path automatically based on length.
Internally,
connect() treats any 36-character token as an API key (UUIDs with hyphens are exactly 36 chars) and anything else as a JWT.
API keys
API keys grant a tool the ability to act as you against the API. They’re the right choice for anything other than the WG web app itself.Create one
- Sign in at wanderersguide.app.
- Open account settings and go to Developer → API Clients.
- + New client, give it a name (e.g.
foundry-importer) and an optional description. - Copy the 36-char
api_key.
Use one
What an API key actually grants
An API key authenticates a request as the user who created the key. The API generates a short-lived Supabase JWT for that user, then runs your request through the same Postgres row-level security policies as if you’d signed into the web app yourself. There is no “API mode” with elevated access. Anything you can do with a key, you could already do by signing in. What that means in practice:- Read all official, published content (this is open to everyone, even unauthenticated browsing).
- Read and write your own homebrew content sources and the content within them.
- Read and write your own campaigns and encounters.
- Read or modify your characters via
find-characterorupdate-characterwithout an explicit per-character grant. See Character access grants. - Act on another user’s data, even if you share a campaign with them. RLS enforces this regardless of how you authenticate.
Errors
JWTs
If you’re building a frontend that signs users in via Supabase Auth, send the access token. The Wanderer’s Guide web app does this; most third-party integrations don’t need to.Protected account and moderation writes
Profiles are created by the trusted Auth signup trigger. Account settings, API-client configuration, Patreon entitlements and GM-group changes go through authenticated Edge Functions with explicit owner and field selection. Anonymous and authenticated REST clients cannot insert, update or delete profiles, including extra self-owned profiles. Role, moderation and entitlement flags are server-managed. Content submissions are created throughcreate-content-update. The server sets the
submitter, pending status and empty votes; direct database mutation of the moderation
queue is denied. Only the verified moderation callback changes approvals and votes.
Character creation remains caller-scoped under RLS and cannot assign another account
as the owner.
Character access grants
Character data is sensitive, so even a valid API key gets403 from find-character, update-character, and the rest unless the character’s owner has explicitly granted that key access. The grant is per-character; one key with grants on three characters works for those three and 403s on every other.
The grant flow is OAuth-style: the API client (the tool/integration) hands the character owner a URL, the owner reviews and clicks Authorize, the grant is written.
Granting access (as the integration)
- Sign in to Wanderer’s Guide and open Account → Developer → API Clients.
-
Find the client you want to grant access to. Each client has a Character Authorization URL template:
-
Replace
<ID>with the character id you want access to and send that URL to the character owner. (If you’re writing a tool, this is the URL to redirect them to.)
Authorizing access (as the character owner)
- Open the URL the integration sent.
- WG shows a consent screen describing what access is being requested (read character, edit stats, manage inventory).
- Click Authorize.
- From here on, calls from that key against that character work. The API-key flow generates a short-lived JWT for the character owner (not the integration’s account) so RLS sees their context.
Revoking access
The character owner opens the character in the builder, scrolls to the Authorized Clients section in the home tab, finds the integration, and clicks Revoke Access. The next call from that key against that character returns403.
Revocation is per-character. Revoking on one character doesn’t affect others. Revoking the API client itself in Account → Developer → API Clients kills the key entirely, so character-level grants no longer matter.
Rate limits
Every request passes a bounded in-memory admission limiter before the JSON body is read. These budgets are per source IP and auth flow in each function isolate. Rotating unverified tokens does not provide a new budget; visitors sharing the public anonymous JWT have independent IP budgets. The limiter retains at most 2,048 active IP buckets, expires idle buckets, and refuses new buckets under pressure. This is a burst guard; cold starts still reset it.
IP attribution relies on the trusted ingress supplying
cf-connecting-ip or x-forwarded-for. Self-hosted ingress must replace client-supplied forwarding headers. This guard is not a distributed abuse ceiling.
JSON bodies are streamed with an 8 MiB maximum and a 10-second read deadline. AI uses 256 KiB, vector queries 64 KiB, and vector population 32 KiB. Oversized bodies return 413 before handler work; malformed JSON returns 400. Known invalid or expired JWT errors return 401 with data.code: AUTH_REQUIRED, so clients can obtain a fresh session while preserving unsynced edits.
Admission responses include:
HTTP 429 with JSend fail and a Retry-After header (in seconds). Failed requests count too, so repeatedly hitting an endpoint with a bad key won’t let you bypass the limit.
Shared AI and vector work budgets
AI and vector querying require a verified, non-anonymous Auth user and a WG profile. Vector population additionally requiresis_admin. The public anonymous JWT does not grant these privileges. Existing free-account AI features remain available.
A service-role-only Postgres function atomically reserves both the account and global daily budget, with UTC midnight resets. All isolates share these counters. Missing/unavailable quota storage returns 503 before contacting a paid service; exhausted quotas return 429 with Retry-After. Provider failures retain their reservation because work may already have been charged.
These are work ceilings rather than dollar-denominated billing guarantees. The external AI service controls output generation. Changes to these limits belong in a reviewed database migration. Apply
20260905000000_edge_work_budget.sql before deploying the dependent handlers; the function and table must remain inaccessible to anon and authenticated.
Other limits not enforced by the admission limiter:
- Supabase Edge Function quotas on the hosting tier (request count and execution time per month). The hosted instance at
wanderersguide.appruns on Supabase’s production limits; if you self-host, your limits are whatever your own Supabase project enforces. - Patreon-tier slot caps on resource creation, not request rate: free accounts get 6 character slots and zero campaign / encounter creates, tier 1+ unlocks campaigns and encounters, tier 2+ removes the character cap. These are checked inside the relevant
create-*endpoints.
find-spell with id: [1, 2, 3]), and avoid tight polling loops.
Endpoint with a different auth model
A handful of internal endpoints use a shared service secret instead. Most consumers never call them. They exist for the Discord moderation bot:/update-content-update:Authorization: Bearer $CONTENT_UPDATE_KEY. Not a user token; it’s a process-to-process secret. The route usesconnect()’sbypassAuthoption so the value isn’t mistaken for a JWT or API key.
Web app session and save recovery
The web app refreshes its session once after a proven JWT rejection (401 with
AUTH_REQUIRED or an invalid/expired JWT message, including older 400/PGRST301
responses). Concurrent rejected requests share that refresh. A request retries as
the same account; permission failures do not trigger refresh. Signing in or recovering
the session clears the persistent session-expired notice.
Ambiguous network and gateway failures retry only explicitly read-only requests, once,
at the request layer. Other mutations, AI requests and vector population are not
repeated automatically. The character editor has a separate guarded recovery flow:
a failed save retains its submitted snapshot, reads the authoritative character,
merges pending intent, and writes with the current server version. This handles a
committed write whose response was lost, including later edits returning a value to
its original state.
Unsynced character edits are buffered locally without bearer tokens, separately for
each account, character and browser page. Saving one tab does not rebase or replace
another tab’s newer draft. Reopening loads recoverable drafts into the editor’s normal
merge/calculation/save queue; restoration itself sends no write. Successful saves
remove only matching recovered snapshots, preserving any newer edits. Signing out
preserves these account-scoped copies.
The editor distinguishes Saved, Saving, pending edits, offline drafts and failed
sync. Completed failures retry with backoff (up to 30 seconds between attempts), on
reconnect, and when the page becomes visible or focused. Retry now uses the same
serialized queue. Account changes, read-only access, conflicts and incomplete
calculations still gate persistence. A browser storage failure displays Not saved:
keep this page open, rather than claiming the edit is durable. A failed initial
character request offers Retry loading on the same route; a network outage is not
treated as an authorization denial.
An older copy without a server version is kept for explicit recovery instead of
silently overwriting newer server data. When a retained copy needs attention, the
character page offers Download saved copy. Its recovery copy remains available
across reloads, even after subsequent edits save successfully. Signed-out viewers and
sessions identified as read-only do not create buffered writes.
Live character reads run every five seconds while the page is visible, and refresh on
focus or reconnect. Their account, route, starting server version and write revision
are checked before applying a response. Reads arriving during an unresolved write are
ignored; save recovery performs the authoritative reconciliation. A successful incoming
merge updates the baseline first, so only remaining local edits or changed calculated
stats can produce a guarded save. Same-field conflicts pause incoming reconciliation
and writes until the user resolves them. Public viewers can receive updates without
creating drafts or write attempts.
Inputs changed during a pending or failed calculation remain local with a requirement
to recalculate. Navigating between builder and sheet restores those inputs using their
last-synced base and the latest server row, preserving unrelated remote changes. The
app writes them only after a fresh successful calculation, with the current server
version. Missing or malformed bases stay available for download instead of being
restored automatically.
Want to look at the source?
The auth flow lives entirely insupabase/functions/_shared/helpers.ts. connect() and handleApiRouting() are short and worth a read if you’re wiring up automated tests or building a client SDK.
