Backlit JavaScript SDK — window.backlit

The Backlit SDK is a small browser library that AI-generated static apps ("glows") use at runtime: read the signed-in user's identity, read and write glow-shared data, per-user data, and owner-scoped records, subscribe to realtime change events, and post write-only telemetry. This page is the public contract for everything window.backlit exposes.

This document is published as HTML at /reference and as plain Markdown at /sdk.md so AI agents can fetch and parse the surface without HTML scraping. A machine-readable TypeScript declaration of the whole surface is published at /sdk.d.ts (hosted alongside the bundle at sdk.backlit.run/sdk/v1/sdk.d.ts), and a routing index for agents at /llms.txt.

Plain text TypeScript types llms-full.txt

Quickref

Drop in the SDK, then call window.backlit.*. Every method is async and throws BacklitError on failure — except auth.me(), which resolves null instead. The full prose, semantics, and examples are below; this section is the ~80% an agent needs to write the calls.

<script src="https://sdk.backlit.run/sdk/v1/sdk.js"></script>

Machine-readable types: /sdk.d.ts.

Pick a store

Store Who reads / writes Use for
backlit.data every visitor reads one shared set; signed-in non-viewer users write (anonymous sessions are read-only) leaderboards, shared docs, published content
backlit.userdata each signed-in user, private isolated silo (requires sign-in) per-user settings, drafts, history
backlit.records any signed-in user creates + reads all; only the creator or an admin edits (requires sign-in) comments, forum posts, submissions, galleries
backlit.capture anyone writes; only an admin reads / lists / deletes form submissions, telemetry the app must not read back

Methods

backlit.version: string

backlit.auth.me(): Promise<BacklitUser | null>             // never throws; null when signed out
backlit.auth.users({ cursor?, limit? }?): Promise<{ items: BacklitUser[], next: string }>  // admin only; THROWS
backlit.auth.login(returnPath?): void                      // send the visitor to sign in (no-op off-host)
backlit.auth.logout(returnPath?): void                     // sign the visitor out (no-op off-host)

// data, userdata, and records share this shape. records.* additionally carries
// `owner` on get / list / getBatch / getBatchJSON / putIfMatch / updateJSON, and adds reassign().
backlit.data.get(key): Promise<{ value: Blob, contentType: string, crc32c: string }>
backlit.data.put(key, body, contentType): Promise<{ crc32c: string }>
backlit.data.putIfMatch(key, body, contentType, expectedCrc32c): Promise<PutIfMatchResult>
backlit.data.delete(key): Promise<void>
backlit.data.list(prefix?, { cursor?, limit? }?): Promise<{ items: Array<{ key, crc32c, contentType, size }>, next: string }>
backlit.data.getBatch(prefix?, { cursor?, limit? }?): Promise<{ items: Array<BatchEntry>, next: string }>       // list + each value; oversized entries arrive metadata-only (omitted:true)
backlit.data.getBatchJSON<T>(prefix?, { cursor?, limit? }?): Promise<{ items: Array<BatchJSONEntry<T>>, next: string }>  // getBatch, each value JSON.parsed
backlit.data.getJSON<T>(key): Promise<{ value: T, crc32c: string }>
backlit.data.putJSON(key, value): Promise<{ crc32c: string }>
backlit.data.putJSONIfMatch<T>(key, value, expectedCrc32c): Promise<PutJSONIfMatchResult<T>>
backlit.data.updateJSON<T>(key, mutate, options?): Promise<{ value: T, crc32c: string }>

backlit.records.reassign(key, newOwnerUid): Promise<void>  // admin only

backlit.capture.create(prefix, body, contentType): Promise<{ handle: string, crc32c: string }>
backlit.capture.update(handle, body, contentType): Promise<{ crc32c: string }>
backlit.capture.get(handle): Promise<{ value: Blob, contentType: string, crc32c: string }>  // admin only
backlit.capture.list(prefix?, { cursor?, limit? }?): Promise<{ items: Array<{ handle, crc32c, contentType, size }>, next: string }>  // admin only
backlit.capture.delete(handle): Promise<void>                                                 // admin only

backlit.on("storage.change", handler): () => void  // handler({ store, operation, key, crc32c, owner? })

backlit.push.supported(): { supported, reason?, requiresInstall, installed }   // SYNC; never throws
backlit.push.state(): Promise<{ permission, subscribed, endpointId }>          // requires sign-in
backlit.push.subscribe(): Promise<{ endpointId, permission: "granted" }>       // requires sign-in; prompts for permission
backlit.push.unsubscribe(): Promise<void>
backlit.push.test(): Promise<void>                                             // send yourself a test push
backlit.push.watch(cb): () => void   // cb({ type: "notification-click", url } | { type: "subscription-changed" } | { type: "permission-changed", permission })

backlit.hideUI(): void  // remove the Backlit button this SDK injects (paid plans only; no-op otherwise; if hidden, offer your own sign-out via auth.logout)

body is string | Blob | ArrayBuffer | ArrayBufferView (plain objects are rejected — use the *JSON helpers, or JSON.stringify + "application/json"). contentType is required. Keys/prefixes are [A-Za-z0-9._~-], 1–256 bytes (prefix may be empty); capture handles are the "{prefix}-{guid}" value create returned.

Shared writes — the one rule

For any data / records key more than one user can write, use updateJSON (load → mutate → compare-and-swap → rebase-and-retry), never a bare putJSON (an unconditional last-writer-wins overwrite that silently drops a concurrent edit). userdata has a single writer, so putJSON is fine there. Shared data writes need a signed-in non-viewer user — anonymous sessions can only read.

await backlit.data.updateJSON("todos", (todos) => {
  todos ??= { items: [] };
  todos.items.push({ text, done: false });
  return todos; // mutate MUST be replayable — it reruns against a fresher value on conflict
});

Error codes

Discriminate on err.code (err instanceof backlit.BacklitError). err.status is the HTTP status, or 0 for client-side/network failures.

code Meaning
not_found get / delete on a key that was never written
unauthenticated a shared data write, userdata.* / records.*, an admin-only capture op, or auth.users() with no user session
forbidden a viewer wrote shared data; a non-creator/non-admin wrote a records key; a non-admin called an admin-only op
invalid_key / invalid_prefix / invalid_handle argument failed the charset / length / handle-form rule
invalid_cursor a list (or auth.users()) cursor was not a valid pagination token
unknown_handle well-formed capture handle with no stored record
invalid_body getJSON read non-JSON bytes, or the server could not decode the body
invalid_argument bad body type, missing contentType, a non-serializable putJSON value, or a malformed reassign owner id
unsupported_content_type content-type off the allowlist (or an always-denied executable type)
payload_too_large body exceeds the per-object cap (10 MiB)
quota_exceeded a write exceeded the account's storage allowance (413), or any call landed after the monthly bandwidth allowance ran out (429)
conflict updateJSON exhausted its retries (maxAttempts, default 6) under contention; or a records delete/reassign lost a concurrent-write race (412) — retry
unsupported_host the SDK was loaded off a deployed glow host
unsupported / requires_install / permission_denied push.subscribe() — the browser can't do push, needs the app installed first (iOS), or the user declined the permission prompt
cross_glow_token · glow_not_found · glow_dark · region_mismatch · invalid_host · method_not_allowed · network · internal transport / glow-state failures

A putIfMatch mismatch is not an error — it resolves { matched: false, witness } carrying the current value.

Deploy bundle contract

