LLM reference (llms.txt)
# Unbase
> Unbase is a zero-config SQL (SQLite) database over HTTP. `POST /v1/projects` returns a project and a bearer token in one call — no signup, no dashboard, no separate API-key step. Every subsequent call is plain JSON over HTTPS. Each project also ships a Supabase-style per-project Auth service for your app's end users, file Storage, a Leaderboards service for HTML games, and an online layer for games (players, cloud saves, world claims, friends, mail).
Base URL: `https://api.unbase.dev`
Ids: project ids are `unbase_xxxxxxxxxxxx`, account ids are `acct_...`, Auth end-user ids are `user_...`.
## Auth (API credentials)
Each project has **two keys**, both returned by `POST /v1/projects`:
- **Secret key** (a.k.a. "service role"), format `${projectId}.${signature}` (HMAC-SHA256). Full SQL read/write plus admin. Sent as `Authorization: Bearer <secretKey>`. It is the permanent data-plane credential — there is no key rotation/management API; it stays valid until the project is deleted. A key only ever authorizes the one `projectId` embedded in it — using it against a different `:id` in the URL returns `401`. **Never expose it in a browser.**
- **Anon key** (a.k.a. "publishable"), format `${projectId}.pk.${signature}`. Safe to embed in a client app. Sent in an `apikey` header, it unlocks the project's per-project **Auth service** (`/v1/projects/:id/auth/*`, see below) and read-only **Storage** access (`/v1/projects/:id/storage/*`, see below), and the game-facing **Leaderboards** routes (`/v1/projects/:id/leaderboards/*`, see below) — it cannot read or write your SQL tables. The secret key is also accepted in the `apikey` header.
Other credentials:
- No separate signup/login for the project itself. Anonymous projects can later be attached to an email via the two-step magic-link claim flow (`POST /v1/claim` then `POST /v1/claim/verify`), which upgrades the plan but does not change the keys.
- Paid (`founder`/`pro`) plan upgrades are applied automatically from Stripe via `POST /v1/stripe/webhook` — not through the bearer-token API.
- The per-project Auth service issues **end-user access tokens**: HS256 JWTs signed with the project's own JWT secret (see the Auth section).
## Endpoints
### `POST /v1/projects`
Create a new project. No auth required.
Request body: `{ "turnstileToken": string }`. **Production enforces Cloudflare Turnstile on this endpoint**: without a valid token it returns `403` ("Turnstile verification failed"), so a plain curl or server-to-server call can't create a project here. Two ways to get one:
- **In a browser:** open https://unbase.dev (no signup). The page solves Turnstile and shows the keys.
- **From an account, with no Turnstile:** `POST /v1/auth/login` `{ "email" }` emails a magic link; `POST /v1/auth/verify` `{ "token" }` returns a session `token`; then `POST /v1/account/projects` `{ "name"? }` with `Authorization: Bearer <session token>` returns `201 { projectId, url, token, anonKey, plan }`.
A browser that already created a project (it holds the `unbase_project` cookie) gets the same project back, with `"reused": true` and no Turnstile check.
Response `201`:
```json
{ "projectId": "unbase_...", "url": "https://api.unbase.dev/v1/projects/unbase_...", "token": "unbase_....<sig>", "anonKey": "unbase_....pk.<sig>" }
```
The `token` (secret key) is shown exactly once — it is not retrievable again. Store it immediately. The `anonKey` is the publishable key for the Auth service.
New projects start on the **anonymous** plan (7-day retention unless claimed).
---
### `POST /v1/claim`
**Step 1 of 2 — initiate a claim.** Mints a signed, 30-minute magic-link token and emails a claim link (`{SITE_URL}/claim/verify?token=...`) to the given address via Resend. No auth required (the caller must know the `projectId`). The project must already exist (`404` otherwise). This does *not* claim the project — it only sends the link. Requiring the email owner to open the link proves control of the address before the claim is committed; the old single-call flow let anyone claim any known `projectId` to their own email.
Request body: `{ "id": "unbase_...", "email": "user@example.com" }`
Response `200`:
- With a Resend key configured: `{ "sent": true }` (email sent; `devLink` omitted).
- In keyless dev mode (no `RESEND_API_KEY`): `{ "sent": false, "devLink": "https://unbase.dev/claim/verify?token=..." }` — no email is sent and the full claim URL is returned inline so the flow stays usable. Do not run keyless in production.
---
### `POST /v1/claim/verify`
**Step 2 of 2 — complete the claim.** Verifies the magic-link token's HMAC signature and 30-minute expiry, then attaches the anonymous project to the email-based account (idempotent: calling with the same email reuses the account), upgrading its plan from `anonymous` to `free`. No auth required (the token itself is the proof). The existing keys keep working unchanged and the 7-day expiry is removed.
Request body: `{ "token": "<token from the magic link>" }`
Response `200`: `{ "projectId": "unbase_...", "accountId": "acct_...", "plan": "free" }`
Returns `400` if the token is malformed, tampered, or expired.
---
### `POST /v1/stripe/webhook`
Applies paid-plan upgrades/downgrades from Stripe. **Authenticated by the Stripe signature, not a bearer token** — requires the `Stripe-Signature` header, which the Worker verifies via HMAC-SHA256 against `STRIPE_WEBHOOK_SECRET` (5-minute timestamp tolerance). Body is the raw Stripe event JSON.
- `checkout.session.completed`: reads the customer email (`customer_email` or `customer_details.email`) and target plan from the session's `metadata.plan` (`founder` or `pro`, set on the Stripe Payment Link), then upgrades that account and **all** its projects to that plan.
- `customer.subscription.deleted`: downgrades the account back to `free`, but only if `metadata.email` is present on the event (Stripe omits it by default, so the integration must add it; otherwise the event is acknowledged with no downgrade).
Response `200`: `{ "received": true, "applied": boolean, "plan"?: string, "projects"?: number }` (`applied` is `false` for events that carry no actionable upgrade intent). Returns `400` on a bad/missing signature, `503` if `STRIPE_WEBHOOK_SECRET` is not configured.
---
### `POST /v1/projects/:id/query`
Run one SQL statement. Secret key required.
Request body: `{ "sql": "SELECT * FROM t WHERE id = ?", "params"?: [1] }`
- `params` are positional `?` placeholders, standard SQLite binding. Always use them instead of interpolating values into `sql`.
- Any statement not starting with `SELECT`, `PRAGMA`, `EXPLAIN`, or `WITH` is treated as a write for quota/billing purposes.
- Statements referencing tables prefixed `_unbase_` (internal bookkeeping) are rejected with `403`.
Response `200`:
```json
{ "rows": [ { "col": "value" } ], "rowsRead": 1, "rowsWritten": 0 }
```
Response headers on every call:
- `X-Unbase-Rows-Read`, `X-Unbase-Rows-Written` — mirror the body.
- `X-Unbase-Limit-Warning: true` — present only when the account is at or over 100% of its plan's monthly read/write/storage quota. Writes are hard-blocked at 120% of quota (`403`); reads are never blocked by quota (only by a suspended account, also `403`).
---
### `POST /v1/projects/:id/batch`
Run multiple statements as one atomic transaction (all-or-nothing). Secret key required.
Request body: `{ "statements": [ { "sql": "...", "params"?: [...] }, ... ] }`
Response `200`:
```json
{
"results": [ { "rows": [...], "rowsRead": 0, "rowsWritten": 1 }, ... ],
"rowsRead": 0,
"rowsWritten": 1
}
```
`results` is positional, one entry per input statement, in order. Top-level `rowsRead`/`rowsWritten` are the sums across all statements.
---
### `GET /v1/projects/:id/usage`
Current plan, status, and this-calendar-month's row counters. Secret key required.
Response `200`:
```json
{
"plan": "free",
"status": "active",
"sizeBytes": 12345,
"usageMonth": "2026-07",
"rowsRead": 420,
"rowsWritten": 17
}
```
`status` is one of `active`, `limited`, `suspended`. `rowsRead`/`rowsWritten` reset at the start of each calendar month.
---
### `GET /v1/projects/:id/tables`
List the project's user tables with row counts and column info. Secret key required. Internal (`_unbase_*`), SQLite (`sqlite_*`), and Cloudflare (`_cf_*`) tables are excluded.
Response `200`: `{ "tables": [ { "name": "todos", "rowCount": 1, "columns": [ { "name": "id", "type": "INTEGER", "notnull": false, "pk": true }, ... ] } ] }`
---
### `GET /v1/projects/:id/export`
Download a full logical SQL dump of the project. Secret key required.
Response `200`, `content-type: application/sql`, `content-disposition: attachment; filename="<id>.sql"`. Body is plain-text `CREATE TABLE` + `INSERT INTO` statements wrapped in a transaction — **not** a binary `.sqlite` file. Fully replayable: `sqlite3 restored.db < dump.sql` reconstructs an equivalent database.
---
### `DELETE /v1/projects/:id`
Permanently delete the project and its stored exports. Secret key required.
Response `204`, empty body. Irreversible.
## Storage service (per-project file storage)
Every project has a private file store under `/v1/projects/:id/storage/objects`, backed by Cloudflare R2. Objects are private — there is no public/anonymous URL; every read is authenticated. Base path below is `STORAGE = https://api.unbase.dev/v1/projects/:id/storage/objects`.
- `PUT {STORAGE}/:key` — upload/overwrite. Body is the raw file bytes (not multipart). `Content-Type` header is stored and returned on download (defaults to `application/octet-stream`). `:key` may contain `/` for folders, e.g. `avatars/user.png`. Secret key required. `201` → `{ key, size, contentType, createdAt, updatedAt }`. `413` if over the 100 MB per-object cap; `403` if it would exceed the plan's file-storage quota (the upload is rolled back, nothing is left stored).
- `GET {STORAGE}/:key` — download. Body is the raw bytes, `content-type` set to what was stored. Secret or anon key. `404` if missing.
- `GET {STORAGE}` — list. `200` → `{ objects: [{ key, size, contentType, createdAt, updatedAt }, ...], totalBytes, count }`. Secret or anon key.
- `DELETE {STORAGE}/:key` — `204`. Secret key required. `404` if missing.
File storage is a separate quota from the SQL database's own storage limit (see Plans & limits). Deleting a project deletes all of its storage objects too.
## Leaderboards service (high scores for HTML games, no backend)
Every project has a leaderboard service under `/v1/projects/:id/leaderboards`. A static browser game calls the **game routes** with the **anon key** (`apikey` header). The owner configures and moderates with the **secret key** (`Authorization: Bearer`), which also skips the origin allowlist. Base path below is `LB = https://api.unbase.dev/v1/projects/:id/leaderboards`. Full guide: `/docs/leaderboards.md`.
Drop-in SDK: `<script src="https://api.unbase.dev/v1/leaderboard.js" data-key="<anon key>"></script>` defines `window.UnbaseLeaderboard` with `start()`, `submit(run, { name, values })`, `top(board, { limit, period })`, `around(board, { span, period })`, `widget(el, { boards })`, `getName()`/`setName()`, `ready()` (resolves the device id) and `storage()` (`"local"`, `"cookie"` or `"memory"`). Every call resolves (never rejects); failures resolve `{ ok: false, error }`.
The device id is kept in localStorage. When that is blocked (cross-site game iframes such as itch.io under Chrome incognito, third-party cookie blocking or Brave), the SDK falls back to `POST {LB}/device`, which uses the anon key and a credentialed request:
- With no body, it returns `{ device, name }` (`name` is the player's last-used display name or `null`) from a partitioned cookie `ulb_<projectId>` (`Secure; SameSite=None; HttpOnly; Partitioned`, 400 days, path-scoped to the project's leaderboards), minting a new 32-hex id if there's none.
- With `{ device }`, it adopts that id, which is how a save-code claim sticks.
- This is the only route that reflects the request origin with `Access-Control-Allow-Credentials: true`. The `null` origin is excluded.
Default config (until you PUT your own): one int field `score` (0..1e9), boards `all` (all-time) and `daily`, both `score desc`, best run per device, any origin.
Config (`PUT {LB}/config`, secret key; `400 invalid_config` explains a bad one):
- `fields`: up to 8 `{ name: /^[a-z][a-z0-9_]*$/, type: "int"|"float", min, max, public?: bool (default true), duration?: bool }`. All are required on submit; extra keys are refused. At most one `duration` field (seconds), which must not exceed the server-measured run time + 5 s.
- `boards`: up to 10 `{ slug, sort: [[field, "asc"|"desc"], ...up to 3], period: "all"|"daily"|"weekly"|"monthly"|"season", season?: "s1", keep: "best"|"all", scoped?: bool }`. A scoped board is one board per scope named at submit time (scope: 1-64 of `[A-Za-z0-9_.,:-]`); read it as `boards/<slug>:<scope>/...` or with `?scope=`; missing/unexpected scope is `400 bad_scope`. Periods are UTC. Period keys are `all`, `2026-10-02`, `2026-W40` (ISO week), `2026-10`, and `<season>`. Changing a board's sort re-ranks it; removing a board deletes its scores.
- `rules`: up to 20 of `min_per {field, per, ratio, offset}` (field >= (per-offset)*ratio), `max_per {field, per, ratio, offset}` (field <= (per+offset)*ratio), `max_linear {field, terms:[{field,k}], c}` (field <= Σk·term + c), `max_rate {field, per_second}` (field / real_seconds <= per_second), `cap_delta {field, max_increase_pct}` (always a flag). Optional `message`. `flag: true` means accept the score but queue it in flags instead of rejecting it.
- `allowedOrigins`: `[]` = any. Entries are exact `https://mygame.com`, wildcard `https://*.gated.page`, or `itch`. Enforced on game routes only (returns `403 origin_not_allowed`). It blocks other websites, not scripts.
Game routes (anon key):
- `POST {LB}/runs/start` `{ device: 8-64 chars [A-Za-z0-9_-] }` → `201 { runId, token, startedAt }`. Limits are 30 starts/min per device and 300/hour per IP; beyond that `429 rate_limited` with `Retry-After`.
- `POST {LB}/runs/submit` `{ token, name, values, scope? }` (scope files the run on scoped boards too; unscoped submits skip them) → `200 { accepted: true, runId, rank: {board: n}, newBest: {board: bool}, top: {board: rows} }`. Errors: `409 run_used` (tokens are single-use), `410 run_expired` (after 6 h), `404 run_not_found`, `400 bad_name` (1-20 letters/digits/space/`_.-'`), `400 invalid_values`, `422 implausible` (duration or rule failure; message is player-safe).
- `GET {LB}/boards/:board/top?limit=10&period=current|<key>&device=` → `{ board, period, periodKey, rows: [{ rank, name, values (public fields only), at, me? }] }`. Ties share a rank (1,1,3). The limit maxes out at 100.
- `GET {LB}/boards/:board/around?device=&span=5&period=` → `{ rank|null, rows }`: the device's row plus up to `span` (max 10) neighbours each side.
Owner routes (secret key):
- `GET {LB}` returns config, `isDefault`, boards with current `periodKey` and entry count, `runs24h` by status, `openFlags` and `bans`.
- `GET {LB}/config` returns `{ config, isDefault }`.
- `GET {LB}/boards/:board/entries?period=&limit=50&offset=` lists all rows (any visibility) with private fields, `device`, `runId` and `visibility` (`public`|`shadow`|`hidden`).
- `GET {LB}/runs?status=accepted|rejected|blocked|expired|deleted&limit=` lists recent runs with `reason`. Runs are kept 14 days.
- `DELETE {LB}/runs/:runId` → `{ deleted: n }` removes that run from every board.
- `GET {LB}/bans`; `PUT {LB}/bans/:device` `{ kind: "shadow"|"block", reason? }`; `DELETE {LB}/bans/:device`. A shadow-banned device still sees its own rows (in top with `device`, around and submit); others don't. A block hides existing rows, and new submits return a fake `accepted` and write nothing.
- `GET {LB}/flags?resolved=true`; `POST {LB}/flags/:id/resolve`.
### Progression (lifetime stats, unlocks, progress board, save codes)
Optional `progression` section in the same config. The server grants progress only from **accepted** runs; rejected and blocked runs never change it, and there's no client route to grant progress.
- **Stats:** `stats` holds up to 16 entries of `{ name, agg: "count"|"sum"|"max"|"min"|"last", field? (required unless count), public?: bool }`. They are updated on every accepted run, and a row is written only when the value changes.
- **Unlocks:** `unlocks` holds up to 64 entries of `{ id: /^[a-z0-9][a-z0-9_-]{0,47}$/ (stable key), name (≤48), description? (≤140), hidden?: bool (shown as "???" until earned), requires?: [ids] (no cycles), when: Condition }`.
- **Conditions:** a Condition is `{ run: <score field>, <op>: n }` (this run's value), `{ stat: <stat>, <op>: n }` (after this run), `{ all: [...] }` or `{ any: [...] }`. `<op>` is exactly one of gte/gt/lte/lt/eq. Max depth is 4, with at most 16 nodes per unlock.
- **Evaluation:** unlocks are evaluated repeatedly until nothing new unlocks, so `requires` chains resolve in one submit.
- **Board:** `board: { enabled: bool }` (default true) controls the progress board, which ranks by unlock_count desc. On a tie, the earliest last unlock ranks higher; full ties share a rank. Players with 0 unlocks are unranked. Bans mirror onto it (shadow is visible to self only; block hides).
- **Submit response:** when progression is configured, submit adds `progress: { newUnlocks: [{ id, name, description?, unlockedAt }], unlocked: [ids], stats: {name: value}, rank: n|null, total }`.
Progression game routes (anon key + origin allowlist):
- `GET {LB}/progress?device=` → `{ stats (own private ones included), unlocked: [{ id, name, description?, unlockedAt }], locked: [{ id, name, description?, requires? } | { id, name: "???", hidden: true }], rank|null, total }`.
- `GET {LB}/progress/top?limit=10&device=` → `{ rows: [{ rank, name, unlocks, total, lastUnlockAt, me? }], total }`. Returns `404` when the board is disabled.
- `GET {LB}/progress/around?device=&span=5` (max 10) → `{ rank|null, rows, total }`.
- `POST {LB}/devices/link` `{ device }` → `{ code: "XXXX-XXXX", expiresAt }`. Codes use 8 characters with no 0/O/1/I, are single-use, expire after 15 min and are stored hashed. Limited to 5 per device per hour (`429` with Retry-After). Returns `404` if the device has neither progress nor leaderboard rows.
- `POST {LB}/devices/claim` `{ code, device }` → `{ device: <original id>, progress|null }`. Case and dashes are ignored. Limited to 10 attempts per IP per hour. `400 bad_code` if the code is unknown, used or expired. The client must adopt the returned device id (the SDK does); progress is never merged.
Progression owner routes (secret key):
- `GET {LB}/progress/summary` → `{ players, unlocks: [{ id, name, earned, pct }] }`. `GET {LB}` also includes this as `progression`.
- `GET {LB}/progress/players?q=&limit=50` → `{ players: [{ device, name, unlocks, lastUnlockAt, visibility, rank }], total }`. `q` matches a name fragment or an exact device id.
- `GET {LB}/progress/devices/:device` → `{ device, stats: {name: { value, updatedAt }}, unlocks: [{ id, name, inConfig, source: run|owner|backfill, runId, unlockedAt }], profile|null, ban|null }`.
- `POST {LB}/progress/devices/:device/unlocks` `{ id }` grants an unlock (source `owner`). `DELETE {LB}/progress/devices/:device/unlocks/:id` revokes one. `DELETE {LB}/progress/devices/:device` resets stats, unlocks and profile, and keeps leaderboard rows.
- `POST {LB}/progress/backfill` `{ cursor?, limit? (≤5000) }` → `{ processed, granted, nextCursor|null }`.
- Covers devices with a profile or any leaderboard row.
- `stat:` conditions use stored stats; `run:` conditions use the best entry on the first all-time `keep: best` board (approximate).
- Seeds missing max/min/last stats from that entry. Idempotent.
- On config change, a removed or redefined stat (changed agg or field) loses its rows. Removed unlocks keep their rows but stop counting; re-adding the id restores them. Deleting a run doesn't roll back stats.
- Errors: `progression_disabled` (400) on progress routes when the config has no progression; `bad_code` (400) on claim.
SDK: `progress()`, `cachedProgress()` (sync, from localStorage: `{ stats, unlocked: [ids], rank, total, at }`), `progressTop({ limit })`, `progressAround({ span })`, `linkCode()`, `claimCode(code)` (adopts the original device id), and `widget(el, { boards: [..., "progress"] })`.
Leaderboard rows count toward the plan's row reads and writes (a submit writes about boards + 1 rows). The data lives in reserved `_unbase_lb_*` tables that tenant SQL can't access, and it is deleted with the project.
## Players & worlds (players, cloud saves, world claims, map, friends, mail)
Base `G = https://api.unbase.dev/v1/projects/:id`. Every route needs the project key in the `apikey` header (anon key for games; Authorization is NOT accepted for the key here). Routes acting as a player also need `Authorization: Bearer <player token>` (`upt_...`); a player can only write their own rows. The leaderboard `allowedOrigins` applies to anon-key calls. Errors: `{ error: { code, message, ...details } }`. Full guide: `/docs/players.md`.
Players:
- `POST {G}/players` `{ name?, device? }` → `{ created, player, token, device }` (201 if created). With `device` (>=16 chars, e.g. the leaderboard SDK's `ready()` id) returns that device's player (creating it once) with a new token. New players: 60/IP/hour. 20 newest tokens kept per player.
- `GET {G}/players/me` (token) → `{ player: { id, name, friendCode, visits, mail, home: claim|null, save: { version, updatedAt }|null, createdAt } }`.
- `PATCH {G}/players/me` `{ name?, visits?: "public"|"friends"|"private" (default friends), mail?: "anyone"|"friends"|"off" }`.
- `GET {G}/players/:playerId` → public `{ id, name, visits, home, createdAt }`.
- `POST {G}/players/me/ping` heartbeat (keeps the active claim alive). `POST {G}/players/me/link-code` → `{ code: "XXXX-XXXX", expiresAt }` (15 min, single use). `POST {G}/players/link` `{ code }` → `{ player, token, device }` (accepts leaderboard save codes too).
Saves (one JSON blob per player, <=256 KB, in R2, counts against file-storage quota):
- `PUT {G}/players/me/save` `{ version: <version it's based on, 0 first>, data }` → `{ version, updatedAt, size }`; stale base → `409 save_conflict` with `error.currentVersion` and `error.updatedAt`. Concurrent writes from the same base: exactly one wins.
- `GET {G}/players/me/save[?since=N]` → `{ playerId, version, updatedAt, data }` or `{ ..., unchanged: true }`.
- `GET {G}/players/:playerId/save` (token optional) — allowed if owner's visits is public, or friends and caller is a friend; else `403 visits_not_allowed`; `404 no_save`.
Claims (cells "x,y", integers within ±1e6; one Durable Object decides all claims):
- Response for claim calls: `{ archipelago, claim: { archipelago, x, y, status: "active"|"complete", owner: { id, name }, claimedAt, completedAt }, requested, granted, reason: null|"taken"|"has_active_claim" }`. `archipelago` is what the player holds now.
- `POST {G}/claims/next` `{ near?: friendCode }` → next free cell on a square spiral from 0,0 (released cells first), or beside the friend's home (6 rings). Idempotent while you hold an unfinished claim.
- `POST {G}/claims/:cell` `{ near? }` → claims that cell; if taken, you get the next free one with `reason: "taken"`.
- `POST {G}/claims/:cell/complete` (permanent, keeps owner name), `POST {G}/claims/:cell/release` (active only; complete → `409 claim_complete`), `GET {G}/claims/me`, `GET {G}/claims/:cell` (or `status: "uncharted"`).
- One active claim per player. Active claims idle 30 days (no save/ping) are released (daily job + lazily).
Map: `GET {G}/map?x0&y0&x1&y1` (<=64 cells per axis) → `{ x0, y0, x1, y1, archipelagos: [claim] }`.
Friends: `GET {G}/friends/:code` → public player. `POST {G}/players/me/friends` `{ code }` links both ways (20/hour, max 200). `GET {G}/players/me/friends`. `DELETE {G}/players/me/friends/:playerId`. `POST {G}/players/me/friend-code` rotates the code.
Mail: `POST {G}/mail/:playerId` `{ kind: /^[a-z][a-z0-9_-]{0,31}$/, body: JSON <=2 KB }` (token) → `{ id, sentAt }`; 30/hour per sender, 5/hour per pair; respects recipient `mail` (`403 mail_closed`). `GET {G}/mail/me?limit=50&before=` → `{ messages: [{ id, kind, body, from: { id, name }, sentAt }], next }`. `DELETE {G}/mail/me/:id`. `POST {G}/mail/me/:id/report`. Mailbox keeps newest 200. Owner (secret key in `apikey`): `GET {G}/mail/reports`, `PUT|DELETE {G}/players/:playerId/mute` (muted senders get a normal answer, nothing delivered).
## Auth service (per-project end-user auth)
Every project has a built-in, Supabase-style Auth service under `/v1/projects/:id/auth`. It authenticates the **end users of your app** (not the project owner) and hands them JWT sessions. Callers authenticate with the project's **anon key** in an `apikey` header (the secret key also works). Base path below is `AUTH = https://api.unbase.dev/v1/projects/:id/auth`.
A **Session** (returned by signup/signin/verify/token/password/reset) is:
```json
{
"accessToken": "<HS256 JWT>",
"tokenType": "bearer",
"expiresIn": 3600,
"expiresAt": 1751824800,
"refreshToken": "<single-use>",
"user": { "id": "user_...", "email": "u@x.com", "emailConfirmedAt": null, "createdAt": "...", "lastSignInAt": "..." }
}
```
The `accessToken` is an **HS256 JWT signed with the project's own JWT secret** (retrievable via `.../auth/settings`), so the project owner can verify end-user tokens in their own backend. Claims: `{ sub: userId, iss: projectId, role: "authenticated", email, iat, exp }`.
- `POST {AUTH}/signup { email, password }` — `201`, Session. Password min length 8. Requires `apikey`.
- `POST {AUTH}/signin { email, password }` — `200`, Session. Requires `apikey`.
- `POST {AUTH}/magiclink { email, redirectTo? }` — `{ sent, devLink? }`. Emails the end user a link to `redirectTo?token=...` (falls back to `<SITE_URL>/auth/callback?token=...`); your app then calls `.../auth/verify` with the token. Keyless dev mode returns `devLink` inline. Requires `apikey`.
- `POST {AUTH}/verify { token }` — `200`, Session (passwordless; creates the user on first verify). Requires `apikey`.
- `POST {AUTH}/token { refreshToken }` — `200`, Session (refresh-token rotation; single-use). Requires `apikey`.
- `POST {AUTH}/password { currentPassword?, newPassword }` — `200`, fresh Session. Changes the signed-in user's password: `Authorization: Bearer <accessToken>` + `apikey`. `currentPassword` is required if the user already has one (`401` if wrong); passwordless (magic-link) users omit it to set one for the first time. New password min length 8. Revokes the user's other sessions.
- `POST {AUTH}/recover { email, redirectTo? }` — `{ sent, devLink? }`. Starts a forgot-password flow: emails a reset link to `redirectTo?token=...` (falls back to `<SITE_URL>/auth/reset?token=...`); your app then calls `.../auth/reset`. Always reports success (no account-existence leak). Keyless dev mode returns `devLink` inline. Requires `apikey`.
- `POST {AUTH}/reset { token, newPassword }` — `200`, fresh Session. Completes a forgot-password flow: sets the new password (min length 8), revokes all prior sessions. Requires `apikey`.
- `POST {AUTH}/user` — `Authorization: Bearer <accessToken>` + `apikey`, body `{}` → `{ user }`.
- `POST {AUTH}/logout { refreshToken }` — `204`. Requires `apikey`.
- `GET {AUTH}/users` — **admin**, `Authorization: Bearer <secretKey>` → `{ users: [...] }` (secret key only; anon key rejected).
- `GET {AUTH}/settings` — **admin**, `Authorization: Bearer <secretKey>` → `{ jwtSecret, userCount, anonKey, authUrl }` (secret key only).
## Errors
All errors are JSON:
```json
{ "error": { "code": "unauthorized", "message": "human-readable explanation" } }
```
| HTTP | code | Meaning |
|---|---|---|
| 400 | `bad_request` | Malformed request (e.g. missing `sql`, empty `statements`) |
| 401 | `unauthorized` | Missing, invalid, or mismatched (wrong `projectId`) key/token |
| 403 | `forbidden` | Quota hard-limit exceeded (writes only), a Storage upload would exceed the file-storage quota, account suspended, or SQL touched a reserved `_unbase_*` table |
| 404 | `not_found` | Unknown route, project, or storage object |
| 409 | `conflict` | Auth signup where the email already exists |
| 413 | `payload_too_large` | Response would exceed the 2 MB response cap (narrow the query), or a Storage upload exceeds the 100 MB per-object cap |
| 429 | `rate_limited` | More than 3 project creations from one IP in an hour |
| 503 | `service_unavailable` | Shared free-tier capacity temporarily saturated (circuit breaker). Writes only; reads unaffected. Retry shortly. |
## Plans & limits
| | Anonymous | Free (email) | Founder (€5/mo) | Pro (€15/mo) |
|---|---|---|---|---|
| Projects | 1 | Unlimited | Unlimited | Unlimited |
| Total storage | 25 MB | 100 MB | 2 GB | 10 GB |
| File storage (Storage service) | 25 MB | 100 MB | 2 GB | 10 GB |
| Row reads / month | 100,000 | 1,000,000 | 25,000,000 | 100,000,000 |
| Row writes / month | 10,000 | 50,000 | 1,000,000 | 5,000,000 |
| Retention | 7 days unless claimed | Forever | Forever | Forever |
| Export | Yes | Yes | Yes | Yes |
New projects start on **anonymous**. The claim flow (`POST /v1/claim` → `POST /v1/claim/verify`) moves a project to **free**. Paid plans (`founder`/`pro`) are applied automatically from Stripe via `POST /v1/stripe/webhook`, not assigned through the bearer-token API.
## Minimal working example (curl)
```bash
# 1. Create (production requires a Turnstile token here, so get a project at
# https://unbase.dev or via POST /v1/account/projects with a session; see above)
curl -s -X POST https://api.unbase.dev/v1/projects -d '{"turnstileToken":"<token>"}'
# => { "projectId": "unbase_abc123", "url": "...", "token": "unbase_abc123.SIGNATURE", "anonKey": "unbase_abc123.pk.SIGNATURE" }
# 2. Query (use the returned projectId + secret token)
curl -s -X POST https://api.unbase.dev/v1/projects/unbase_abc123/query \
-H "Authorization: Bearer unbase_abc123.SIGNATURE" \
-d '{"sql":"CREATE TABLE t (id INTEGER PRIMARY KEY, v TEXT)"}'
curl -s -X POST https://api.unbase.dev/v1/projects/unbase_abc123/query \
-H "Authorization: Bearer unbase_abc123.SIGNATURE" \
-d '{"sql":"INSERT INTO t (v) VALUES (?)","params":["hello"]}'
curl -s -X POST https://api.unbase.dev/v1/projects/unbase_abc123/query \
-H "Authorization: Bearer unbase_abc123.SIGNATURE" \
-d '{"sql":"SELECT * FROM t"}'
# 3. Sign up an end user (anon key in the apikey header)
curl -s -X POST https://api.unbase.dev/v1/projects/unbase_abc123/auth/signup \
-H "apikey: unbase_abc123.pk.SIGNATURE" \
-d '{"email":"user@example.com","password":"hunter2!!"}'
# 4. Upload and download a file
curl -s -X PUT https://api.unbase.dev/v1/projects/unbase_abc123/storage/objects/hello.txt \
-H "Authorization: Bearer unbase_abc123.SIGNATURE" \
-H "Content-Type: text/plain" -d "hello world"
curl -s https://api.unbase.dev/v1/projects/unbase_abc123/storage/objects/hello.txt \
-H "Authorization: Bearer unbase_abc123.SIGNATURE"
# 5. Post a game score to the default leaderboard (anon key; start, then submit)
curl -s -X POST https://api.unbase.dev/v1/projects/unbase_abc123/leaderboards/runs/start \
-H "apikey: unbase_abc123.pk.SIGNATURE" -d '{"device":"player-device-1"}'
curl -s -X POST https://api.unbase.dev/v1/projects/unbase_abc123/leaderboards/runs/submit \
-H "apikey: unbase_abc123.pk.SIGNATURE" -d '{"token":"run_...","name":"Ada","values":{"score":1200}}'
curl -s https://api.unbase.dev/v1/projects/unbase_abc123/leaderboards/boards/all/top \
-H "apikey: unbase_abc123.pk.SIGNATURE"
```
## Further reading
- Full OpenAPI 3.0 spec: `/docs/openapi.yaml`
- Human quickstart: `/docs/quickstart.md`
- Leaderboards guide: `/docs/leaderboards.md`