JavaScript client
backd-js: auth, sessions, collections and the admin API from browsers, Node and edge runtimes.
backd-js is a small JavaScript library for backd. It is plain ES modules with no dependencies and no build step, and it runs wherever fetch does: browsers, Node 20+, Deno, Bun and edge runtimes. It ships TypeScript declarations, so editors autocomplete it in JavaScript and TypeScript projects alike.
Install
npm install backd-js
It has no dependencies, needs no build step and works with any bundler. Without one, import it in a browser with an import map pointing at the package’s src/index.js (from a CDN such as https://cdn.jsdelivr.net/npm/backd-js/src/index.js, or from your own server), as the example app does.
The client is versioned on its own, apart from the server. Versions of backd-js before 0.2 were an unrelated, older library.
Example app
The repository includes two example apps built with this client and Alpine.js, with no build step:
- the blog: sign-up, public posts and private drafts, editing with
ifMatch, and filters; - the expenses example: data shared between users, and the limits of access rules; it requires a verified email and offers “Forgot your password?”;
- the workshop tour, which also walks the email flows (verification, reset, change of address, emailed invitations).
make dev-examples serves both at https://localhost:8443/example/.
Create a client
import { createClient } from 'backd-js'
const backd = createClient({
url: 'https://localhost:8443',
realm: 'blog',
})
| Option | Default | Meaning |
|---|---|---|
url | required | Base URL of backd |
realm | required | The realm to talk to |
storage | memory | Where the session token is kept (see Token storage) |
cookies | false | Browser apps: keep the session in an HttpOnly cookie, out of scripts’ reach (see Session cookies) |
retry | off | { attempts, maxDelayMs } for 429 and 503 answers (see Retries) |
headers | none | Extra headers for every request |
fetch | global fetch | A custom fetch implementation |
apiKey | none | Server-side only: an API key. See API keys |
dangerouslyAllowBrowser | false | Allow apiKey in a browser, where every visitor can read it. Only for internal tools |
A web app on another origin needs that origin listed in the realm’s CORS settings.
Sign-up, login and sessions
const session = await backd.auth.signup({ email: 'ada@example.com', password: 'dev-p4ssw0rd!', locale: navigator.language })
// or: await backd.auth.login({ email, password })
const me = await backd.auth.me() // { id, email, email_verified, roles, locale, created_at, identities, reauth_methods }
await backd.auth.updateMe({ locale: 'es' }) // must be a language the realm lists, else a ValidationError (invalid_locale)
await backd.auth.reauth({ method: 'password', password }) // a fresh proof, before sensitive actions (below)
await backd.auth.changePassword({ newPassword }) // other sessions end
const sessions = await backd.auth.sessions() // [{ id, created_at, last_used_at, expires_at, current }]
await backd.auth.revokeSession(sessions[1].id)
await backd.auth.logout() // this session
await backd.auth.logoutAll() // every session of the user
await backd.auth.deleteAccount() // needs a fresh proof; deactivates the account, all data is kept
signupandloginstore the session token; every later request sends it asAuthorization: Bearer ….- In realms with
signup: invite, pass the invitation:signup({ email, password, invitation }). - In a realm that requires verified addresses,
signupcreates the account and then rejects with aVerificationRequiredError: there is no session yet, and the user logs in after following the link in the email. Aloginbefore that rejects with aForbiddenErrorwhosecodeisemail_not_verified.redirectTonames where the page after the link may send the user (it must be within the realm’semail.allowed_redirects). logoutalways removes the stored token, even if the server can’t be reached or already forgot the session.- Sensitive actions need a fresh proof.
changePassword,requestEmailChange,deleteAccount,unlinkProviderand (for an administrator signed in with a session)admin.apiKeys.createanswer401 reauth_requiredwithout one.auth.reauth({ method, … })takes the strongest proof the user has (me.reauth_methods:totporrecovery_codewith a second factor,password, oremail_codeafterrequestReauthCode()), and the client sends the token it gave with those requests for the realm’sreauth.ttl(5 minutes by default): one prompt covers a settings page. See Re-authentication.
The endpoints behind these calls, and their rules, are described in Sign-up, login and sessions.
Two-step sign-in and magic links
A user with a second factor doesn’t get a session from their password alone. signIn() returns the step as a value; login() and signup(), which promise a session, reject with a SecondStepRequiredError carrying the challenge.
const result = await backd.auth.signIn({ email, password })
if (result.mfaRequired) {
// result.methods: ['totp', 'recovery_code']
const session = await backd.auth.verifyMfa(result.challenge, 'totp', code) // stores the session
} else if (result.enrollmentRequired) {
// the user's role requires a second factor and they have none: set one up, no session yet
const setup = await backd.auth.mfa.setup({ challenge: result.challenge }) // { secret, otpauth_uri, qr (a PNG data URI) }
const done = await backd.auth.mfa.confirm({ challenge: result.challenge, setupId: setup.setup_id, code })
// done.recovery_codes (shown once) and done.session
} else {
// result is the session
}
The same steps come back from completeSignIn() / auth.ready after a provider or a magic link, and from signInWithIdToken().
// A signed-in user's own second factor; changes need a fresh proof (see below).
await backd.auth.mfa.status() // { enabled, methods, recovery_codes_left, required }
const setup = await backd.auth.mfa.setup() // show setup.qr, or the secret
const { recovery_codes } = await backd.auth.mfa.confirm({ setupId: setup.setup_id, code })
await backd.auth.mfa.regenerateRecoveryCodes()
await backd.auth.mfa.disable() // not for a role that requires it
// The proof, asked for only when the server wants it, then the action once more:
await backd.auth.withProof(async (methods) => ({ method: 'password', password: await ask() }),
() => backd.auth.mfa.disable())
// A link and a code by email (the realm needs magic_link); no return route is needed.
await backd.auth.magicLink.request('ada@example.com') // the link comes back to this page
const result = await backd.auth.magicLink.verifyCode('123456') // or type the code here
await backd.auth.password.set({ newPassword }) // an account that signs in another way may have none
await backd.auth.password.remove() // 409 last_sign_in_method when nothing else is left
Errors: invalid_code (a ValidationError, details has the attempts left), invalid_challenge, mfa_locked (a RetryableError, 429), reauth_required and, for a link or code, invalid_magic_link. A link opened on another device reports wrong_device, as for a provider. See Multi-factor authentication, Magic links and Re-authentication.
Account flows by email
In a realm with email, the links in the emails open pages of backd that need no code. An app that wants its own pages points the links at them (email.links in realm.yaml) and posts the token from the link with these calls, none of which needs a session or starts one:
await backd.auth.resendVerification({ email }) // 202 whatever the address is
await backd.auth.verifyEmail(token) // the token of the verification link
await backd.auth.requestPasswordReset({ email, redirectTo }) // 202 whatever the address is
await backd.auth.resetPassword({ token, password }) // ends every session, verifies the address
await backd.auth.requestEmailChange({ newEmail }) // signed in; needs a fresh proof (reauth)
await backd.auth.confirmEmailChange(token) // the link sent to the new address
await backd.auth.revertEmailChange(token) // the link sent to the old one
await backd.auth.acceptInvitation({ token, password, locale }) // an emailed invitation
- A token that is expired, used or unknown rejects with a
ValidationErrorwhosecodeisinvalid_token; a password the policy refuses rejects with aValidationErrornamingpasswordand leaves the token usable. A reset request past the realm’s limits rejects with aRetryableError. redirectTo(here and insignup) must be within the realm’semail.allowed_redirects: it is where the page after the link may send the user.- After
resetPassword,confirmEmailChange,revertEmailChangeandacceptInvitationthe user logs in: none of them starts a session, and the first three end the sessions that exist.
The rules behind each flow are in Sign-up, login and sessions.
Sign in with Google, Microsoft or Apple
In a realm that lists providers, signing in is one call on the page with the buttons. backd sends the user back to a page of your app (the current one by default, so no return route is needed), and the client finishes the sign-in when it is created there.
// A "Continue with Google" button: PKCE is handled, the browser goes to the provider.
await backd.auth.signInWith('google', { redirectTo: 'https://app.acme.example/signed-in' })
// On the page the user comes back to, whenever the app starts: `ready` is the outcome of the
// sign-in the address carried, or null on an ordinary visit.
try {
const session = await backd.auth.ready
if (session?.new_user) await createProfile(session.profile) // { name, given_name, family_name, picture } as the provider gave them
} catch (err) {
if (err.code === 'account_exists') showLinkHelp(err.details[0].reason) // the provider
}
- The client reads the
backd_code(orbackd_error,backd_linked) that backd added to the address, removes it from the address bar and trades the one-time code for a session (stored like a login’s) and resolves with it, plusnew_userand, for a new user, theprofile. Useaccount.on_signupto create the profile document on the server instead of depending on the page.completeOnLoad: falseincreateClientturns this off, andcompleteSignIn()does the same on demand. - A page opened where the sign-in was not started (the link was opened on another device or in another tab) has no verifier to redeem the code with:
readyrejects with aValidationError,code: "wrong_device"; show “open this on the device where you asked”. - Failures reject with the error the code means:
account_existsandlink_conflictare aConflictError;signup_closed,email_not_verifiedandsignin_refusedaForbiddenError;cancelledanAuthenticationError;invitation_invalidandemail_requiredaValidationError;provider_erroraBackdError.codeis the code. - The PKCE verifier waits in the tab’s
sessionStoragebetween the two pages (oauthStorageincreateClientreplaces it).redirectTois the current page unless given; the realm’ssign_in.app_urltakes over only when the client sends none.navigate(url)in the options replaceslocation.assignfor apps that open a browser session of their own (React Native, Capacitor);redirectTomay be an app scheme (acme://signed-in) there, andcompleteSignIn(url)takes the address the app was opened with. signInWith(provider, { invitation, locale })passes an invitation for realms withsignup: invite.
For a signed-in user:
await backd.auth.linkProvider('apple', { redirectTo }) // the same flow; completeSignIn() resolves { linked: 'apple' }
const me = await backd.auth.me() // me.identities: [{ provider, email, email_verified, created_at, last_used_at }]
await backd.auth.unlinkProvider('apple') // never the last way to sign in: ConflictError, code 'last_sign_in_method'
An app that signs in with the platform (Sign in with Apple, Google Sign-In, MSAL) already holds the ID token and uses it directly:
const session = await backd.auth.signInWithIdToken('apple', { idToken, nonce, authorizationCode })
await backd.auth.linkProviderWithIdToken('google', { idToken, nonce }) // for a signed-in user
nonce is the raw value the sign-in was started with. An invalid token rejects with an AuthenticationError (invalid_token). Apple’s authorizationCode is required unless the provider sets revoke_on_delete: false.
Auth events
const stop = backd.auth.onAuthChange((event, session) => {
// 'SIGNED_IN' (session set), 'SIGNED_OUT' or 'SESSION_EXPIRED' (session null)
})
stop() // stop listening
SESSION_EXPIRED fires when the server refuses the stored token: it expired, was revoked (for example by a password change on another device), or the user was disabled. The client removes the token, so the app can send the user to the login screen. A failed login doesn’t fire any event.
A listener that throws is reported with console.error and doesn’t affect the client or other listeners.
Collections
const posts = backd.db('main').collection('posts')
const page = await posts.list({
where: { published: true, title: { $ilike: 'go%' } },
orderBy: '-_meta.created_at', // or ['-_meta.created_at', 'title']
limit: 20,
skip: 0,
count: true,
})
// { items: [...], limit: 20, skip: 0, has_more: true, next_cursor: '…', total: 42 }
// The next page: pass the cursor back, with the same where and orderBy
const next = await posts.list({ where: { published: true }, orderBy: '-_meta.created_at', limit: 20, after: page.next_cursor })
// Alternatives with $or (see Querying)
await posts.list({ where: { $or: [{ title: { $icontains: 'go' } }, { body: { $icontains: 'go' } }] } })
for await (const post of posts.iterate({ where: { published: true } })) {
// every matching document; pages of 100 by default (set `limit` to change)
}
const post = await posts.get(id)
const created = await posts.create({ title: 'Hello', published: false })
await posts.patch(id, { title: 'Hello again', subtitle: null }) // null removes a field
await posts.replace(id, { title: 'Only this field now' })
await posts.delete(id)
whereandorderByusebackd’s query language as it is: the client only serializes them.- Documents come back as the API returns them: your fields plus
idand_meta(created_at,updated_at,version, and in realms with authenticationowner,created_by,updated_by). - What a user sees and may change is decided by the collection’s access rules. A document they can’t read answers
NotFoundError; an operation they can’t do answersForbiddenError, orAuthenticationErrorwhen they aren’t signed in. iteratefollows each page’snext_cursor, so documents created, changed or deleted while it runs never shift a page, and the end is as fast as the start. A cursor can’t follow anorderByon arrays, objects or fields of mixed types:iteratethen fetches by offset (skip), where documents created or deleted meanwhile can be skipped or repeated.aftercan’t be combined withskip; see paging with a cursor.
With TypeScript (or JSDoc), describe a collection’s fields for typed documents:
/** @typedef {{ title: string, published?: boolean }} Post */
/** @type {import('backd-js').Collection<Post>} */
const posts = backd.db('main').collection('posts')
Revalidation cache
The client remembers the last body and ETag of each document and list it read, in memory, and sends them back as If-None-Match on the next read of the same URL. When nothing changed the server answers 304 without a body and the client returns the copy it holds: the query and the rules still run for every read, so you never see more than the caller may read now, only less data on the wire.
const post = await posts.get(id) // 200, kept with its ETag
const again = await posts.get(id) // sends If-None-Match; a 304 gives the same document
backd.cacheStats() // { hits: 1, misses: 1, entries: 1, bytes: … }
await posts.get(id, { cache: false }) // this read skips the cache
createClient({ url, realm, cache: false }) // no cache at all
createClient({ url, realm, cache: { maxEntries: 50, maxBytes: 1e6 } }) // smaller bounds
- Bounded: 200 entries or 5 MB of bodies by default; the least recently used are dropped first.
- Only document and list reads are kept. Reads with
fileLinks(their links expire) and answers markedno-storenever are. - Per caller: signing in, signing out or a different session or API key empties the cache, and so does
backd.clearCache(). A successful write to a document drops its entry. - Functions reach the API with the cache off.
For a client written by hand, or a mobile app, see Revalidating a read.
Concurrent edits
Every write increases _meta.version. Pass the version you read as ifMatch, and the write fails with a VersionMismatchError if someone changed the document in between:
import { VersionMismatchError } from 'backd-js'
const post = await posts.get(id)
try {
await posts.patch(id, { title: 'Edited' }, { ifMatch: post._meta.version })
} catch (err) {
if (err instanceof VersionMismatchError) reloadAndAskUser()
else throw err
}
ifMatch also accepts '*' (the document must exist) or a raw header value. Writes with ifMatch are safe to retry.
The trash
In a collection that soft-deletes, delete marks the document instead of removing it, and the trash has its own calls:
await posts.delete(id) // hidden from every read, restorable
const trash = await posts.list({ deleted: 'only' }) // 'include' for both; needs the restore rule
const gone = await posts.get(id, { deleted: 'only' }) // gone._meta.deleted_at, deleted_by, purge_at
const back = await posts.restore(id, { ifMatch: gone._meta.version }) // ConflictError if a live document took its unique value
await posts.delete(id, { purge: true }) // for good, under the purge rule
Expanding references
A collection that declares references can return the referenced documents next to the result. You ask for them with expand, on get, list and iterate; the client never adds it by itself:
import { expand } from 'backd-js'
const page = await posts.list({
expand: expand(['author', ['name', 'avatar']], 'tags'), // "author(name,avatar),tags", or write the string
limit: 20,
})
const post = page.items[0]
post._expanded?.author // { id, name, avatar }, or null when missing or not readable by this user
post._expanded?.tags // [{ id, ... }, null, ...] (positions kept), or { items, truncated: true }
expand(...)takes names ('author','author.company'for a second level) and[name, [fields]]pairs, and checks their form; the server checks them against the config.nullis the same for a document that doesn’t exist, one the user can’t read and one of another organization. In a typed client (below)_expandedis typed from the collection the reference leads to:T | null, the selected fields optional, andtruncatedfor arrays.- Errors are
InvalidExpandError(invalid_expand: thedetailsname the part) andExpandTooLargeError(expand_too_large: ask for less). Both areValidationErrors. The cost headers and limits are the server’s. - It isn’t cached by the server, and your revalidation cache always asks again for an expanded read.
- Functions have it too:
ctx.db.collection('posts').get(id, { expand: 'author(name)' }).
Aggregations and search
aggregate() summarizes what the caller may read, with the fields the collection declares; search() is a list sorted by relevance with the text index:
import { fillBuckets } from 'backd-js'
const { groups, total_groups } = await orders.aggregate({
where: { status: { $in: ['paid', 'shipped'] } },
groupBy: ['paid_at:month'], tz: 'Europe/Madrid',
metrics: ['count', 'sum:total'], orderBy: 'paid_at:month',
})
groups[0].key['paid_at:month'] // '2026-09'
groups[0].sum_total // '18234.5' (an exact decimal string)
// A chart wants every month, including the ones nobody ordered in:
const points = fillBuckets(groups, { field: 'paid_at', unit: 'month', from: '2026-01', to: '2026-12', empty: { sum_total: '0' } })
const page = await articles.search('red "running shoes" -kids', { where: { status: 'published' }, limit: 10 })
page.items[0]._meta.score // the relevance; `language: 'es'` picks the stemming
aggregate(params):groupByandmetricstake a string or a list,orderBya key or result name ('-count'),tzan IANA zone,limitat most the collection’s maximum. WithoutgroupBythe answer is one group with an emptykey, for a counter.fillBuckets(groups, { field, unit, from, to, empty })adds the empty buckets of one date field betweenfromandto(bucket keys such as'2026-09', or dates), ascount: 0plusempty. It needs groups grouped by that bucket alone.search(text, params)takes a list’s parameters exceptorderByandafter; pages withskipthrough the first 1,000 results.- Errors:
AggregateNotEnabledError,InvalidAggregateErrorandSearchNotEnabledError(400,ValidationErrors),AggregateTooExpensiveErrorandSearchTooExpensiveError(422) andIndexBuildingError(503, aRetryableError: the text index is being rebuilt, andretryAftersays when to try again). - Typed: in a client typed from
backd types,aggregateonly accepts the declared fields (a bucket only on dates), and a collection that declares noaggregate:has none. - Functions have both on
ctx.db(as the caller) andctx.admin.db.
Custom ids
A collection that declares custom_id takes the id from the client: pass it in the document. Nothing is overwritten, and a typed client requires it:
const invoices = backd.db('billing').collection('invoices')
await invoices.create({ id: 'INV-2026-0042', customer: 'Ada', total: 120 })
await backd.db('main').collection('profiles').create({ id: user.id, bio: '…' }) // custom_id: user
try {
await invoices.create({ id: 'INV-2026-0042', customer: 'Bob', total: 5 })
} catch (err) {
if (err instanceof IdTakenError) pickAnotherNumber() // 409 id_taken
if (err instanceof InvalidIdError) console.log(err.code) // id_required | invalid_id | id_not_allowed
}
A 429 after repeated IdTakenErrors is a RetryableError: the duplicate limit. batch creates take id on the operation. See the API for the order of the checks.
Safe retries of creates
create() and db.batch() take an idempotencyKey. The server remembers the first answer for 24 hours and returns it for the same key and body, so a retry never makes a second document (see Safe retries). A request with a key is also retried by the client itself after a network error, 429 or 503, when retry is on:
const order = await orders.create(draft, { idempotencyKey: 'checkout-' + cartId })
Batch writes
db.batch(operations) creates, replaces, patches and deletes documents across the database’s collections in one MongoDB transaction — either every operation applies, or none does:
const [order, stock] = await backd.db('main').batch([
{ op: 'create', collection: 'orders', document: { item: 'widget', qty: 2 } },
{ op: 'patch', collection: 'stock', id: stockId, patch: { qty: 8 }, ifMatch: stockDoc._meta.version },
])
- Up to 100 operations. Each is validated and rule-checked exactly like a single write;
ifMatchworks the same as above. - If any operation fails, the whole batch rejects (the usual error types —
ValidationError,ForbiddenError,VersionMismatchError, …) and nothing was written. A version mismatch means re-reading and retrying the whole batch, not just one operation. - See Batch writes for the full request/response shape.
Files
Files live in the file fields of a collection’s documents (File fields). The collection handle has a method for each thing an app does with them, and each is a request to the field’s _files routes, under the signed-in user’s rules.
const docs = backd.db('app').collection('claims')
// Upload into a document. Through backd or straight to the bucket: the client asks the field.
const doc = await docs.uploadFile(id, 'receipts', file, { onProgress: ({ loaded, total }) => bar.set(loaded / total) })
// Upload before the document exists, then name the upload in the create.
const up = await docs.prepareUpload('receipt', file)
await docs.create({ title: 'Taxi', receipt: up.ref }) // up.ref is { upload, token }
const { url, expiresAt } = await docs.fileUrl(id, 'receipts', fileId) // a link for an <img> or a button
await docs.downloadFile(id, 'receipts', fileId) // a fresh link, opened: the browser saves it
const one = await docs.get(id, { fileLinks: true }) // every file carries url and expires_at
await docs.deleteFile(id, 'receipts', fileId)
const info = await docs.fileField('receipts') // { upload, max_size, types, multiple, max_files, … }
uploadFile(id, field, file, opts)takes aFileorBlob(or bytes, or a string) and resolves with the updated document. A field withupload: directis sent straight to the bucket: the client reads the file once to compute its SHA-256 (incrementally, so a large file isn’t held in memory), declares it, sends it with the signed link and completes it, and backd verifies the result.opts:nameandtype(default to the file’s own),ifMatch,signalandonProgress.prepareUpload(field, file, opts)does the same for a document that doesn’t exist yet and resolves with{ id, token, ref, expiresAt, file }. Putrefin the file field of acreate(or aputorpatch; a list of them for amultiplefield). An upload is used once and expires after the realm’spending_ttl.onProgress({ loaded, total })reports the bytes sent as they go in a browser (the client usesXMLHttpRequest, the only way to know). Elsewhere it is called once, when the file has been sent.fileUrl(id, field, fileId)resolves with a link that works without credentials untilexpiresAt. Fetch it when it is needed: see Download buttons.fileUrl(id, field, fileId, { version: 'thumb' })links a made version of an image, and resolves withnullwhile it isn’t ready (files(id, field).get()and.link()take{ version }too and throwversion_unavailable).versionStatus(file, 'thumb')reads a version’s{ status, ready, reason, width, height }from a file’s details, to show “processing…” and reserve room for the picture. WithfileLinks: trueeach ready version also has its ownurl.files(id, field).version(name, fileId?)hasregenerate(),generate(params)anddelete()for a made version: they wait for a worker and resolve with the version’s state (see Versions of an image); they are what a function uses.downloadFile(id, field, fileId)isfileUrl()followed bylocation.assign(); the link forces a download, so the page stays. Browsers only: elsewhere it throws, andfileUrl()orfiles(id, field).get()are what to use.fileLinks: trueongetandlistaddsurlandexpires_atto every file. Links expire; use it for what is shown at once.deleteFile(id, field, fileId, { ifMatch })removes one file.files(id, field).delete()clears the field.files(id, field)is the lower level:put,get(the bytes),link,deleteandlist, which is also what a function uses.fileField(field)says how the field takes files (asked once, then remembered): to showmax_sizeandtypesand refuse a file before sending it.
Errors are the client’s: unsupported_file_type (415), payload_too_large (413), too_many_files (409), a NotFoundError for a document the user can’t read, and for a direct upload upload_failed when the bucket refuses the file. That one almost always means the bucket’s CORS isn’t set for the app’s origin: see your provider’s page.
Functions
db.fn(name, input, opts) calls a function. A sync function’s output comes back directly:
const receipt = await backd.db('shop').fn('checkout', { cart: 'c1' })
// { order: 'o7', total: 1250 }
An async function instead returns a Job handle, without waiting for it to finish:
import { Job, JobTimeoutError } from 'backd-js'
const job = await backd.db('shop').fn('reconcile', {})
job.id // "d3c9ljp8hc2g00b6s1m0"
job.function // "shop/reconcile"
await job.status() // 'queued', 'running' or 'done'
const output = await job.wait({ pollIntervalMs: 500, timeoutMs: 30_000 })
// polls GET .../_jobs/{id} until status is 'done', then resolves like a sync call would
waitpolls everypollIntervalMs(default 500) and resolves with the job’s output once it’sdone. If the job itself fails — the function threw, timed out, or crashed —wait(and a plainsynccall) throw the same error a direct call would: aBackdErrorsubclass built from the job’s own status/code/message, so callers don’t need to branch on sync vs. async to handle failures.- If
timeoutMsis set and elapses before the job is done,waitthrowsJobTimeoutError(.jobId) instead — a plainError, not aBackdError, since giving up on polling isn’t something the server reported. The job itself keeps running; callwaitagain, orstatus(), to check on it later. - With no
timeoutMs,waitpolls until the job finishes, however long that takes (up to the function’s owntimeout). job.stepsare the steps the function reported as of the laststatus()orwait()(the last is the current step), andwait({ onProgress: (steps) => ... })calls you after every poll.job.rawis the lastGET .../_jobs/{id}responsestatus()/wait()fetched, if you need fields beyondid,functionandstatus.
An administrator can also run a function by hand, internal ones included, with backd.admin.invokeFunction('<database>/<name>', { input, as, idempotencyKey }) (see Internal functions); it returns the output or a Job, like db.fn().
A sync function can be started as a job by the caller, with respondAsync: true (sends Prefer: respond-async, see Calling functions): the call resolves to a Job, and job.wait() gives the output or throws the function’s error, exactly as above.
const job = await backd.db('shop').fn('checkout', { cart: 'c1' }, { respondAsync: true })
const receipt = await job.wait()
An Idempotency-Key makes a retried call safe (see Idempotency):
await backd.db('shop').fn('charge', { amount: 1250 }, { idempotencyKey: `order-${orderId}-charge` })
fn’s third argument also accepts { idempotencyKey, respondAsync, retry, signal, headers }, same as collections calls. webhook functions aren’t called through fn — they’re invoked by the sender, not the client.
Triggers
A trigger runs an internal function when a collection’s documents change, in the background and without a client in the loop, so there is nothing to call: the client has no triggers surface, by design. What the client does touch:
backd.admin.invokeFunction('main/on-paid', { input })runs the trigger’s function by hand, with the input you give it. It neither fires the trigger nor needs an event: the wiring lives in the collection’scollection.yaml, not in the function.- The function’s input has a type,
generatedwith the rest: the realm’sbackd.d.tshasRealm['db']['main']['collections']['orders']['trigger']for a collection with triggers (beforeandafterare partial: only the paths the trigger’sfieldsnames are there). - Handlers should be idempotent: the same change can be delivered again after a restart. Deduplicate on the input’s
id, which is the same on every delivery of one change.
export default async function (input: Realm["db"]["main"]["collections"]["orders"]["trigger"], ctx: Ctx) {
const seen = ctx.admin.db("main").collection("seen_events");
if ((await seen.list({ where: { event_id: input.id }, limit: 1 })).items.length) return; // a replay
await seen.create({ event_id: input.id });
// …the effect
}
Live lists
client.realtime.subscribe() follows collections that declare realtime: true: it loads each list, keeps it current from the stream and tells you when it changes.
const live = client.realtime.subscribe([
{ db: 'main', collection: 'items', where: { '_meta.org': orgId }, load: { sort: '-_meta.created_at', limit: 50 } },
])
live.on('change', (state) => render(state.items)) // sorted, reconciled
live.on('status', (s) => showBanner(s)) // 'connecting' | 'live' | 'resyncing' | 'closed'
await live.ready // the first load
live.close()
// The same, from a collection (an organization or team scope becomes a `where` on `_meta.org`/`_meta.team`):
const mine = client.db('main').collection('items').inOrg(orgId).subscribe({ load: { sort: '-_meta.created_at' } })
What the client does for you:
- One stream per client, multiplexed over every view’s subscriptions (the server allows 20 per stream). Adding or closing a subscription reopens it with the last resume token.
- Stream first, then load: the stream opens, each list is read with a normal list call, and the events that arrived meanwhile are applied after it, ignoring any whose
_meta.versionis not newer. New documents are inserted in theload.sortorder, and the list is trimmed toload.limit(100 by default). - Resume: after a dropped connection the stream continues after the last event, with the caller’s current access; the list is not read again. A token the server refuses, or history it no longer holds, reloads the lists.
- Resets: when the server says
reset(a slow client, a changed access, missing history) the affected list is reloaded after a random wait of up tojitterMs(1,000 by default), while the last list stays available and the status isresyncing. - Credentials: at the end of a credential’s lifetime (
reauth) the client reconnects with the current one. When the session is revoked (session_ended) the view stops anderrorcarries aSessionEndedError; sign in again and subscribe again. - References:
references: { author: { field: 'author_id', collection: 'profiles', fields: ['name'] } }keeps the referenced documents instate.references.author(by id), reading the missing ones in one batched call. They are read under the normal rules; subscribe to the referenced collection too if they must update live. - Errors are typed:
RealtimeNotEnabledErrorandTooManySubscriptionsError(ValidationErrors),InvalidResumeTokenError(handled for you) andSessionEndedError.
A list bounded by load.limit can’t refill itself when a document leaves it: a removal makes it shorter until the next reload, so subscribe with an equality on a grouping field and keep limit above what you show.
Push notifications
client.push registers this device so that functions can push the signed-in user: register({ platform, token }), devices(), unregister(), registerOnLaunch(getToken) and, for browsers, subscribeWeb({ registration }), which asks for the permission and does nothing when it is denied.
backd.push.registerOnLaunch(async () => ({ platform: 'android', token: await getFcmToken() }))
const { permission } = await backd.push.subscribeWeb({ registration: await navigator.serviceWorker.ready })
It needs a user session. The service worker, the mobile apps’ raw endpoints and the rest are in Push notifications → From apps.
Consents
A realm can ask people to accept consents: versioned texts such as terms, a privacy notice or marketing. client.consents reads them and records the answers.
// Before an account exists: what the realm asks for, and the exact text
const list = await backd.consents.current()
// [{ name: 'terms', required: true, current: { version: '2026-10', reaccept: true }, locales: ['en', 'es'] }, …]
const terms = await backd.consents.text('terms', '2026-10', 'es') // { text, hash, locale, … }
// At sign-up: the name and the version shown. Every required consent must be there
await backd.auth.signup({ email, password, consents: { terms: '2026-10', marketing: '1' } })
// Signed in: what the user holds, accept or withdraw
await backd.consents.state() // { consents, to_accept, history }
await backd.consents.accept({ marketing: '1' })
await backd.consents.withdraw('marketing') // optional consents only
- At sign-in a user who has to accept something (a required consent they never accepted, or a new version that asks for re-acceptance) gets a
ConsentChallengefromauth.signIn():{ consentRequired: true, challenge, consents }.auth.login()andauth.signup()promise a session, so they throw aSecondStepRequiredErrorwithcode: 'consent_required'and the samechallengeandconsents. Show the texts and finish withauth.acceptConsents(challenge, { terms: '2026-10' }), which stores the session. - After a
reaccept_bydate, a request of a user who hasn’t accepted rejects with aConsentRequiredError(403): show the prompt and sendclient.consents.accept(...), which stays open for them. - Sign-ups through a magic link or a provider take
consentsas an option (auth.magicLink.request(email, { consents }),auth.signInWith(provider, { consents }),auth.signInWithIdToken(provider, { …, consents })); a new account made without them gets the challenge at its first sign-in.
import { ConsentRequiredError, SecondStepRequiredError } from 'backd-js'
try {
await backd.auth.login({ email, password })
} catch (err) {
if (err instanceof SecondStepRequiredError && err.code === 'consent_required') {
const accepted = await showConsentDialog(err.consents) // { terms: '2026-10' }
await backd.auth.acceptConsents(err.challenge, accepted)
} else throw err
}
App configuration
client.config reads the realm’s app configuration: feature flags, maintenance switches, limits. The server answers only what the signed-in user’s rules allow, with defaults filled in.
await client.config.load(["features", "maintenance"]); // or load() for everything readable
client.config.get("maintenance.enabled"); // from the copy held
await client.config.get("beta.new_editor", { refresh: true }); // force a fetch
client.config.on("change", (paths) => rerender(paths));
load(paths?)fetches and keeps a copy, with no request while it is fresh.get(path)returns the value from the copy held; for a key whose group was never loaded it fetches that group first, and only then returns a promise.getSync(path, fallback)never asks: it returns the copy’s value, orfallback(the client doesn’t know the server’s defaults).- Freshness: a copy is fresh for the
cache_ttlthe server sent (5 minutes by default). After that, reads return the copy at once and revalidate it in the background withIf-None-Match, which usually costs a304.refresh: trueforces a fetch. - Where copies live: per realm and per user, in
localStoragein browsers and in memory elsewhere (configStorage: falsefor memory only, or any object withgetItem,setItemandremoveItem). Blocked storage falls back to memory. The copy of a page that has just reloaded is restored by the firstload(). - Clearing: sign-out and signing in as another user drop every copy of the realm, so the next person on the same browser never sees the previous user’s keys.
on("change")fires with the dot paths whose value changed when a read brings a different configuration.
Typed client
backd types generates a TypeScript Realm interface from the config (Generated types). Pass it to createClient and everything the client returns or accepts is typed, with no change at run time:
import { createClient } from "backd-js";
import type { Realm } from "./backd/types";
const client = createClient<Realm>({ url: "https://api.example.com", realm: "shop" });
const page = await client.db("main").collection("orders")
.list({ where: { status: "paid", total: { $gte: 100 } }, orderBy: "-total" });
page.items[0].status; // "new" | "paid" | "shipped"
const r = await client.db("main").fn("place", { items }); // typed input and result
client.config.get("shop.currency"); // "eur" | "usd" | undefined
- What’s typed: databases and collections by name, documents (with
_metaand file details, and_expandedfor the references a collection declares), whatcreate,replaceandpatchaccept,where(fields, and the operators each type allows) andorderBy, function calls (input, and result or job by mode; internal functions have no route and aren’t offered) and configuration paths. - Without the type parameter everything works untyped, as before. The runtime package is the same for every realm.
- Stale-types warning (development). Pass
typesthe fingerprint fromrealm.json, and the client sends it asX-Backd-Types; when the server’s config has moved on it answersX-Backd-Types-Stale: trueand the client warns once on the console (or callsonStaleTypes). Nothing is refused. Send it only in development builds; withouttypes, no header is sent. The fingerprint covers the whole realm, so generate without--db, or the warning never goes away.
import info from "./backd/realm.json";
const client = createClient<Realm>({
url, realm: "shop",
types: import.meta.env.DEV ? info.fingerprint : undefined,
});
Token storage
By default the token is kept in memory: it is gone after a reload, and nothing on the page can read it from storage. To keep users signed in across reloads, choose a storage:
import { createClient, localStorageStorage } from 'backd-js'
const backd = createClient({ url, realm, storage: localStorageStorage() })
Or keep no token in the page at all, with session cookies.
Any object with get(), set(token) and remove() works as storage, and the methods may be async, for example to use a mobile app’s secure storage:
const storage = {
get: () => SecureStore.getItemAsync('backd'),
set: (token) => SecureStore.setItemAsync('backd', token),
remove: () => SecureStore.deleteItemAsync('backd'),
}
await backd.auth.token() returns the stored token, or null.
Session cookies
In a realm that enables sessions.cookie, a browser app can keep the session in an HttpOnly cookie instead of a token, so even an injected script can’t steal it:
const backd = createClient({ url: 'https://api.example.com', realm: 'blog', cookies: true })
await backd.auth.login({ email, password }) // asks for a cookie: the answer has no token
await backd.auth.me() // the browser sends the cookie; the client sends no Authorization header
await backd.auth.hasSession() // what this page believes; after a reload, ask the server with me()
await backd.auth.logout() // also clears the cookie
cookies: truemakesloginandsignupask for a cookie and every request send credentials (fetch(..., { credentials: 'include' })).auth.token()isnull: there is no token for scripts to read.- The realm must list your app’s origin in
cors.origins(no wildcard), and the API must be on a site that the cookie’ssame_sitesetting reaches: the same site as the app by default (app.example.comwithapi.example.com). - The client keeps only a hint that a session exists (in the
storageyou choose, default memory). If the server stops accepting the cookie, the next request signs the client out (SESSION_EXPIRED), as with tokens. - Not combined with
apiKey, and not for the admin API, which needs anAuthorizationheader (see Sessions).
Organizations
backd.orgs is the organizations API: the groups of users a person belongs to, with their members, teams and invitations.
const org = await backd.orgs.create({ name: 'Acme Corp', slug: 'acme' })
const mine = await backd.orgs.list() // each with roles, require_mfa and mfa_met
await backd.orgs.members(org.id).list()
await backd.orgs.members(org.id).update(userId, ['editor']) // direct roles
await backd.orgs.teams(org.id).create({ name: 'Editors', roles: ['editor'] })
await backd.orgs.teams(org.id).addMember(team.id, userId)
const { items } = await backd.orgs.teams(org.id).members(team.id, { role: 'manager' }) // members and holders of a team role, in force now
await backd.orgs.teams(org.id).grantRole(team.id, { user: userId, role: 'delegate', until: '2026-08-15T00:00:00Z' })
await backd.orgs.teams(org.id).setRoleWindow(team.id, { user: userId, role: 'delegate', until: '2026-08-31T00:00:00Z' })
await backd.orgs.teams(org.id).revokeRole(team.id, { user: userId, role: 'delegate' })
// An invitation answers the link once: show it, send it, or let the server email it.
const inv = await backd.orgs.invitations(org.id).create({ binding: 'email', email: 'ana@example.com', roles: ['editor'], send: true })
inv.link // https://app.acme.example/join?token=…
// The person who got the link:
const offer = await backd.orgs.invitations.preview(token) // { org, roles, teams }
await backd.orgs.invitations.accept(token)
Documents belong to an organization when their collection says so: inOrg(id) scopes a collection, so creates go there and lists show only that organization’s documents (the read rule still decides what is seen).
const projects = backd.db('main').collection('projects').inOrg(org.id)
await projects.create({ title: 'Plan' })
const { items } = await projects.list({ where: { status: 'open' } })
org in the options of create and batch does the same for one call.
In a collection with teams, inTeam(id) narrows a collection of an organization further: creates go to that team (the caller must belong to it or hold a team role in it, or the create throws NotTeamMemberError) and lists show only its documents.
const requests = backd.db('main').collection('requests').inOrg(org.id).inTeam(sales.id)
await requests.create({ title: 'Leave' })
Moving documents between organizations or teams is an administrator’s job: only through the admin API.
- Deleting an organization and turning
require_mfaon ask for a fresh proof of identity: wrap them inbackd.auth.withProof, as other sensitive actions. - A second factor.
mfa_metis false for a session without one in an organization that requires it; the organization’s calls then throwOrgRequiresMFAError. Leaving is still allowed.
Errors
Every answer that isn’t 2xx throws a BackdError, or one of its subclasses:
| Class | Status | Typical code |
|---|---|---|
ValidationError | 400 | validation_error, invalid_json, invalid_query, invalid_header |
InvalidIdError (a ValidationError) | 400 | id_required, invalid_id, id_not_allowed: the id of a custom id create |
AuthenticationError | 401 | unauthenticated, invalid_credentials |
ForbiddenError | 403 | forbidden |
NotFoundError | 404 | not_found |
ConflictError | 409 | email_taken, conflict, write_conflict |
IdTakenError (a ConflictError) | 409 | id_taken: a document with that custom id exists, and nothing was overwritten |
NotOrgMemberError, OrgRequiresMFAError, OrgRoleRequiresMFAError (ForbiddenErrors) | 403 | not_org_member (also for an unknown organization), org_requires_mfa, org_role_requires_mfa (details names the role) |
NotTeamMemberError (a ForbiddenError) | 403 | not_team_member: not a member of the team and no team role there (the same for an unknown team) |
TeamError (a ValidationError) | 400 | team_required, team_not_allowed |
InvalidExpandError, ExpandTooLargeError (ValidationErrors) | 400 | invalid_expand (details name the part), expand_too_large: see expanding references |
AggregateNotEnabledError, InvalidAggregateError, SearchNotEnabledError (ValidationErrors) | 400 | aggregate_not_enabled, invalid_aggregate, search_not_enabled: see aggregations and search |
AggregateTooExpensiveError, SearchTooExpensiveError | 422 | aggregate_too_expensive, search_too_expensive |
IndexBuildingError (a RetryableError) | 503 | index_building: the collection’s text index is being rebuilt |
LastOrgManagerError (a ConflictError) | 409 | last_org_manager: the organization needs someone who can manage it |
VersionMismatchError | 412 | version_mismatch |
ConsentRequiredError | 400, 403 | consent_required; consents lists what to accept, as { name, version } |
RealtimeNotEnabledError, TooManySubscriptionsError, InvalidResumeTokenError (ValidationErrors) | 400 | realtime_not_enabled, too_many_subscriptions, invalid_resume_token: see live lists |
SessionEndedError (an AuthenticationError) | — | session_ended: the stream’s session, user or key is gone |
RetryableError | 429, 503 | too_many_requests, unavailable; retryAfter in milliseconds |
NetworkError | 0 | network_error, aborted |
Each error has status, code, message, details ([{ path, reason }], for example the fields that failed validation) and requestId, which matches backd’s logs.
import { AuthenticationError, ValidationError } from 'backd-js'
try {
await backd.auth.login({ email, password })
} catch (err) {
if (err instanceof AuthenticationError) showMessage('Wrong email or password')
else if (err instanceof ValidationError) showFieldErrors(err.details)
else throw err
}
Retries
Retries are off by default. When enabled, the client retries 429 and 503 answers, waiting as long as the server’s Retry-After asks (otherwise 0.5 s, doubling), and never longer than maxDelayMs:
const backd = createClient({ url, realm, retry: { attempts: 2, maxDelayMs: 10_000 } })
Every call also accepts { retry, signal, headers } as a last argument, for example an AbortSignal to cancel it.
API keys and the admin API
For server-side code, createClient({ url, realm, apiKey }) sends an API key with every request. Access rules don’t apply to it: collections calls see and change everything in the realm. In a browser, createClient refuses an apiKey, because every visitor could read it. Pass dangerouslyAllowBrowser: true only for tools that never reach real users.
A client with an API key with the admin role (backd apikey create --realm <realm> --name <name> --role admin) can use the admin API:
const backd = createClient({ url, realm: 'blog', apiKey: process.env.BACKD_API_KEY })
const ada = await backd.admin.users.find('ada@example.com') // or null
const page = await backd.admin.users.list({ limit: 50, skip: 0 }) // q: 'ada' searches emails
const user = await backd.admin.users.create({ email: 'bob@example.com', password }) // password optional
await backd.admin.users.get(user.id)
await backd.admin.users.update(user.id, { emailVerified: true, disabled: false })
await backd.admin.users.setPassword(user.id, newPassword) // ends their sessions
await backd.admin.users.addRole(user.id, 'editor') // roles declared in realm.yaml
await backd.admin.users.removeRole(user.id, 'editor')
await backd.admin.users.owned(user.id) // what erasing them would do, per collection with a policy
await backd.admin.users.identities(user.id) // the ways to sign in: password, providers
await backd.admin.users.unlinkIdentity(user.id, 'google') // never the last one: ConflictError, 'last_sign_in_method'
await backd.admin.users.sessions(user.id) // unexpired sessions, never a token
await backd.admin.users.revokeSession(user.id, sessionId)
await backd.admin.users.delete(user.id) // ERASES them (irreversible): a tombstone, and the collections' policies; resolves with the erase job
const invitation = await backd.admin.invitations.create({ email: 'eve@example.com', expiresIn: '3d' })
sendInvitationEmail(invitation.token) // shown only here
await backd.admin.invitations.send({ email: 'eve@example.com', redirectTo, locale }) // backd emails it: no token
await backd.admin.users.changeEmail(user.id, 'new@example.com') // at once; both addresses are told
await backd.admin.invitations.list()
await backd.admin.invitations.revoke(invitation.id)
const orgs = await backd.admin.orgs.list({ status: 'orphaned', q: 'acme' }) // each with its number of members
const report = await backd.admin.orgs.owned(orgs[0].id) // what deleting it would do, per collection
await backd.admin.orgs.assignOwner(orgs[0].id, user.id) // an orphaned organization gets a manager
await backd.admin.orgs.setClaims(orgs[0].id, { plan: 'pro' }) // read by rules with orgClaims()
await backd.admin.orgs.transfer('main', 'projects', docId, otherOrgId) // moves a document; also invite, members, teams, delete, reactivate, assign
const access = await backd.admin.whoami() // level, areas it may read and change
const posts = backd.admin.data('blog', 'posts') // the admin data route: list/get/create/replace/patch/delete/restore, past the rules
const config = await backd.admin.config() // what this instance runs for the realm, read-only
const spec = await backd.admin.openapi() // the realm's admin OpenAPI document (needs the config area)
const appSpec = await backd.admin.clientOpenapi() // the one for client applications: no admin routes
const storage = await backd.admin.storage.check() // the realm's object storage: credentials, put/get, signed SHA-256, links
const started = await backd.admin.dataChecks.start({ database: 'main', collection: 'orders' }) // reads every document: see estimated_documents
const reports = await backd.admin.dataChecks.wait(started, { onProgress: (job) => console.log(job.progress?.current) }) // the documents that no longer match their schema
const detail = await backd.admin.jobs.get(jobId) // the job with every step it reported (detail.steps)
await backd.admin.jobs.cancel(jobId) // a queued or running function job ends as "cancelled"
const again = await backd.admin.jobs.rerun(jobId) // a finished one is queued again as a new job (again.rerun_of)
await backd.admin.schedules.pause('main/nightly') // a schedule creates no runs until resume(); list() shows which are paused
const secrets = await backd.admin.secrets.list() // scope, name, who set it; never the values
await backd.admin.secrets.set('STRIPE_KEY', value, { database: 'main' }) // omit database for a realm secret
await backd.admin.secrets.delete('STRIPE_KEY', { database: 'main' })
const history = await backd.admin.invocations.list({ function: 'main/refund', limit: 20 }) // function calls, with origin and parent_id
const created = await backd.admin.apiKeys.create({ name: 'billing', role: 'data', expiresIn: '90d', networks: ['203.0.113.0/24'], scopes: ['read:main/posts'] })
storeSecret(created.key) // shown only here
await backd.admin.apiKeys.list() // never the keys themselves
await backd.admin.apiKeys.revoke('billing')
await backd.admin.users.setNetworks(user.id, { loginNetworks: ['10.20.0.0/16'] }) // [] removes
const trail = await backd.admin.audit.list({ target: `user:${user.id}`, since: new Date(Date.now() - 864e5) })
trail.items // newest first; read-only
const jobs = await backd.admin.jobs.list({ function: 'main/nightly_cleanup', status: 'done', limit: 10 }) // state and outcome, never input or output
A client signed in as a user holding one of the realm’s admin roles can use client.admin too, for example in a back-office app. Calling client.admin without an API key or a session throws at once; the server decides the rest. Acting on behalf of a user from the client is planned.