Every deploy bundle must contain index.html, APP.md (what the app is, who it's for), and DATA.md (how it uses the data namespaces) at the archive root, or the upload is rejected with missing_required_files; .html/.htm/.js/.mjs files that reference localStorage or indexedDB are rejected with browser_storage_forbidden — use window.backlit.* instead. APP.md and DATA.md are served publicly: no secrets, credentials, or PII. An optional root notify.json turns on push notifications for the glow (see backlit.push); without one, push is simply off.

Loading

The SDK is a single UMD bundle hosted on the Backlit CDN. Drop one <script> tag in your HTML; no build step required.

<!doctype html>
<script src="https://sdk.backlit.run/sdk/v1/sdk.js"></script>
<script>
  (async () => {
    const user = await window.backlit.auth.me();
    // ...
  })();
</script>

(The async IIFE matters: classic <script> blocks can't use top-level await — that's a SyntaxError outside module scripts.)

Two URL channels are supported:

URL Stability Use for
https://sdk.backlit.run/sdk/v1/sdk.js Stable Production glows. Pinned to the v1 surface.
https://sdk.backlit.run/sdk/latest/sdk.js Rolling Quick experiments. Aliases the newest v1.

The bundle is an IIFE that attaches a single global, window.backlit. It takes no constructor — the namespaces below are ready to use the moment the script tag finishes parsing.

window.backlit.version is a string identifying the bundled SDK version (e.g. "1.0.0").

The SDK runs only on a deployed glow host (a *.backlit.run subdomain, or a recognised dev host — *.backlitdev.run, *.localtest.me, localhost, *.localhost). Loaded anywhere else — straight from file://, an AI design tool's preview iframe, or any other origin — every data.* / userdata.* / records.* / capture.* call (and auth.users()) rejects with BacklitError(code: "unsupported_host"); only auth.me() resolves null rather than throws. There is no local/offline fallback — deploy the glow to exercise the data surface.

Storing app state

Most glows keep some state — a list, a document, user preferences. Backlit gives you three key/value stores; pick by who owns the data:

Store Who can read & write Use for
backlit.data every visitor reads (one shared set); signed-in non-viewer users write leaderboards, shared docs, published content
backlit.userdata each signed-in user, isolated (requires sign-in) per-user settings, drafts, history
backlit.records any signed-in user creates + reads all; only the creator/admin edits (requires sign-in) forum posts, comments, user submissions, galleries

All three stores hold bytes, but the *JSON helpers serialize and parse objects for you — so most apps never touch the raw byte API:

// Save an object. Returns its { crc32c } — an integrity/version hash.
await backlit.data.putJSON("todos", { items: [], updatedAt: 0 });

// Load it back. `value` is the parsed object; `crc32c` is its current hash.
const { value: todos, crc32c } = await backlit.data.getJSON("todos");

The first read of a key — before anything is written — throws not_found. Catch it and start from a default:

let todos;
try {
  todos = (await backlit.userdata.getJSON("todos")).value;
} catch (e) {
  if (e.code !== "not_found") throw e;
  todos = { items: [] }; // first run
}

Saving shared state safely

putJSON is an unconditional overwrite — last writer wins. That is fine for userdata (you are the only writer) and for any data key only one user ever writes. But when two users can edit the same data key, a plain putJSON silently drops one of their changes.

For shared, multi-writer state use updateJSON instead. It loads the current value, runs your merge function, and commits with a compare-and-swap; if another session wrote in between, it rebases onto the latest value and retries — so no edit is ever lost, and you never write a loop yourself:

// `mutate(current)` receives the current value (undefined on the first write)
// and returns the new value. It may run more than once — it replays against a
// fresher value on conflict — so keep it a pure function of its input (no side
// effects).
await backlit.data.updateJSON("todos", (todos) => {
  todos ??= { items: [] };
  todos.items.push({ text, done: false });
  return todos;
});

updateJSON resolves the committed { value, crc32c }. It retries up to maxAttempts (default 6) and throws BacklitError(code: "conflict") only if it can't win the race in that many attempts — a sign of extreme contention. It's built on putJSONIfMatch; reach for that directly only when you want create-only semantics or to inspect the conflicting value yourself.

Rule of thumb: putJSON when last-writer-wins is genuinely fine (single writer, or idempotent content); updateJSON for anything several people edit at once. Full method docs are under backlit.data.

Migrations

When porting an existing standalone HTML app to a glow, replace browser- local persistence (localStorage, sessionStorage, raw indexedDB) with the equivalent Backlit surface. The mapping is straightforward once you decide who the data belongs to:

Browser API Backlit equivalent When to pick it
localStorage / sessionStorage (per-user) backlit.userdata.* Settings, drafts, history — anything scoped to this signed-in user. Requires sign-in (a Private or Accounts glow).
localStorage (app-global state) backlit.data.* Content that every visitor should see: a wiki page, a leaderboard, a published gallery. Writes need a signed-in non-viewer user.
IndexedDB keyed records backlit.data.* / userdata.* / records.* Same split as above. Backlit's keyspace is flat — collapse object-store + key into one string; one entry per author that others can read but not edit → records.*.
Hidden form posts / fetch("/submit") backlit.capture.create Contact forms, telemetry events, support requests — write-only captures the page can't read back.
File uploads to a separate server backlit.data.put (binary) Accepts Blob / ArrayBuffer / typed arrays with a matching raster content type (image/png, image/jpeg, image/gif, image/webp).

Three differences will trip you up if you don't plan for them:

  1. Everything is async. localStorage.getItem is synchronous; backlit.data.get returns a Promise. Wrap reads in await and move any work that depended on synchronous storage into an async initialiser.
  2. Objects use the *JSON helpers. localStorage stores strings; for object state use putJSON / getJSON, which serialize and parse for you. (The underlying put / get take raw string | Blob | ArrayBuffer | ArrayBufferView with an explicit contentType — that's the path for binary like images.)
  3. Keys are restricted. [A-Za-z0-9._~-] only, no slashes. If you used localStorage.setItem("user/42/profile", …) translate to user.42.profile (or any other slash-free scheme).

A typical port — the localStorage version on the left, the Backlit version on the right:

// before
const prefs = JSON.parse(localStorage.getItem("prefs") ?? "{}");
prefs.theme = "dark";
localStorage.setItem("prefs", JSON.stringify(prefs));

// after
let prefs = { theme: "light" };
try {
  prefs = (await backlit.userdata.getJSON("prefs")).value;
} catch (e) {
  if (e.code !== "not_found") throw e;
}
prefs.theme = "dark";
await backlit.userdata.putJSON("prefs", prefs);

⚠️ This load → mutate → save is safe here only because userdata has a single writer — you. The identical shape on shared backlit.data.* is a lost-update bug. For any shared key more than one session can write, use updateJSON instead of putJSON.

When you need a key gone, call backlit.data.delete(key) or backlit.userdata.delete(key) — see the delete sections below. Reach for backlit.capture.create whenever the page must capture something — a contact form, an event — but ordinary app code should never be able to read it back even if the page's JavaScript is compromised. (An admin user can later read, enumerate, and delete those captures with backlit.capture.{get,list,delete}; non-admin sessions cannot.)

Offer advanced features that fit the app

A faithful port reproduces what the original app already did — but moving onto Backlit unlocks capabilities a plain static page never had, and the person you're building for usually doesn't know to ask for them. If you're an AI agent migrating an app, read what it does and proactively offer the matching upgrades below before you deploy. Describe the benefit in plain terms, suggest only what genuinely fits the app, and wire one in only if the user says yes.

If the app… Offer Backlit surface
shows shared state several people view at once — leaderboard, kanban board, presence, a collaborative doc Realtime sync + safe writes — live updates across tabs and users (no polling), and updateJSON so concurrent edits merge instead of clobbering. Realtime makes a conflict visible; updateJSON makes it impossible to lose. backlit.on("storage.change", …) + data.updateJSON
lets people post content others see but shouldn't edit on each other's behalf — comments, chat, forum threads, reviews, profiles, a shared gallery, contest entries Owner-scoped records — anyone signed in can post and read everyone's, but only each item's author (or an admin) can edit or delete it, so users can't tamper with one another's content. Emits realtime events and has updateJSON, just like data. backlit.records.* (requires sign-in)
keeps per-user settings, drafts, or history in one global blob Per-user data — each signed-in user gets an isolated silo backlit.userdata.* (requires sign-in)
collects form submissions, contact requests, or telemetry Private capture + an admin review page — visitors write submissions the page can't read back; an admin page lists and reads them backlit.capture.create to capture; capture.list/get/delete behind an admin
shows or hides controls by role — an editor vs. a read-only viewer Permission-aware UI — branch on the signed-in user's role backlit.auth.me() (the permission field)
needs sign-in, or should be limited to specific people or domains Private or Accounts auth mode — Private restricts sign-in to an allowlist; Accounts lets any visitor create an account themselves; leave it Public for an open app the glow's auth_mode setting
lets users upload or attach images Binary storage — store images directly, no separate file server backlit.data.put(key, blob, "image/png")

Don't bolt realtime onto a single-user notepad or force sign-in on a public toy — suggest only what the app's purpose calls for. When the user opts in, the relevant section of this reference has the full signatures.

Packaging the deploy bundle

A glow is deployed as a single bundle — a gzipped tar (.tar.gz) of its built static assets, posted as the archive field of a multipart/form-data request (see the glow deploy API's OpenAPI at glow.backlit.run/openapi.yaml for the direct and ticket-based upload endpoints). A few rules for what to put inside.

Required files at the bundle root. Every deploy bundle must contain three files at the top level of the archive (exact names, exact case):

A bundle missing any of them is rejected with missing_required_files (HTTP 400); the message lists every missing name — add the file(s) and re-upload. A short skeleton is enough:

<!-- APP.md -->
# Trail Crew Scheduler

A weekend roster for the volunteer trail-maintenance crew: members
claim shifts, see who else is coming, and leave notes for the lead.

Built for the crew's ~20 members; the crew lead administers it.
<!-- DATA.md -->
# Data layout

| Namespace | Keys / prefixes | Meaning |
| --- | --- | --- |
| `data` | `roster` | the shared shift roster (JSON, written via `updateJSON`) |
| `userdata` | `prefs` | each member's notification preferences |
| `capture` | `signup-…` | new-member signup submissions (admin reads them back) |

APP.md and DATA.md are public. They are served like any other asset (e.g. https://{name}.backlit.run/APP.md) — they are public documentation files; never put secrets, credentials, or PII in them.

Optional notify.json. A bundle may additionally carry a notify.json at the archive root to turn on push notifications for the glow — its rules declare which data writes send a notification to whom (see the notify.json schema for every field, and the push guide for the end-to-end story). A bundle without one deploys with push off; an invalid one is rejected at upload.

No browser storage. At upload, the content of every .html, .htm, .js, and .mjs file in the bundle is scanned for browser-storage usage; any hit rejects the deploy with browser_storage_forbidden (HTTP 400), and the message lists the offending files and the first matched token in each. The platform's published scan list is these case-sensitive substrings, for each of localStorage and indexedDB:

localStorage. · localStorage[ · .localStorage · =localStorage · = localStorage · indexedDB. · indexedDB[ · .indexedDB · =indexedDB · = indexedDB

Comments and string literals count — the scan is a best-effort heuristic that steers apps toward the window.backlit data APIs, not a security boundary. On a reject, replace the storage calls with backlit.data.* / backlit.userdata.* (see Migrations above) and delete the replaced code — don't comment it out.

Path rules. Bundle paths must be forward-slash-relative — absolute paths, parent traversal (..), NUL bytes, and backslashes are rejected by the unpacker.

Split inline data out of the HTML. AI generators often emit a single self-contained HTML file with JSON state and base64 images embedded. Once that page runs on a real glow, the habit costs you cache hits: the HTML is fetched on every navigation, while sibling files at /_v/{ver}/... carry Cache-Control: public, max-age=31536000, immutable (private on Private glows) and become free on repeat visits. Two refactors are worth doing before packing:

Anything under ~1 KiB is fine inline — the round-trip to fetch a separate file dwarfs the parse cost.

Building the bundle. From the directory containing your built site:

tar -czf site.tar.gz -C ./dist .

The trailing . matters: it produces entries like index.html rather than dist/index.html, which is what the server expects at the bundle root.

Deploy with an agent

You don't have to build and upload the bundle by hand. Backlit runs a hosted MCP server, so an AI assistant — Claude, ChatGPT, or any client that speaks the Model Context Protocol — can create the glow, deploy your files as a draft, hand you a preview URL, and promote it live, all from a chat.

Connect it once, then just ask ("deploy this to Backlit and give my team access"):

The deploy tools move the bundle out of band rather than over the MCP connection, and there are two transports — pick by what your agent's sandbox can reach:

So if your agent runs in a sandbox with a domain allow-list, add the host for whichever transport it uses (github.com or glow.backlit.run) to its allowed/permitted domains, or the deploy is blocked.

The agent acts as you (no API key to copy around), and every deploy lands as a draft you preview before promoting — so nothing goes live without your go-ahead.

Public, Private, and Accounts glows

What works for a given visitor depends on the glow's auth_modePublic (serves anonymously; sign-in is optional, and when the app offers it only allowlisted users — e.g. an admin — are let in), Private (only allowlisted users may sign in, and they must), or Accounts (serves anonymously like Public, but any visitor can create an account and sign in themselves) — and whether the visitor has signed in. A visitor with no signed-in user — a signed-out Public or Accounts visitor — is an anonymous session.

Surface Public glow / Accounts glow, signed out (anonymous) Private or Accounts glow, signed in Private glow, signed out
backlit.auth.me() returns null returns { uid, name, email, mobile, permission } (permissionadmin/user/viewer) n/a — the visitor never reaches the glow; Backlit redirects them to sign in
backlit.auth.users() throws unauthenticated works only for admin (else forbidden) n/a
backlit.auth.login() navigates to sign-in — Accounts admits any visitor (self-serve); a Public glow admits only allowlisted users n/a — already signed in n/a
backlit.data.* (reads) works works n/a
backlit.data.put/delete/putIfMatch throw unauthenticated — anonymous sessions cannot write shared data work (blocked with forbidden for viewer) n/a
backlit.userdata.* throws unauthenticated works (per-user silo) n/a
backlit.records.* (reads) throws unauthenticated works (read every record) n/a
backlit.records.put/delete throws unauthenticated works on your own records (others' → forbidden) n/a
backlit.records.reassign throws unauthenticated works only for admin (else forbidden) n/a
backlit.capture.create/update works works n/a
backlit.capture.get/list/delete throw unauthenticated (no admin) work only for admin (else forbidden) n/a

Signed-out Private visitors never execute JavaScript on the glow — Backlit intercepts the request and redirects them to sign in. So inside a glow page, "signed out on a Private glow" is not a state your code has to handle. An Accounts glow is different: it serves anonymously, so your code DOES run for signed-out visitors there — branch on auth.me() returning null and offer auth.login().

One Accounts-mode trap to design around: a self-serve account is created with permission viewer, and viewers cannot write shared data — so signing in does not, by itself, grant shared-data write access. The glow's owner promotes accounts to user / admin in the Backlit console. Apps on Accounts glows should treat shared-data writes as a privileged action (hide or disable the control unless me.permission === "user" || me.permission === "admin"), or use records.* — where any signed-in user, including a viewer, can create their own entries.


backlit.auth

auth.me(): Promise<BacklitUser | null>

Returns the signed-in user, or null when there is no user session — an anonymous or signed-out visitor and the SDK loaded off a deployed glow host both resolve to null. Never throws: network failures and missing sessions both resolve to null (off-host it resolves to null too, rather than throwing unsupported_host), since they all render the same anonymous fallback UI in practice.

BacklitUser is a plain object with five string fields:

Field Notes
uid The user's stable id — survives an email change; keys their userdata silo.
name Display name, or "" if unset.
email The user's verified email, or "" if they signed in without one.
mobile Mobile number, or "" if unset.
permission Permission level — "admin", "user", "viewer", or "" (legacy/unknown).

Every field is a string; absent values are "" (never null or undefined). The whole result is null only when there is no signed-in user. name, email, and mobile are user-controlled display strings — escape them before inserting into HTML.

"viewer" users are blocked from writing shared data: data.put and data.delete reject with BacklitError(code: "forbidden") (HTTP 403). Their userdata.* and capture.* writes are unaffected. The server is the source of truth — permission exists so apps can branch ahead of a write, e.g. hiding a Save button.

You stay signed in across visits, and while a page is open it is kept signed in and your access level stays up to date automatically — no app code required.

const user = await backlit.auth.me();
if (user === null) {
  // Show the anonymous experience.
} else {
  greeting.textContent = `Hello, ${user.name || user.email}`;
  saveButton.disabled = user.permission === "viewer"; // the server would 403 a data.put anyway
}

auth.users(opts?: { cursor?: string, limit?: number }): Promise<{ items: BacklitUser[], next: string }>

Lists this glow's users. Each entry is the same BacklitUser object auth.me() returns ({ uid, name, email, mobile, permission }). Admin only and read-only — it's an operator view, not an editing surface.

The result is paginated: it returns one bounded page of BacklitUser entries plus an opaque next cursor. To walk the whole roster, re-call users with { cursor: next } until next is "" (the empty string means the roster is exhausted).

Unlike auth.me(), this method throws BacklitError rather than resolving to a fallback:

code When
forbidden The signed-in user's permission is not "admin".
unauthenticated No signed-in user (an anonymous or signed-out visitor).
invalid_cursor opts.cursor is not a valid pagination token.
unsupported_host The SDK was loaded outside a deployed glow host.
network The request could not be completed.

Each name, email, and mobile is a user-controlled display string — escape it before inserting into HTML.

// Render an admin-only roster of the glow's users, one page at a time.
try {
  let cursor = "";
  do {
    const page = await backlit.auth.users({ cursor });
    for (const u of page.items) {
      const row = document.createElement("li");
      row.textContent = `${u.name || u.email} — ${u.permission}`;
      roster.appendChild(row);
    }
    cursor = page.next;
  } while (cursor);
} catch (err) {
  if (err.code === "forbidden") {
    // Not an admin — hide the roster.
  } else {
    throw err;
  }
}

auth.login(returnPath?: string): void / auth.logout(returnPath?: string): void

Send the visitor to sign in, or sign them out. Both are top-level navigations — the page unloads — so they return void (not a Promise) and nothing runs after them. returnPath is where the visitor lands afterwards: an absolute same-origin path (e.g. /dashboard); it defaults to the current page, and anything that isn't a plain same-origin path is replaced with /. Off a deployed glow host both are a silent no-op.

Use login() to add a "Sign in" button on a glow whose auth mode is Accounts (open self-serve sign-in — signed-out visitors can create an account) — or on a Public glow to sign in an allowlisted user for an admin-only view (only allowlisted users are let in; the general public is not, and the built-in Backlit button never shows a Login option on a Public glow, so this is app-driven). Use logout() for a "Sign out" button. After login() completes, auth.me() returns the signed-in user and userdata.* works. (A fresh self-serve account starts as a viewer, so shared data writes stay blocked until the owner promotes it — see the auth-mode matrix above.)

// A sign-in / sign-out button that reflects the current session.
const user = await backlit.auth.me();
if (user) {
  signOutBtn.onclick = () => backlit.auth.logout();      // returns to this page
} else {
  signInBtn.onclick = () => backlit.auth.login("/welcome");
}

The Backlit button (and backlit.hideUI())

The SDK adds a small Backlit button to the bottom-right corner of your glow. A visitor can open it to sign in or out (when the glow offers accounts) and to discover Backlit. You don't have to add or configure anything — it appears automatically.

backlit.hideUI() removes it. Removing the button is a paid feature: on a free glow the call is a silent no-op (the button stays); on a paid glow it is removed. It never throws and is safe to call as early as you like.

backlit.hideUI(); // paid glows: removes the button. free glows: no-op.

There is also a per-glow setting in the Backlit console that removes the button without any code (also paid). Either one is enough.

The button is also where a signed-in visitor signs out. If you remove it — by either route — you're encouraged to give visitors your own "Sign out" control that calls backlit.auth.logout().


backlit.data — glow-shared key/value

Readable by any visitor with a valid glow session — every visitor of a Public or Accounts glow, and every signed-in visitor of a Private glow. Writes require a signed-in user: put / delete / putIfMatch (and the *JSON write wrappers) throw unauthenticated for an anonymous session (a signed-out Public or Accounts visitor) and forbidden for a signed-in viewer. There is one shared keyspace per glow, and more than one user can write the same key at the same time.

For any shared key more than one session can write, updateJSON is the default — it runs a load-modify-commit loop that rebases and retries on a concurrent write, so nothing is ever silently lost. (It's built on the putIfMatch compare-and-swap primitive below — reach for that directly only for create-only locks or a custom merge.) Plain data.put is an unconditional, last-writer-wins overwrite; reserve it for a single writer or genuinely idempotent content. This is the one habit that does not survive the jump from userdata (one writer per silo) to shared data.

Working with objects? The *JSON helpers (getJSON / putJSON / putJSONIfMatch / updateJSON) wrap the byte methods below with serialize/parse — most apps use those. The raw get / put below are for strings and binary (e.g. images).

Sizes and integrity. Your storage allowance and the 10 MiB per-object cap both measure the data you pass, so the limit you hit is the size of your own bytes. Every put/create/update response carries { crc32c } and every get / list entry carries the body's CRC32C — the same value the storage.change event broadcasts on writes, so app code can dedup local state against server state without an extra fetch. Each list entry also carries the stored contentType and the body's size in bytes, so a listing alone tells you each value's type and size without a per-key get. A get or list against a server that omits the CRC (a legacy object, or a proxy that stripped the non-standard X-Backlit-CRC32C header) tolerates the gap and returns crc32c: "" rather than failing the read; only put / create / update, where the CRC is the whole success payload, treat a missing value as a protocol error.

Debounce frequent writes. Each put / putIfMatch / updateJSON is a network round-trip that counts toward your glow's monthly request and bandwidth allowance, and every successful write broadcasts a storage.change event to every connected session. Don't write on every keystroke, drag, or animation frame — coalesce rapid edits (debounce ~250–500 ms, or save on blur / pause) and send one write when the value settles. This keeps you well under your allowance and stops connected peers from being flooded with intermediate updates. The same applies to userdata.* and records.*.

Writing identical content is a no-op. A write that stores exactly what the key already holds — the same bytes with the same contentType — succeeds normally and returns the same { crc32c }, but nothing is observed to change: it emits no storage.change event, so open peer sessions don't hear it and no notification rule fires. This applies to overwrites in every store (userdata, records, and capture.update included, and the *JSON wrappers); a first write to a key is never a no-op, and changing only the contentType is a real write. If a repeat of the same value must still be observed — for example a key written purely to trigger a notification — include something that varies in the payload, like a timestamp or counter.

data.get(key: string): Promise<{ value: Blob, contentType: string, crc32c: string }>

Read the value stored at key. Returns the raw bytes as a Blob, the Content-Type the value was written with, and the Castagnoli CRC32C of the body (base64-encoded big-endian uint32 — same format as the storage.change event).

try {
  const { value, contentType, crc32c } = await backlit.data.get("greeting");
  console.log(await value.text(), contentType, crc32c);
  // → "hello world" "text/plain" "+IxOLg=="
} catch (e) {
  if (e.code === "not_found") {
    // First-run path.
  } else {
    throw e;
  }
}

data.put(key: string, body: BodyInit, contentType: string): Promise<{ crc32c: string }>

Write body to key under the glow-shared keyspace.

⚠️ put is an unconditional overwrite — last writer wins. Read-modify-write on a shared key loses any write that landed in between, worst with the one-big-JSON-blob state AI generators emit, where every edit rewrites the whole blob so even edits to unrelated records clobber each other. Reach for bare put only when last-writer-wins is genuinely fine — a single writer, or idempotent content. For any shared mutable state, updateJSON is the default (or the putIfMatch primitive for non-JSON bodies).

// String.
const { crc32c } = await backlit.data.put("greeting", "hello world", "text/plain");

// JSON — the byte API takes a serialized string; prefer putJSON for objects.
await backlit.data.put(
  "config",
  JSON.stringify({ theme: "dark" }),
  "application/json",
);

// Binary — pass the bytes with the matching image content-type.
const png = await fetch("/icon.png").then(r => r.arrayBuffer());
await backlit.data.put("icon", png, "image/png");

If you pass a Blob whose .type disagrees with the explicit contentType argument, the explicit argument wins and the SDK emits one console.warn per page.

data.putIfMatch(key: string, body: BodyInit, contentType: string, expectedCrc32c: string): Promise<PutIfMatchResult>

Atomic compare-and-swap, and the default way to write any shared key more than one session can touch — bare data.put is the exception, for a single writer or idempotent content. Writes body to key only if the current stored value's crc32c equals expectedCrc32c, otherwise resolves { matched: false, witness } carrying the current value — so two callers racing on the same key can detect the conflict and merge instead of silently clobbering each other (see data.put's last-writer-wins note).

The result is a discriminated union:

type StoredValue = { value: Blob; contentType: string; crc32c: string };
type PutIfMatchResult =
  | { matched: true;  crc32c: string }        // your write landed; crc32c of the new body
  | { matched: false; witness: StoredValue }; // someone else's value is there now
// Create-only: claim a key iff nobody else has it yet.
const r = await backlit.data.putIfMatch("lock", "held", "text/plain", "");
if (!r.matched) {
  // Already taken — r.witness.value is the current holder's bytes.
}

For JSON state — the common case — use data.putJSONIfMatch, which wraps this method with serialize/parse and an already-parsed witness, or data.updateJSON, which runs the whole load-mutate-commit-retry loop for you.

The check-and-set is one atomic operation, so there is no read-then-write window to lose data in — the only failure mode left is contention (too many conflicting writers), which the retry cap surfaces honestly rather than hides. The one constraint putIfMatch imposes is that your merge must be replayable: on a conflict it runs again against the witness's fresher state, so write it as a pure rebase ("apply my change to this base"), never baking in "apply my one edit" assumptions that break on the second pass.

Storing a whole dataset under one key is correct under putIfMatch, just higher-contention — every edit competes for that one key's crc32c. If independent records start colliding, split them into per-record keys (students.{id}) so unrelated edits never touch the same key and retries approach zero. A small team can keep one blob; a busy multi-writer board should split. It's a contention optimization, not a correctness requirement.

data.getJSON / data.putJSON / data.putJSONIfMatch

backlit.data.getJSON<T = unknown>(key: string): Promise<{ value: T, crc32c: string }>
backlit.data.putJSON(key: string, value: unknown): Promise<{ crc32c: string }>
backlit.data.putJSONIfMatch<T = unknown>(key: string, value: unknown, expectedCrc32c: string): Promise<PutJSONIfMatchResult<T>>
backlit.data.updateJSON<T = unknown>(key: string, mutate: (current: T | undefined) => T, options?: { maxAttempts?: number }): Promise<{ value: T, crc32c: string }>

Convenience wrappers over get / put / putIfMatch that JSON-serialize on write and parse on read, always using application/json. Keys, caps, errors, and compare-and-swap semantics are identical to the byte methods — only the body shape changes. The differences worth knowing:

type PutJSONIfMatchResult<T> =
  | { matched: true;  crc32c: string }
  | { matched: false; witness: { value: T; crc32c: string } };
// Single-writer save/load (userdata, or a data key only you write).
await backlit.data.putJSON("config", { theme: "dark" });
const { value: config } = await backlit.data.getJSON("config");

The canonical multi-writer retry loop is under Saving shared state safely.

data.delete(key: string): Promise<void>

Remove the value stored at key. The deletion is observable via backlit.on("storage.change") under {store:"data", operation:"delete"}.

try {
  await backlit.data.delete("greeting");
} catch (e) {
  if (e.code !== "not_found") throw e;
}

data.list(prefix?: string, opts?: { cursor?: string, limit?: number }): Promise<{ items: Array<{ key: string, crc32c: string, contentType: string, size: number }>, next: string }>

List the keys in the glow-shared keyspace whose names start with prefix. The empty prefix (or omitting the argument) lists everything.

Each entry carries the body's CRC32C, the stored contentType, and the body's size in bytes, so apps can dedup against locally-cached state and learn each value's type and size in one round trip — no per-key get needed just to discover what changed.

The result is paginated: it returns one bounded page of entries plus an opaque next cursor. To walk the whole listing, re-call list with { cursor: next } until next is "" (the empty string means the listing is exhausted). Entries come back in ascending key order, and that same order is stable across pages.

// Walk every "posts." key, one page at a time.
const stale = [];
let cursor = "";
do {
  const page = await backlit.data.list("posts.", { cursor, limit: 100 });
  for (const e of page.items) {
    if (e.crc32c !== cache.get(e.key)) stale.push(e);
  }
  cursor = page.next;
} while (cursor);
// page.items entries look like:
// { key: "posts.0001", crc32c: "+IxOLg==", contentType: "application/json", size: 128 }

data.getBatch(prefix?: string, opts?: { cursor?: string, limit?: number }): Promise<{ items: Array<BatchEntry>, next: string }>

Like list, but each page entry carries the value — so a whole page of small keys comes back in one round trip instead of a list followed by a get per key. Same inputs, same ascending-key order, same { items, next } pagination.

Each entry is the list metadata (key, crc32c, contentType, size) plus one of two arms — discriminate on omitted, exactly as you narrow a putIfMatch result on matched (both arms are normal outcomes, not errors):

A page's included entries stop when their combined stored-data size reaches the per-response budget — default 10 MiB, the same measure as the per-object cap (the size of the data you stored, not of the response payload). So a page can hold fewer entries than limit asked for, and end before the listing is exhausted. next is the sole completion signal: walk until next is "", never stop because a page came back short. (Every page makes progress — it always includes or omits at least one entry, so the walk can't stall.)

// Load every "posts." value in bulk, page by page.
const posts = new Map();
let cursor = "";
do {
  const page = await backlit.data.getBatch("posts.", { cursor, limit: 200 });
  for (const e of page.items) {
    if (e.omitted) {
      // Too big for a batch page — fetch it on its own.
      if (e.reason === "too_large") posts.set(e.key, await backlit.data.get(e.key));
      continue;
    }
    posts.set(e.key, e.value); // e.value is a Blob
  }
  cursor = page.next;
} while (cursor); // next === "" means done

data.getBatchJSON<T>(prefix?: string, opts?: { cursor?: string, limit?: number }): Promise<{ items: Array<BatchJSONEntry<T>>, next: string }>

The getBatch sibling that JSON-parses each included value for you — the bulk companion to getJSON, the way getBatch is the bulk companion to get. Same inputs, same pagination, same budget and omitted discrimination; only the value shape differs.

Per included entry, the value is the parsed T (parsing is content-type-agnostic — every included value is JSON.parsed, matching getJSON). A JSON null (or 0 / "" / false) is a legal parsed value — the discriminant is omitted, never value-nullness. A value that is not valid JSON degrades that one entry to the metadata-only arm with reason: "not_json" (the raw bytes are not carried; the rest of the page is unaffected), so you can spot a key written via raw put without a failed getJSON. A server-omitted entry passes through with its own reason (e.g. "too_large"). Each entry still carries crc32c, so a value you read here feeds straight into putJSONIfMatch — the bulk way to seed an updateJSON retry loop with values you already have.

// Read a page of JSON posts; a non-JSON key degrades to omitted, not a throw.
const page = await backlit.data.getBatchJSON("posts.");
for (const e of page.items) {
  if (e.omitted) continue;      // too_large, or not_json
  render(e.key, e.value);       // e.value is the parsed object
}

backlit.userdata — per-user data silo

Same shape as backlit.data, but each signed-in user has their own isolated silo. Two users on the same glow cannot see each other's userdata.

Available to signed-in users only — a Private glow, an Accounts glow after the visitor signs in, or a Public glow's allowlisted user after they sign in. With no user session (an anonymous or signed-out visitor), every method throws BacklitError with code === "unauthenticated" and an actionable message. Call backlit.auth.me() first if you need to branch.

userdata.get(key: string): Promise<{ value: Blob, contentType: string, crc32c: string }>

userdata.put(key: string, body: BodyInit, contentType: string): Promise<{ crc32c: string }>

userdata.delete(key: string): Promise<void>

userdata.list(prefix?: string, opts?: { cursor?: string, limit?: number }): Promise<{ items: Array<{ key: string, crc32c: string, contentType: string, size: number }>, next: string }>

userdata.getBatch(prefix?, opts?) / userdata.getBatchJSON<T>(prefix?, opts?)

userdata.putIfMatch(key: string, body: BodyInit, contentType: string, expectedCrc32c: string): Promise<PutIfMatchResult>

userdata.getJSON<T>(key: string): Promise<{ value: T, crc32c: string }>

userdata.putJSON(key: string, value: unknown): Promise<{ crc32c: string }>

userdata.putJSONIfMatch<T>(key: string, value: unknown, expectedCrc32c: string): Promise<PutJSONIfMatchResult<T>>

userdata.updateJSON<T>(key: string, mutate: (current: T | undefined) => T, options?): Promise<{ value: T, crc32c: string }>

Signatures, parameters, return types, validation rules, and the *JSON convenience wrappers are identical to the backlit.data.* methods of the same name. The only behavioural difference is the unauthenticated rewrite above.

userdata.delete removes a key from the calling user's silo — never from another user's. Successful deletes emit a privacy-scoped storage.change event (store: "userdata") that only the writing user's own sessions receive.

const user = await backlit.auth.me();
if (user === null) {
  // userdata not available — fall back to glow-shared.
  return;
}

await backlit.userdata.putJSON("preferences", { theme: "dark" });

const { value: prefs } = await backlit.userdata.getJSON("preferences");

backlit.records — public-read, owner-write store

A third key/value store, between backlit.data (everyone reads and writes one shared set) and backlit.userdata (each user's private silo). With backlit.records, any signed-in user creates records and reads everyone's, but only the record's creator — or an admin — may overwrite or delete it. It's the store for user-generated content many people see but shouldn't be able to edit on each other's behalf: forum posts, comments, profiles, submitted entries, a shared gallery where each image belongs to whoever uploaded it.

Available to signed-in users only — a Private glow, an Accounts glow after the visitor signs in, or a Public glow's signed-in user. With no user session every method throws BacklitError with code === "unauthenticated", exactly like backlit.userdata. The store is free — no subscription required. (Unlike shared data, a viewer CAN create records — so on an Accounts glow, records is the store self-serve users can write to out of the box.)

records.get(key: string): Promise<{ value: Blob, contentType: string, crc32c: string, owner: string }>

records.put(key: string, body: BodyInit, contentType: string): Promise<{ crc32c: string }>

records.delete(key: string): Promise<void>

records.list(prefix?: string, opts?: { cursor?: string, limit?: number }): Promise<{ items: Array<{ key: string, crc32c: string, contentType: string, size: number, owner: string }>, next: string }>

records.getBatch(prefix?, opts?) / records.getBatchJSON<T>(prefix?, opts?)

records.putIfMatch(key, body, contentType, expectedCrc32c): Promise<{ matched: true, crc32c, owner } | { matched: false, witness: { value, contentType, crc32c, owner } }>

records.getJSON<T>(key) / records.putJSON(key, value) / records.putJSONIfMatch<T>(key, value, expectedCrc32c) / records.updateJSON<T>(key, mutate, options?)

Signatures, parameters, validation rules, and the *JSON convenience wrappers match the backlit.data.* methods of the same name, with these differences:

// Anyone signed in posts a comment under their own key.
await backlit.records.putJSON(`comment.${Date.now()}`, { text: "nice!" });

// Everyone reads every comment, and sees who wrote each one.
// list() is paginated — `items` is this page, `next` continues it.
const { items } = await backlit.records.list("comment.");
for (const { key, owner } of items) {
  const { value } = await backlit.records.getJSON(key);
  render(value, owner);
}

records.reassign(key: string, newOwnerUid: string): Promise<void>

Admin only. Transfers a record's ownership to newOwnerUid (a user id — for example an owner value read from get / list). The body is untouched; only the owner moves. A non-admin caller throws BacklitError(code: "forbidden"), an unknown key throws not_found, a malformed newOwnerUid throws invalid_argument (HTTP 400), and a call that races a concurrent write/reassign of the same key throws conflict (HTTP 412) — retry. The SDK does not pre-check admin — the server is the boundary.

// An admin hands a record over to another user.
await backlit.records.reassign("entry.42", someUserId);

backlit.capture — write-only capture store

For data the glow's app code needs to capture but never read back from the browser: form submissions, telemetry events, contact requests, support tickets. The server returns an opaque {prefix}-{guid} handle on create.

Writes (create / update) are available on any glow — no user session required. Reads, enumeration, and deletion (get / list / delete) are admin only — they need a signed-in user whose auth.me() permission is admin (a Public glow's app can sign an allowlisted admin in for exactly this — see auth.login()), so ordinary app code can capture data but can never read it back even if the page is compromised.

capture.create(prefix: string, body: BodyInit, contentType: string): Promise<{ handle: string, crc32c: string }>

Store a new value and get back its handle plus the body's CRC32C. The handle is "{prefix}-{guid}" — the prefix you supply, prepended to a server-generated 36-character lower-hex GUID in 8-4-4-4-12 format (e.g. create("contact", ...)"contact-3b1f4a2c-1d2e-4f5a-8b9c-0d1e2f3a4b5c"). The prefix is a human-meaningful label that lets whoever reads these objects out of band group and recognise them; the GUID keeps each name unguessable and unique.

const { handle } = await backlit.capture.create(
  "contact",
  JSON.stringify({ email: form.email.value, message: form.body.value }),
  "application/json",
);
// handle === "contact-3b1f4a2c-..." — show the user the handle if you
// want them to be able to reference their submission later, or just
// store it for your own records.
form.querySelector(".receipt").textContent = `Submitted (ref ${handle})`;

capture.update(handle: string, body: BodyInit, contentType: string): Promise<{ crc32c: string }>

Replace the value stored at an existing handle. Open to any session — the handle is the capability.

// handle is the value create returned, e.g. "contact-3b1f4a2c-..."
await backlit.capture.update(
  handle,
  JSON.stringify({ ...previous, resolved: true }),
  "application/json",
);

capture.get(handle: string): Promise<{ value: Blob, contentType: string, crc32c: string }>

Admin only. Read the value stored at a handle. Resolves to the same shape as data.get (the value Blob, contentType as stored, crc32c of the body).

// Only an admin can read captured submissions back.
const { value } = await backlit.capture.get(handle);
const submission = JSON.parse(await value.text());

capture.list(prefix?: string, opts?: { cursor?: string, limit?: number }): Promise<{ items: Array<{ handle: string, crc32c: string, contentType: string, size: number }>, next: string }>

Admin only. Enumerate the handles stored in this glow's private store, each with its body's CRC32C, stored contentType, and size in bytes. Returns handles and metadata only, never values.

The result is paginated, exactly like data.list: one bounded page of handles plus an opaque next cursor — re-call with { cursor: next } until next is "".

// Only meaningful for an admin user; branch on auth.me() first.
if ((await backlit.auth.me())?.permission === "admin") {
  let cursor = "";
  do {
    const page = await backlit.capture.list("contact", { cursor });
    for (const { handle } of page.items) {
      const { value } = await backlit.capture.get(handle);
      console.log(handle, await value.text());
    }
    cursor = page.next;
  } while (cursor);
}

capture.delete(handle: string): Promise<void>

Admin only. Remove the object stored at a handle.

await backlit.capture.delete(handle);

Writes (create / update) are open to any session; reads, enumeration, and deletion (get / list / delete) are admin-only. That asymmetry is intentional — ordinary app code can capture data here but can never read it back, even if the page's JavaScript is compromised, while an admin operator retains a way to inspect and clean up what was captured.


backlit.on — realtime subscriptions

The SDK delivers realtime notifications whenever a data.*, userdata.*, or records.* key is set or deleted, so apps can keep two tabs in sync, react to a peer's write, or invalidate a cached read without polling. Subscribe with backlit.on("storage.change", handler).

const off = backlit.on("storage.change", (event) => {
  // event = { store, operation, key, crc32c, owner? }
  if (event.store === "data" && event.key === "leaderboard") {
    refreshLeaderboard();
  }
});

// Later — stop receiving events.
off();

Event shape

interface StorageChangeEvent {
  readonly store:     "data" | "userdata" | "records";
  readonly operation: "set"  | "delete";
  readonly key:       string;
  readonly crc32c:    string; // base64 big-endian uint32; empty on delete
  readonly owner?:    string; // records events only — the record's owner uid
}

crc32c is the base64-encoded Castagnoli CRC32C of the body — the same value get / list entries and the data.put response carry. On a "delete" event it is the empty string. Apps that maintain a local cache can compare a freshly-fetched body's CRC32C against the event's to detect duplicate deliveries.

Scoping rules

Delivery semantics

Reconnect

The SDK opens a single WebSocket on the first on("storage.change", …) call and reuses it for every subsequent subscription. If the socket drops the SDK reconnects with exponential backoff (1s → 2s → 4s → … capped at 30s, with ±25% jitter). The socket closes 5 seconds after the last unsubscribe() so a tab that briefly drops to zero handlers (a route change in an SPA) doesn't pay the reconnect cost.

Future events

The first argument is currently restricted to the literal string "storage.change". Other event names will be added in future v1 minor releases without breaking existing callers.


backlit.push — notifications

backlit.push lets your app enroll the signed-in user for push notifications — messages that reach the user even when the glow's tab is closed, delivered by the browser and the operating system. Backlit installs and runs the notification machinery for you; your app decides when to offer to enable notifications and reacts to taps.

Every glow is also installable — a visitor can add it to their home screen or app launcher as a standalone app. That happens automatically; there is no API to call, and it works whether or not you use push.

Requirements. subscribe / state / unsubscribe / test need a signed-in user (a Private glow, an Accounts glow after the visitor signs in, or a Public glow's signed-in user) — an anonymous or signed-out visitor gets BacklitError(code: "unauthenticated"). Push must be enabled for the glow (turned on at deploy time); when it isn't, subscribe throws unsupported. Browser support varies — always gate your UI on supported() first.

Enabling push. To turn push on for a glow, ship a root notify.json in the deploy bundle declaring when a notification fires — see the notify.json schema for its fields and the Push notifications end-to-end guide for the full author story.

Don't register your own service worker. Backlit owns the glow's service worker and registers it for you on page load. Registering your own would replace Backlit's and break push + install. (A deploy bundle that reaches for localStorage / indexedDB is rejected for the same reason — the platform owns that layer; use window.backlit.*.)

// Offer a notification opt-in only where it can actually work.
const cap = backlit.push.supported();
if (cap.supported) {
  enableBtn.onclick = async () => {
    try {
      await backlit.push.subscribe();          // prompts the user for permission
      await backlit.push.test();               // send yourself a test push
    } catch (e) {
      if (e.code === "permission_denied") showTip("You blocked notifications.");
      else throw e;
    }
  };
} else if (cap.requiresInstall) {
  showTip("Install this app to your home screen to turn on notifications.");
} else {
  enableBtn.hidden = true;                      // this browser can't do push
}

// React to a notification tap (or a permission change) while a tab is open.
backlit.push.watch((e) => {
  if (e.type === "notification-click") location.assign(e.url);
});

push.supported(): { supported, reason?, requiresInstall, installed }

A synchronous capability probe — the only SDK method that is not async, and the only one that never throws. Call it to decide whether to show an "Enable notifications" control at all.

push.state(): Promise<{ permission, subscribed, endpointId }>

Report the current push state for this browser and signed-in user.

push.subscribe(): Promise<{ endpointId, permission: "granted" }>

Enroll the signed-in user's browser for push. This prompts the user for notification permission (if not already granted), sets up the subscription, and registers it with Backlit — so a later push.test() (or a notification your glow's rules send) reaches this browser.

try {
  const { endpointId } = await backlit.push.subscribe();
  console.log("subscribed", endpointId);
} catch (e) {
  if (e.code === "permission_denied") {
    // The user said no — don't nag; offer it again later from a clear action.
  } else {
    throw e;
  }
}

push.unsubscribe(): Promise<void>

Remove this browser's push subscription (from the browser and from Backlit). A no-op when there is no active subscription. Requires a signed-in user; throws unsupported / requires_install / unsupported_host on the same conditions as subscribe.

push.test(): Promise<void>

Send a test push to the signed-in user's own subscriptions — a quick way for a just-enrolled user to confirm notifications work.

push.watch(callback): () => void

Observe push-related events while a tab is open, and get back an unsubscribe function. The callback receives one of:

watch never throws and is a no-op off a glow host. It observes only; it does not deliver the notification payloads themselves (the browser shows those).

const off = backlit.push.watch((e) => {
  if (e.type === "notification-click") location.assign(e.url);
});
// Later: off();

Errors

Every method (except auth.me(), which never throws) rejects its returned Promise with a single error class, BacklitError. Discriminate on .code.

class BacklitError extends Error {
  readonly code: string;     // one of the codes in the table below
  readonly status: number;   // HTTP status, or 0 for network / client-side
  readonly backlitCause: unknown; // underlying Error or fetch failure, when there is one
}

instanceof BacklitError works as expected. .code matches the server's {error:{code,message}} envelope verbatim so the same switch statement handles both server-emitted and client-side errors.

try {
  await backlit.data.put("key", new Date(), "application/json");
} catch (e) {
  if (e instanceof backlit.BacklitError) {
    switch (e.code) {
      case "invalid_argument": /* the Date isn't a BodyInit */ break;
      case "invalid_key":      /* charset / length problem */ break;
      case "quota_exceeded":   /* account storage/bandwidth allowance hit */ break;
      default: throw e;
    }
  } else {
    throw e;
  }
}

Error codes

code Source Meaning
not_found server get / getJSON / delete on a key that has never been written (any store), a putIfMatch with a non-empty expectedCrc32c against an absent key, or records.reassign on an unknown key.
unauthenticated server / SDK A shared-data write (data.put/delete/putIfMatch and the JSON write wrappers), userdata.*, or records.* called without a user session, or capture.get/list/delete or auth.users() with no signed-in user (an anonymous or signed-out visitor, an expired session).
cross_glow_token server Your session cookie is bound to a different glow. Rare; indicates cookie reuse.
forbidden server A viewer attempted data.put / data.delete; a non-creator/non-admin attempted to overwrite, delete, or reassign a records key; or a non-admin attempted capture.get/list/delete or auth.users(). (capture.create/update and records create are unaffected.)
invalid_key both Key violates the charset ([A-Za-z0-9._~-]) or length (1–256 bytes) rule.
invalid_prefix both Prefix violates the charset or length rule (length 0 is allowed).
invalid_cursor server A list opts.cursor is not a valid pagination token. Pass a prior page's next verbatim, or omit it for the first page.
invalid_handle both capture.update/get/delete handle is not the {prefix}-{guid} form (a bare GUID is rejected).
unknown_handle server capture.update/get/delete against a well-formed handle that has no record.
invalid_body both SDK-side when getJSON reads a key whose stored bytes are not valid JSON; server-side when the request body could not be decoded.
invalid_argument both Client-side rejection (status 0): wrong body type, missing contentType, or a putJSON value with no JSON form (undefined / function / circular / BigInt). Also server-side (HTTP 400) for a malformed records.reassign owner id.
unsupported_content_type both Content-Type isn't on the allowlist (or is one of the always-denied executable types).
payload_too_large server Body exceeds the server's per-object cap (default 10 MiB).
quota_exceeded server Two cases, split by status: 413 — the write would push you over your account's storage allowance (the just-written object has already been rolled back); 429 — the account's monthly bandwidth/request allowance is exhausted, which rejects every call, reads and lists included, until the monthly reset.
method_not_allowed server A non-SDK caller used an unsupported HTTP method. Should never fire from the SDK.
invalid_host server The request reached Backlit with no recognisable glow Host. Should never fire from the SDK.
glow_not_found server The glow has been deleted (or never existed) since the page loaded.
glow_dark server The glow has been switched to Dark since the page loaded.
region_mismatch server The request reached a Backlit region different from the glow's region.
network SDK fetch itself rejected: offline, CORS, aborted, etc. .backlitCause holds the underlying error.
conflict both SDK-side (status 0): updateJSON exhausted its compare-and-swap retries (maxAttempts, default 6) under write contention. Server-side (HTTP 412): a records.delete / records.reassign lost a concurrent-write race — re-read and retry.
unsupported_host SDK A data.* / userdata.* / records.* / capture.* call — or auth.users() / push.* — ran while the SDK was loaded outside a deployed glow host. The SDK has no data surface off-glow; deploy the glow to use it. (auth.me() and push.supported() do not throw it — they resolve null / { supported:false } instead.)
unsupported SDK push.subscribe/state/unsubscribe — this browser can't do push (no service worker / Push API / secure context), or push isn't enabled for this glow. Gate on push.supported() first.
requires_install SDK push.subscribe/state/unsubscribe on a platform where push works only from an installed home-screen app (iOS Safari) and it isn't installed yet. push.supported() reports this as requiresInstall: true.
permission_denied SDK push.subscribe — the user declined (or had previously blocked) the notification-permission prompt.
internal both Server returned 5xx (or a non-JSON 4xx), or the response body wasn't parseable.

A few patterns worth pulling out:

BacklitError.status

The HTTP status code that produced the error, when one exists. 0 for client-side validation failures (invalid_argument, invalid_key, invalid_prefix, invalid_handle, the always-denied content-type pre-flight), for conflict raised by updateJSON (gave up after its retries — a server-raised records race carries HTTP 412 instead), for unsupported_host (the SDK was loaded off a deployed glow host), and for transport failures (network).


Validation

The SDK pre-flights all inputs so you see a BacklitError from the caller's await rather than from a wasted round-trip. The rules below mirror the server exactly.

Keys (every data.* / userdata.* / records.* method that takes a key — get, put, putIfMatch, delete, getJSON, putJSON, putJSONIfMatch, updateJSON)

Failures throw BacklitError with code === "invalid_key".

Prefixes (data.list, userdata.list, records.list)

Applies to every method that takes a prefix — the three lists above and the matching getBatch / getBatchJSON on data / userdata / records:

Failures throw BacklitError with code === "invalid_prefix".

Handles (capture.update / get / delete)

A handle is the keyed string "{prefix}-{guid}" returned by create:

A bare GUID with no prefix is not a valid handle. The GUID is always server-generated; never construct a handle yourself — pass back exactly what create returned.

Failures throw BacklitError with code === "invalid_handle".

Content-Type

contentType is required on every put and create/update. It must be a non-empty string. The server enforces a strict allowlist (by default text/plain, text/css, text/csv, text/markdown, the raster image families image/{png,jpeg,gif,webp}, application/json, and application/octet-stream — note these are the exact text/ subtypes, not a text/* wildcard); the exact allowlist may be tuned per deployment.

A small set is always denied, regardless of the allowlist, because serving them back on a GET could execute as same-origin script:

The SDK pre-flights this always-denied set client-side (unsupported_content_type). The full allowlist is enforced server- side. On reads the server also sends X-Content-Type-Options: nosniff so stored bytes can't be MIME-sniffed into executable markup.


Notes

Changelog