# 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.