# PrefsDB
> A no-accounts key/value and list storage API: hash a client-chosen seed phrase into an opaque API key, then read, write, and delete JSON-scoped values, lists, and files over plain HTTP — no signup, no SDK, and no backend to run.
PrefsDB is built to be callable directly over HTTP with no client library: every endpoint takes plain query-string or form params and returns JSON. This file is a map for language models and coding agents generating integration code against the API. Human-oriented docs live at /docs/; treat the pages linked below as the source of truth over any summary here, since exact parameters, defaults, and quotas can change per deploy target.
## Getting started
- [llms-full.txt](https://prefsdb.com/llms-full.txt): this file plus the full text of every docs page, in one request.
- [openapi.json](https://prefsdb.com/openapi.json): OpenAPI 3.1 description of every public endpoint, its parameters, responses and error codes, for function calling and client generators.
- [Docs home](https://prefsdb.com/docs/): what PrefsDB is and when to use it.
- [Getting started](https://prefsdb.com/docs/gettingstarted/): the shortest path from zero to a working read/write.
- [Get an API key](https://prefsdb.com/docs/getapikey/): exchange a seed phrase for a key via `getkey`/`createkey` — no account required.
- [API overview](https://prefsdb.com/docs/api_overview/): the four ways to get a key, sessions, request and response shape, how data is organized and isolated, and when PrefsDB is not a fit.
- [Examples](https://prefsdb.com/docs/examples/): worked `getkey()` and read/write calls with parameter tables.
## Core operations
- [Read API](https://prefsdb.com/docs/readapi/)
- [Write API](https://prefsdb.com/docs/writeapi/)
- [Remove data](https://prefsdb.com/docs/removedata/)
- [Rotate keys](https://prefsdb.com/docs/rotatekeys/)
- [Grouping data (domain/subdomain/project/type)](https://prefsdb.com/docs/groupingdata/)
- [Filters](https://prefsdb.com/docs/filters/)
- [Options](https://prefsdb.com/docs/options/)
- [Offline and optimistic writes](https://prefsdb.com/docs/offline/): what `queued`/`optimistic` results mean, `db.onSyncEvent()`, and `db.logout()` on sign-out.
- [Your site's name and logo](https://prefsdb.com/docs/branding/): branding on verification emails and pages.
- [Client-side encryption](https://prefsdb.com/docs/clientsideencryption/): opt-in; the server stores ciphertext it can't read, with trade-offs.
- [Sign in with Google](https://prefsdb.com/docs/googlesignin/), [Microsoft](https://prefsdb.com/docs/microsoftsignin/), [Apple](https://prefsdb.com/docs/applesignin/), [GitHub](https://prefsdb.com/docs/githubsignin/): a key per signed-in user instead of a seed phrase.
- [Sign-in buttons](https://prefsdb.com/docs/signinbuttons/): `prefsdb.signin.v1.js`, ready-made buttons for all four, and a template sign-in page.
- [Recovery phrases](https://prefsdb.com/docs/recoveryphrases/): `prefsdb.phrase.v1.js` (optional), a 12-word seed generated in the browser.
- [Errors](https://prefsdb.com/docs/errors/)
## Client library
- [API reference](https://prefsdb.com/docs/prefsdbapi/): every method of the JS client.
- [Methods table](https://prefsdb.com/docs/libs/): one-line summary of each client method.
- [Including the JS library](https://prefsdb.com/docs/lib_js/): script tag and setup.
- [prefsdb.v1.js source](https://prefsdb.com/prefsdb.v1.js): the official JS client. Read it directly to see the exact request/response shape it builds and parses — a fluent builder (`.domain()`, `.list()`, `.key()`, `.keys([...])`, `.ttl()`, `.filter()`, then `.read()`/`.write()`/`.delete()`/`.history()`/`.undo()`) over the same plain endpoints listed above.
- Pinned release with SRI (never changes): ``. Every release and hash: https://prefsdb.com/v/versions.json; the readable source sits next to each build (`/v/1.0.0/prefsdb.v1.js`). The unversioned `prefsdb.v1.min.js` always serves the newest client.
- [prefsdb.channels.v1.js source](https://prefsdb.com/prefsdb.channels.v1.js): optional broadcast pub/sub add-on (`channel/publish`, `channel/poll`); load it (or `prefsdb.channels.v1.min.js`) after `prefsdb.v1.js`.
## Quick shape
```
GET https://prefsdb.com/api/v1/getkey/?seed=&name=&email=
-> { "token": "", "session": { "token": "", "expiresIn": 60 },
"emailVerificationRequired": true, "sponsored": false, ... }
# no auth required for this call. A brand-new key needs `email` and only works
# once the emailed link is clicked -- unless the calling site sponsors its users'
# keys, in which case no email is needed ("sponsored": true), or the address is
# at sandbox.prefsdb.com (a test key). Check emailVerificationRequired in the
# response rather than assuming either; poll GET verify/status/ to see it change.
POST https://prefsdb.com/api/v1/write/?theme=
-H "X-Authorization: Token " -H "X-Prefs-Session: " -H "Content-Type: text/plain"
--data-binary "dark"
# the body is the value (any text up to 1 MB on free, 4 MB Plus, 8 MB Pro, stored exactly, emoji included; key
# names up to 240 characters, case-insensitive); the key is the
# param with no value. Short values can also go in the query: GET .../write/?theme=dark
# (several key=value pairs at once). In JS, write() always POSTs (there is no post()).
GET https://prefsdb.com/api/v1/read/?theme
-H "X-Authorization: Token "
-> { "value": "dark", "count": 1, ... }
# one match comes back as "value", several as a "values" array (add __start=0 to
# always get "values"; JS: read({ array: true }) or entries()). Large lists:
# page with __limit= and pass each response's "next" back as __after
# (JS: .read({ limit, after }) or for await (const page of db.list(x).pages({ limit })));
# a single read returns at most 1,000 rows (and 8 MB of values) and says "more": true
# if there are more -- loop on "more", not on the row count.
```
`delete`, `undo`, `expire`, `rotatekey`, `putobject`/`getobject`/`deleteobject`, and `channel/publish`/`channel/poll` follow the same plain-params-in, JSON-out shape — see the linked docs for full parameter tables.
## Endpoint reference
All under `https://prefsdb.com/api/v1/`. Every endpoint except `getkey`/`createkey` needs the key (see "Auth"). Params go in the query string; `write` takes its value as the raw POST body, `putobject` as `multipart/form-data`. JSON out, except `getobject`, which returns the file. Always check `success` in the JSON body: errors come back as JSON with `success: false`, `error` and `message`.
| Endpoint | Method | Params | Returns |
|---|---|---|---|
| `getkey/` | GET/POST | `seed`, `name`, `email` (new key only; `@sandbox.prefsdb.com` makes a test key), `namespace` or `appcred`, `offline`, `sessionttl`; `renew=1` + the key to renew a session | `token`, `session` (send `session.token` as `X-Prefs-Session`); existing key if seed+name+origin already known |
| `verify/status/` | GET | none (just the key; no session) | `verified`, and `reason` while false: poll it to learn when the email link was clicked |
| `getkey/` (sign-in) | POST form body | `id_token` (a Google, Microsoft or Apple ID token carrying a nonce from `getkey/nonce/`), `provider` (default `google`; `microsoft`, `apple`), `name` (default `default`), `offline`, `sessionttl`. GitHub instead: `provider=github`, `signin_code` (from the `/signin/github/` popup) and `code_verifier`, from the page's own `Origin` | `token`; the same user always gets the same key for your client ID (GitHub: your site's origin) and name; created already verified, no `email` |
| `getkey/nonce/` | GET | none | `nonce` (single use, 10 minutes) for the sign-in provider to put in the ID token |
| `createkey/` | GET/POST | same as `getkey` (no `id_token`) | `token`; errors if the key already exists |
| `read/` | GET/POST | bare keys `?a=&b=`, `*`/`**` wildcards, scope, `list`, `__map`, `__filter`, `__limit`, `__after`, `__start`, `__history=1` | `value` (one match) or `values` (several), `count`, `more`, `next` |
| `write/` | GET/POST | `?key=` + raw body, or `?key=value&key2=value2`; scope, `list`, `__map`, `ttl` | `updated` (rows changed) |
| `delete/` | GET/POST | same selectors as `read`, plus `__filter` | `affectedCount` |
| `undo/` | GET/POST | exactly one bare key, scope, `__map` | restores the previous value; needs value history enabled |
| `expire/` | GET/POST | `days`, and at least one of `domain`/`subdomain`/`project`/`list`/`__map` | sets or clears expiry on existing rows |
| `rotatekey/` | GET/POST | new `seed`, new `name`, `namespace`/`appcred` | new `token`; the old key stops working |
| `putobject/` | POST multipart | `file`, optional `domain`/`subdomain`/`project`/`list`/`path` | `file` (id), `shareUrl`, `shareViewUrl`, `storage` |
| `getobject/` | GET | `file`, the same scope used on upload; or `url=1` | the decrypted file, or with `url=1` JSON share links |
| `deleteobject/` | GET/POST | `file`, the same scope used on upload | deletes the file and frees the space |
| `channel/publish/` | GET/POST | `channel` (`[A-Za-z0-9_-]{1,128}`), `message` or raw body | `id` |
| `channel/poll/` | GET | `channel`, `since`, `limit` (1-200, default 50), `peek` | messages and a `cursor` to pass back as `since` |
Channels are the one exception to key isolation: any key that knows the channel name can publish and read it.
## Data model
- **Pairs** are the default. One value per key; writing the same key again overwrites it. Key names are case- and accent-insensitive (`Theme` = `theme`).
- **Lists** (`list=`) are append-only. The same key can hold many values; adding a value already in the list is ignored. List values are compared exactly (`Apple` and `apple` are different).
- **Maps** (`__map=`) are named groups of pairs: writing an entry again overwrites it. `?__map=m&*=` reads the whole map.
- **Scope:** `domain`, `subdomain`, `project` and `type` are free-form labels stored with each value. They nest in no fixed order and are all optional. `read` and `delete` match the scope exactly, so a level you leave out matches only values stored *without* one. `expire` is the exception: a level you leave out matches any value.
- **Reserved words:** these can't be used as your own key names, because they are request params: `project`, `domain`, `subdomain`, `type`, `list`, `name`, `ttl`, `key`, `hash`, `token`, `keyhash`, `__map`, `__filter`, `__history`, `__limit`, `__start`, `__after`, `__since`, `__ifVersion`. Using one as a key name is refused with `400 RESERVED_KEY_NAME` (the JS client's `key()`/`keys()` throw it before sending); avoid other names starting with `__` too. `list` and `map` are also reserved values of `type`.
- **Wildcards** on `read`/`delete`: `*` is every non-keyed value in the given scope, `**` is everything the key can see in any scope, and either one overrides any specific key names given with it.
- **One response shape:** a read answers `value` for exactly one row and `values` for several. To always get `values`, add `__start=0` (HTTP) or use `read({ array: true })` / `entries()` (JS; `entries()` gives `[{ key, value, ... }]`, handy for maps). Each `values` entry is `{ "": value, uid, modifiedt }`. `entries()` returns one read (at most 1,000 rows on free); the array's `more`/`next` say whether rows were left out, and `entries({ max: Infinity })` reads every page.
- **JS chains don't share state:** `.domain()`, `.map()`, `.key()` etc. return a new object and leave `db` unchanged, so a partial chain can be kept (`const inv = db.map("inventory")`) and two requests can be built at once. Don't call `db.domain("x");` as a statement of its own and expect the next call to use it.
- Values are text, any Unicode including emoji. Binary bodies are stored base64-encoded. Values over the plan's limit (1 MB free, 4 MB Plus, 8 MB Pro), more than 8 MB of values in one write, and key names over 240 characters are rejected. Put larger data in `putobject`.
- Every key's data is isolated: a key can only ever see its own rows.
- `__filter=` is a SQL `LIKE` pattern on the stored value (`%foo%`). Dashboard keys store values encrypted by default, so for them only exact matches work, and a `%` pattern gets `400 FILTER_UNAVAILABLE`.
## Auth, and the mistakes agents make
- Send `X-Authorization: Token ` (what the JS client sends). `Authorization: Bearer ` works too, as does `Bearer` in `X-Authorization`. The key can also be passed as a `key`, `hash`, `token` or `keyhash` param (the header wins). A dashboard key may be read-only, limited to a scope (send exactly that `project`/`domain`/`subdomain` with every request) or expiring; give an agent the narrowest key that does the job. That is the only way to link to `getobject` from an `
` or ``.
- An **anonymous key** (from `getkey`/`createkey`) also needs the session that call returned: send `session.token` as the `X-Prefs-Session` header on every call. It lasts 60 seconds by default (`sessionttl` changes it); `offline=1` adds `session.offlineToken` (8 hours, read/write/delete/undo only), sent as `X-Prefs-Offline-Session`. Without a session you get `401 SESSION_REQUIRED`, after it runs out `401 SESSION_EXPIRED`: call `getkey` again. Check `error`, never `message`. The JS client does all of this itself, including renewing the session (`autoRenew`, on by default; for a key from a Google/Microsoft/Apple/GitHub sign-in, for 7 days from the sign-in; `db.onSessionExpired(fn)` for when it can't). Don't rely on cookies: the API answers with `Access-Control-Allow-Origin: *`, so a browser never sends them to prefsdb.com from your site (`credentials: "include"` doesn't help). curl can use a cookie jar (`-c`/`-b cookies.txt`) instead of the header.
- A **dashboard-issued key** needs no session and doesn't expire. Use it for servers, cron jobs and CLIs.
- The JS client's `getkey`/`createkey` refuse to run on a plain `http:` page (`INSECURE_CONTEXT`); `https:` and `http://localhost`/`127.0.0.1` (for development) are fine. The API itself is only served over `https`.
- Keys are scoped to the caller's `Origin` header, so the same seed+name from two sites gives two different keys. Callers with no `Origin` (curl, servers) all share one bucket unless they pass `namespace=` (self-declared) or `appcred=` (minted in the dashboard's App Credentials panel; unknown or revoked ones get `403 APPCRED_INVALID`). They are mutually exclusive. A random 32+ character `namespace` is enough for a script. `appcred` adds validation and revocation.
- So `http://localhost:5173` and your production site have different users. Develop with test keys (a `sandbox.prefsdb.com` email) instead of carrying keys over. A site owner whose app has several verified addresses (`example.com` and `www.example.com`) can mark them as the same site in the dashboard's Sites panel; then they share keys.
- Pass the same `namespace`/`appcred` on every later `getkey`, or you get a different, empty key.
- The seed is the password. Don't use something guessable. `getkey`/`createkey` are rate-limited to 20 calls a minute per IP (429).
- A brand-new anonymous key needs `email`, and does nothing (`403 UNVERIFIED`) until the emailed link is clicked, unless the calling site sponsors its users' keys. Check `emailVerificationRequired` in the `getkey` response instead of assuming (it stays true for an existing key until the link is clicked). To know when the user has clicked it: `await db.waitForVerification()` in JS, or poll `GET /api/v1/verify/status/` with the key (no session needed) every few seconds until `verified` is true. Don't poll `getkey` for this: it's limited to 20 calls a minute per IP.
- **Testing without an inbox:** pass an email address at `sandbox.prefsdb.com` (e.g. `dev@sandbox.prefsdb.com`). That makes a *test key*: usable at once, no email sent, `testKey: true` and `expires` in the response. It lasts a day, has small limits (64 KB values, 2,000 requests a day), can't store files (`403 TEST_KEY_NO_FILES`), and one network can make 20 a day. Use it to check an integration end to end, then switch to a real address.
- `getkey` returns the existing key and creates nothing without `email`, so it is safe to call on every page load.
- **Sign in with Google instead of a seed:** `const nonce = await PrefsDB.idTokenNonce();` pass it to Google Identity Services (`google.accounts.id.initialize({ client_id, nonce, callback })`), then `await PrefsDB.getkeyWithIdToken(credential, { name })`. Get a fresh nonce for every attempt; each works once. The ID token must go in the POST body, never the URL. `namespace`/`appcred` don't apply. `reviewUrl()` is where the user signs in with Google again to see or download their data. Don't derive a client-side encryption key from the token or user id.
- **Sign in with Microsoft instead of a seed:** the same with MSAL.js: `await msalApp.loginPopup({ scopes: ["openid", "profile"], nonce })`, then `await PrefsDB.getkeyWithIdToken(result.idToken, { provider: "microsoft", name })`. The app registration needs a Single-page application redirect URI; no client secret. Its "Supported account types" must match the MSAL `authority`: `common` (work, school and personal), `organizations`, `consumers`, or the tenant ID for a single-tenant app; adding personal accounts to an existing registration first needs the manifest's `api.requestedAccessTokenVersion` set to 2. No email is stored for these keys. `reviewUrl()` is where the user signs in with Microsoft again (PrefsDB's own app registration) to see or download their data.
- **Sign in with Apple instead of a seed:** the same with Apple's script (`https://appleid.cdn-apple.com/appleauth/static/jsapi/appleid/1/en_US/appleid.auth.js`): `AppleID.auth.init({ clientId: , scope: "email", redirectURI, nonce, usePopup: true })`, then `const data = await AppleID.auth.signIn()` straight from the click (fetch the nonce beforehand, or the popup is blocked), then `await PrefsDB.getkeyWithIdToken(data.authorization.id_token, { provider: "apple", name })`. Needs an Apple Developer account and a Services ID with the site's domain and a Return URL; no client secret. The email (possibly a Hide My Email relay) is kept only when Apple vouches for it; no email is fine. `reviewUrl()` is where the user signs in with Apple again to see or download their data; it uses the app's own Services ID (Apple's user ids differ per developer team), so it works only after the developer adds `prefsdb.com` to the Services ID's Domains and `https://prefsdb.com/review/apple/callback.php` to its Return URLs.
- **Sign in with GitHub instead of a seed:** `await PrefsDB.signInWithGitHub({ name })`, called directly from a click handler (it opens a popup at once). No GitHub app, client ID or secret of your own: PrefsDB's GitHub app signs the user in (GitHub's screen says "PrefsDB") and the popup hands the page a one-time code, which the client trades for the key with a verifier only that page holds. The key belongs to the GitHub user, the page's origin and `name`. No email is stored; `reviewUrl()` is where the user signs in with GitHub again to see or download their data. Client-side errors: `POPUP_BLOCKED`, `POPUP_CLOSED`, `GITHUB_SIGNIN_CANCELLED`, `GITHUB_SIGNIN_FAILED`. A page with `Cross-Origin-Opener-Policy: same-origin` cuts the popup off; use `same-origin-allow-popups`.
- **Each way in is a separate key:** Google, Microsoft, Apple, GitHub and seed+email give the same person different, unlinked keys. Offer one, or warn users who switch (the sign-in add-on's `onSignIn` gets `previousProvider` when this browser last used another one).
- **Sign-in buttons (any of the four, ready-made):** load `https://prefsdb.com/prefsdb.signin.v1.min.js` after the client (readable source: `prefsdb.signin.v1.js`), then `PrefsDB.signIn({ name, google: { clientId }, apple: { clientId, redirectUri }, microsoft: { clientId, redirectUri }, github: true, onSignIn: (db, { provider }) => {...}, onError: (err) => {...} }).mount("#signin")`. It renders each provider's own button (Google's and Apple's from their scripts; Microsoft and GitHub drawn to their guidelines), loads only the configured providers' scripts (MSAL.js from jsDelivr, pinned), fetches nonces ahead and opens popups inside the click. Errors have one shape, `{ error, message, provider }`: `POPUP_CLOSED`, `POPUP_BLOCKED`, `NOT_READY`, `SCRIPT_LOAD_FAILED`, `SIGNIN_FAILED`, or getkey's own. `attach(element, "microsoft"|"github")` uses your own button; Google and Apple only sign in through theirs. One instance with Google or Apple per page. A template page: `https://prefsdb.com/docs/files/signin-template.html`.
- **Recovery phrase as the seed (optional add-on):** load `https://prefsdb.com/prefsdb.phrase.v1.min.js` after the client. `await PrefsDB.generatePhrase({ words: 12 })` picks words in the browser with `crypto.getRandomValues` from EFF's 7,776-word list (fetched on demand from `/api/words/list/`, about 13 bits a word); the phrase never reaches the server. `PrefsDB.showPhrase(el, phrase, { onConfirm, copy })` shows it with a "written down" checkbox. Use the phrase as the seed of `createkey()`/`getkey()`. When the user types it back: `PrefsDB.normalizePhrase(text)`, then `await PrefsDB.checkPhrase(text)` → `{ valid, words, unknown }` before `getkey()` (a typo is another seed). Errors: `PHRASE_LIST_UNAVAILABLE`, `PHRASE_INVALID_OPTIONS`. The phrase grants access; it doesn't encrypt data (use client-side encryption for that).
- `write` with the value in the query string (`?theme=dark`) is fine for short values. For anything with `+`, `%`, `&`, line breaks or leading/trailing spaces, POST the raw body with `Content-Type: text/plain`.
- A read of a key that was never written is not an error: it returns `count: 0` and `value: ""`.
## Errors
Failures return JSON `{"success": false, "status": "error", "error": "", "message": "..."}` with the HTTP status below. `ts` is also included. A 500 adds a `ref` to quote to support. `error` is always a stable code (`A_CODE`); `message` is for people and may change. Never retry a 4xx unchanged. Every 429 has a `Retry-After` header (seconds; also `retryAfter` in the body): wait that long, then retry. 5xx can be retried once after a short wait.
| Status | `error` | Meaning and fix |
|---|---|---|
| 400 | `MISSING_API_KEY` | No key sent. Send `X-Authorization: Token ` (or `Authorization: Bearer `). |
| 400 | `UNKNOWN_API_KEY` | The key doesn't exist (a key deleted in the dashboard looks the same). Check its spelling. |
| 400 | `RESERVED_KEY_NAME` | A key named with a reserved word (`domain`, `subdomain`, `project`, `type`, `name`, `list`, `ttl`, `key`, `hash`, `token`, `keyhash`, `__map`, `__filter`, `__history`, `__limit`, `__start`, `__after`, `__since`, `__ifVersion`). The body's `key` names it. Rename the key. |
| 400 | `BAD_REQUEST` | A missing or malformed parameter; `message` says which. |
| 400 | `KEY_TOO_LONG` | Key name over 240 characters. |
| 400 | `VALUE_TOO_LARGE` | Value over the plan's limit (1 MB free, 4 MB Plus, 8 MB Pro), or over 8 MB of values in one write. The body has `limit`. Use `putobject`. |
| 400 | `FILTER_UNAVAILABLE` | `%` pattern on a key whose values are stored encrypted. Use an exact value. |
| 400 | `Invalid domain` (or `subdomain`/`project`/`list`) | `putobject`/`getobject` scope labels allow only `[A-Za-z0-9_-]`, up to 128 characters. |
| 401 | `SESSION_REQUIRED` | Anonymous key sent without its session. Send `session.token` from `getkey` as `X-Prefs-Session`. |
| 401 | `SESSION_EXPIRED` | The session ran out. Call `getkey` again. |
| 403 | `SESSION_MISMATCH` | The session belongs to another key. Call `getkey` for this key. |
| 401 | `SIGNIN_EXPIRED` | A sign-in key's session can't be renewed any more (7 days after the sign-in). Sign in again. |
| 403 | `KEY_EXISTS` | `createkey` for a seed+name that already has a key here. Use `getkey`. |
| 403 | `TOO_MANY_KEYS` | The email address already backs the most keys allowed. Waiting doesn't help. |
| 403 | `UNVERIFIED` | New anonymous key; the emailed link hasn't been clicked. |
| 403 | `SPONSORSHIP_LAPSED` | Key was created without an email on a site that no longer sponsors keys. |
| 403 | `KEY_SUSPENDED` | Dashboard key is suspended. A new **Live** key starts this way; activate it in the dashboard. |
| 403 | `KEY_READ_ONLY` | The key was created read-only. Reads work; nothing can be changed with it. |
| 403 | `SCOPE_NOT_ALLOWED` | The key is limited to a scope: send exactly the `project` (and `domain`/`subdomain`) in the response's `scope` with every request. |
| 400 | `INVALID_DEVICE_CODE` | `pair/poll`: no such pairing request (it ended). Start again with `pair/start`. |
| 403 | `KEY_EXPIRED` | The key's expiry date has passed. Ask its owner for a new key. |
| 403 | `ACCOUNT_SUSPENDED` | The key's account is suspended. Contact support. |
| 403 | `APPCRED_INVALID` | Unknown or revoked `appcred`. |
| 400 | `RESERVED_ORIGIN` | `Origin` header starting with `ns:`, `ac:` or `oidc:`, or not printable ASCII (getkey, createkey, rotatekey). Send no `Origin` and pass `namespace`/`appcred`. |
| 400 | `IDTOKEN_IN_QUERY` / `IDTOKEN_CONFLICT` / `IDTOKEN_PROVIDER` | Sign-in: `id_token` (or GitHub's `signin_code`/`code_verifier`) was in the URL, came with `seed`/`namespace`/`appcred`/`email`, or `provider` is unknown, switched off, or doesn't match (`signin_code` is GitHub's only; GitHub takes no `id_token`). `createkey` answers `IDTOKEN_NOT_SUPPORTED`: sign in through `getkey`. |
| 401 | `IDTOKEN_INVALID` / `IDTOKEN_REPLAYED` | The ID token failed verification (signature, issuer, audience, age), or its nonce is missing, foreign, expired or already used; for GitHub, the code is damaged, expired (2 minutes), already used, or the verifier or `Origin` doesn't match. Have the user sign in again. |
| 403 | `IDTOKEN_EMAIL_UNVERIFIED` | The Google account's email isn't verified. |
| 409 | `IDTOKEN_KEY` | `migrateorigin` on a key from a sign-in: it can't be moved. |
| 503 | `IDTOKEN_KEYS_UNAVAILABLE` | The provider's (Google, Microsoft, Apple) signing keys can't be fetched right now. Retry later. |
| 403 | `PLAN_LIMIT` | Dashboard action over the plan's limit (for example keys per account). |
| 405 | `DISK_QUOTA_EXCEEDED` | Upload would exceed storage. The body has `storage: {used, max, plan}`. `message` and `quota.metric` say which: "for this email address", "for this account" (sign-in keys, all apps together), "for this network" or "for this site". |
| 413 | `FILE_TOO_LARGE` | Upload over the per-file limit (12 MB on free). |
| 429 | `QUOTA_EXCEEDED` | `quota` says which `metric` (`ops` or `bytes`), `window` (`minute`/`day`/`month`), and `scope` (`key`, or the dashboard `account`'s shared pool). Wait `Retry-After` seconds (the window's end), or use a higher plan. |
| 429 | `RATE_LIMITED` / `TOO_MANY_KEYS` | Too many attempts (getkey/createkey per IP, verification emails, a sponsored site's new keys for the day). Wait `Retry-After` seconds. |
| 500 | `INTERNAL_ERROR` | Server fault. Has a `ref`. Safe to retry once. |
| 500 | `STORAGE_ERROR` | A file couldn't be stored or read. Retry once; quote the time to support. |
**The JS client's rule:** a refusal from the server *resolves* with `success: false` (check `success`); a request with no answer *rejects* (`error` `NETWORK_ERROR`, `TIMEOUT`, `ABORTED`, or `HTTP_` for a non-JSON answer). Pass `throwOnError: true` to `getkey()`/`createkey()`/`getkeyWithIdToken()`/`signInWithGitHub()`/`PrefsDB.client(key, opts)` to make refusals reject too, so `try { await ... } catch` sees every failure. Methods that resolve to something other than an answer reject on any failure regardless: `getkey`/`createkey`/the sign-ins/`rotate()`, `entries()`, `getObject()`, `getObjectUrl()`, `waitForVerification()`. Everything rejected is a `PrefsDBError` (an `Error`; also `PrefsDB.Error`) with the answer's fields plus `code` (= `error`), `httpStatus`, `retryAfter` and `ref`: `if (err instanceof PrefsDBError && err.code === "QUOTA_EXCEEDED") ...`.
## JS client: offline, optimistic writes, sign-out
- **Offline mode is on by default.** Offline, a write/put/remove of one key or a list `add()` is saved in IndexedDB and sent on reconnect; it resolves `{ success: true, queued: true }`, which means **not on the server yet**, so don't show it as saved. A read of one key answers from the local copy; so does a whole-map read (`map(name).entries()`/`readAll()`, from the copy of the last online read, `cached: true` on the array; `OFFLINE` if never read here). Other wide reads (`list().read()`, a scope-wide `readAll()`, `keys([...])`) need the network and reject with `NETWORK_ERROR`. Online and offline, whole-map reads include this browser's unconfirmed writes as `pending: true` rows. `{ offline: false }` on `getkey()` turns it off. Queued writes of an anonymous key can be sent for 8 hours (`offlineTtl`), then wait until the key gets a session again on that device.
- **`settled`:** every write/delete result (put, add, remove too) has a non-enumerable `settled` Promise: resolves when the change is on the server (at once for a plain write; on push/confirm for queued/optimistic), rejects with a `PrefsDBError` when it never will be (the server's code, `CONFLICT`, or `SIGNED_OUT`). `const w = await map.put(id, v, { optimistic: true }); w.settled.catch(() => rollback(id));` -- per-item rollback without event matching. Page-local: after a reload use `db.onSyncEvent()`.
- **Optimistic writes:** `{ optimistic: true }` on a write (or on `getkey()` for all of them) resolves at once with `optimistic: true`; the server's answer comes later as an event, and a refusal restores the previous value.
- **Events:** `db.onSyncEvent((e) => ...)` (only this key's events; `PrefsDB.onSyncEvent` hears every key) with `e.phase`: `queued`, `start`, `pushed`, `conflict`, `dropped` (`reason` `conflict` or `refused`: the write will never reach the server, re-read the value), `sync-error`/`auth-expired` (stays queued, retried), `pulled`, `optimistic`, `confirmed`, `optimistic-rollback`, `done`. `e.key` identifies the value (for an optimistic list `add()`, its `tempId`). `await db.pendingCount()` counts this key's queued writes; `PrefsDB.sync()` sends them now. Use these instead of hand-built rollback.
- **Sign-out:** call `await db.logout()`. It deletes the key's cached values, queued writes and their offline grants from this browser (`{ keepLocal: true }` keeps them) and removes its `onSyncEvent` listeners; without it, the next user of a shared device inherits them. Check `pendingCount()` first if unsent writes matter. The sign-in add-on's `signin.signOut()` calls `logout()` on the key it issued. Details: [Offline and Optimistic Writes](https://prefsdb.com/docs/offline/).
## Several devices: versions, conditional writes, change feed
- **Versions:** every key and map entry (not list entries) has a `version` that changes on every write. Reads return it (`version` on one value, on each `values[]` row and each `entries()` item), and so does a write of one key (`version` in its answer). Versions come from one counter per API key: compare them only for equality.
- **Conditional write/delete:** `__ifVersion=` on `write`/`delete` (JS: `put(k, v, { ifVersion })`, `write(v, { ifVersion })`, `remove(k, { ifVersion })`) changes the key only if its version is still that; `__ifVersion=0` on a write only if the key doesn't exist. Otherwise `409 CONFLICT` with `current: { exists, value, version, uid, modifiedt }` and nothing changed. One key per request; lists answer `CONDITION_NOT_SUPPORTED`. Offline, the queued write carries the condition and its `settled` rejects `CONFLICT` if it loses when it lands. Last write wins only without `ifVersion`.
- **Change feed:** every read of keys or a map has `changeSeq` (JS: `entries()` array's `changeSeq`). `read/?__map=todos&*=&__since=` (JS: `map("todos").changes({ since })`) answers `changes: [{ key, value, version, uid, modifiedt } | { key, deleted: true, version, deletedt }]` oldest first, `changeSeq` (ask from there next) and `more` (ask again at once). `__since=0` lists every value (no deletions). Deletions (also TTL expiry) are kept 30 days; an older `since` is `410 CHANGES_EXPIRED`: read the whole map again. `__limit` pages it. Keys and maps only. JS `map.watch((changes, { reset, changeSeq }) => ..., { since, interval })` polls it (every 10 s by default; no `since` or an expired one starts with every value and `reset: true`); returns `stop()`, and `logout()` stops it. In the browser, `changes()` also updates the offline copy of the map.
- Details: [Several Devices](https://prefsdb.com/docs/severaldevices/).
## Staying signed in: remember and resume
- `remember: true` (or `{ days, device }`, up to 90 days, default 30) on `getkey()`/`createkey()`/`getkeyWithIdToken()`/`signInWithGitHub()`/the sign-in add-on (HTTP: `remember=1&rememberdays=&device=` on getkey/createkey) returns `deviceGrant: { token, id, expires }`; the JS client keeps it (and the key) in localStorage.
- After a reload: `const db = await PrefsDB.resume(name) ?? await signIn();` (the getkey `name`, or the sign-in's, `"default"` if none). `null` when never remembered, expired, signed out or removed. Offline it still resolves (`db.resumedOffline`). HTTP: `POST getkey/resume/` with `grant=` in the form body (never the URL) answers like getkey; a revoked/expired grant is `401 DEVICE_GRANT_INVALID`. `PrefsDB.canResume(name)` (no request), `PrefsDB.forgetResume(name)`.
- `db.devices()` (`GET devices/`) lists `{ id, label, origin, created, lastUsed, expires, current }`; `db.revokeDevice(id)`/`db.revokeAllDevices()` (`POST devices/` `action=revoke&id=` / `revoke_all`) sign devices out at once. The key's review page has a Devices panel. `db.logout()` revokes and forgets this device's grant.
- Trade-off, state it to users: anyone who can read that browser's storage while the grant lasts can use the key. Offer it as an opt-in ("Keep me signed in on this device"), not on shared computers. Details: [Staying Signed In](https://prefsdb.com/docs/stayingsignedin/).
## Files
- Upload with `POST putobject/` (`multipart/form-data`, field `file`). The response has `file` (the id), `shareUrl` (downloads) and `shareViewUrl` (shows images, audio, video, PDF and plain text inline, so it can go straight into `
`). HTML, SVG and anything else always download as attachments.
- Fetch the file with `getobject/?file=`, passing the **same** `domain`/`subdomain`/`project`/`list` as on upload, or it returns 404. `getobject/?file=&url=1` returns the share links as JSON instead, without downloading anything (JS: `getObjectUrl(id)`).
- Uploading the same filename to the same scope overwrites the old file. The share link stays the same.
- `deleteobject/` is the only way to free the space. `delete` never touches files.
- The share links are public bearer URLs: anyone with the link can fetch the file, no key needed.
- Bucket: a dashboard key's files go in its account's shared bucket. An anonymous key has its own.
- JS client: `db.putObject(file)`, `db.getObject(id)`, `db.getObjectUrl(id)`, `db.deleteObject(id)`. See the API reference for the exact signatures.
## Limits (free plan)
Paid plans raise these; a response's `plan` field and the `storage` block show what applies. Treat the numbers as defaults that can change.
| Limit | Free |
|---|---|
| Requests per key | 120 a minute, 50,000 a day, 1,000,000 a month |
| Data transferred per key | 5 MB a minute, 100 MB a day, 2 GB a month |
| Value size | 1 MB (4 MB on Plus, 8 MB on Pro) |
| Key name length | 240 characters |
| Rows per read | 1,000 (and 8 MB of values); paginate with `__limit`/`__after` |
| File upload size | 12 MB |
| Storage per key or account | 50 MB |
| Dashboard keys per account | 25, with 20 new a day |
| Key lookups (`getkey`/`createkey`) | 20 a minute per IP |
| Channel poll | 1 to 200 messages |
For a dashboard account, all its keys share one request and transfer pool and one storage allowance. Writing anything past the storage limit fails with 405; nothing is deleted when a plan is downgraded.
## Client-side encryption (opt-in)
Off by default. The server holds an at-rest key for stored values and files unless the caller opts in: `await db.enableEncryption({ key })` (or `{ encryption: { key } }` on `getkey`/`createkey`) makes the JS client encrypt each value and uploaded file with AES-256-GCM before sending, so the server stores only ciphertext it cannot read. `PrefsDB.generateEncryptionKey()` makes a key; or give `{ passphrase, salt }`. A value is stored as `pdbe2:` plus base64 of `key id(4) | iv(12) | ciphertext | tag`, with the value's scope (domain, subdomain, project, list or map) as authenticated data, so a value only decrypts where it was written; a file as `PDBF2`, a header, then 64 KiB chunks each sealed with AES-GCM. Do this only when the user asked for it, and tell them the trade-offs:
- A lost key means lost data. Never send the key to PrefsDB, and never reuse the `getkey` seed as the passphrase.
- Key names, scope names, file names and sizes stay visible to the server.
- The dashboard shows ciphertext. `__filter` / `match()` cannot use `%`: only an exact value, with `deterministic: true`. Lists only de-duplicate in deterministic mode.
- Files get no share links or previews (`getObjectUrl()` is refused); use `getObject()`.
- A value that won't decrypt comes back as `DECRYPT_FAILED` (single read) or `null` with `decryptFailed` (bulk). Callbacks passed to `read()` get the still-encrypted response; use the Promise.
- Channels are not encrypted. Details: [Client-side encryption](https://prefsdb.com/docs/clientsideencryption/).
## Expiry (TTL)
Values never expire unless given a time-to-live in days: per value with `ttl=` on `write` (JS: `.ttl(days).write(value)`), or for every value already stored in a scope with `expire` (JS: `db.project("my-app").expire(30)`; scope levels left out match any value). `ttl`/`days` of 0 or less means "never expires" -- it does not delete anything; use `delete` for that. In the JS client `.ttl()` on its own sends nothing.
## Important: two ways to hold a key
- **Anonymous key** (minted by `getkey`/`createkey`): needs the short-lived session that call returns (60 seconds by default), renewed by calling `getkey` again with the same seed. The JS client renews it by itself. Not a good fit for a long-lived server-side credential.
- **Dashboard-issued key** (from the account dashboard's API Tokens panel): a plain, long-lived bearer token with no session requirement. A new **Live** key starts suspended (`403 KEY_SUSPENDED`) until it's activated in the dashboard; a key whose account is suspended gets `403 ACCOUNT_SUSPENDED`; a key deleted in the dashboard gets `400` like a key that doesn't exist. An account can have 25 keys at a time and create 20 a day on the free plan.
Generating code that calls PrefsDB from a server, cron job, or CLI script should use a dashboard-issued key, not a `getkey()`/`createkey()` key — the latter needs its seed to renew its session every minute.
**If you are an agent with no key:** don't ask the user to paste one into the chat. Pair instead: `POST https://prefsdb.com/api/v1/pair/start/` with JSON `{"name": " on "}` (optional: `"access": "read-only"`, `"project"` (default `agent-state`, `""` for none), `"days"`). Tell the user to open the returned `verification_uri` and type the `user_code` (show them separately). Then `POST /api/v1/pair/poll/` with `{"device_code": ...}` every `interval` seconds until `state` is `approved` (the response has `key`, once; store it securely, never print it), `denied` or `expired`. The key is a dashboard key limited to the project the user chose: send that `project` with every request. `GET /api/v1/pair/self/` tells what the key can do; `POST /api/v1/pair/revoke/` gives it up. See [Connect an AI agent](https://prefsdb.com/docs/agentpairing/). (Only where the server has pairing switched on; 404 otherwise.)
**MCP clients (Claude Code, Cursor, Claude Desktop):** instead of calling the HTTP API, the user can add the PrefsDB MCP server: `claude mcp add prefsdb -- npx -y @prefsdb/mcp`. It offers `prefsdb_get`/`prefsdb_set`/`prefsdb_delete`/`prefsdb_list_keys`/`prefsdb_append`/`prefsdb_read_list`/`prefsdb_status` and logs in by pairing (`prefsdb_login_start`, `prefsdb_login_check`). See [Use PrefsDB from an AI agent](https://prefsdb.com/docs/mcp/).
A site using anonymous keys can show its name and logo on PrefsDB's verification email and pages by publishing `https:///.well-known/prefsdb-site.json` with `{"name": "...", "logo": "https://.../logo.png"}` (PNG or JPEG, at most 100 KB) — see [Your site's name and logo](https://prefsdb.com/docs/branding/). No account needed.