Documents
Create, read, replace, patch and delete documents; versions, If-Match and If-None-Match.
Every configured collection gets the same set of endpoints:
| Method | Path | Behavior | Success |
|---|---|---|---|
POST | /v1/{realm}/{database}/{collection} | Create a document | 201 + Location + ETag |
GET | /v1/{realm}/{database}/{collection} | List documents | 200 |
GET | /v1/{realm}/{database}/{collection}/{id} | Fetch one document | 200 + ETag |
PUT | /v1/{realm}/{database}/{collection}/{id} | Replace the whole document | 200 + ETag |
PATCH | /v1/{realm}/{database}/{collection}/{id} | Partial update (JSON Merge Patch) | 200 + ETag |
DELETE | /v1/{realm}/{database}/{collection}/{id} | Delete the document | 204 |
POST | /v1/{realm}/{database}/_batch | Create, replace, patch and delete atomically | 200 |
- An unknown realm, database, collection or
idreturns404 not_found. PUT and PATCH never create documents: there are no upserts, so create with POST and let the server choose theid. - In a realm with
auth: enabled, requests are decided by the collection’s access rules, or allowed outright with an API key inAuthorization: Bearer …. - Request bodies must be a single JSON object. Anything else returns
400 invalid_json.
Document shape
Documents are returned as bare JSON objects:
{
"id": "d2fjps30h2ipb32i2hg0",
"_meta": {
"created_at": "2026-09-25T21:30:00.000Z",
"updated_at": "2026-09-25T21:45:12.345Z",
"version": 3
},
"name": "Widget",
"price": 12.5
}
idis generated by the server (xid: 20 characters, sortable by creation time). Clients can never choose it._metais owned by the server:created_atis set on create and never changes.updated_atis set on every write.Both timestamps are RFC3339 in UTC with millisecond precision.
versionis1on create and increases by one on every write.In realms with
auth: enabled, three ownership fields are added:owneris the id of the signed-in user who created the document (or the user an API key acted for), ornullwhen an API key created it on its own. It never changes.created_byis who created it, and never changes:user:<id>,key:<name>, oranonymouswhen a create rule lets callers without credentials write.updated_byis who made the last write, in the same form.
Realms with
auth: disableddon’t have them. Documents stored before these fields existed don’t gainownerorcreated_by; their next write addsupdated_by.chain({root, depth}) is present only on a write made by a trigger run, or by a function it called: it says where the write sits in a chain of such runs. It comes from the function’s credential, so no client can set it, and the next write by anything else removes it.
- Single-document responses carry
ETag: "<version>", for exampleETag: "3", and answer304to a matchingIf-None-Match. - Documents written before versioning existed have no
version. They getversion: 1on their next write. _idand_metain request bodies are always ignored, and so isidonPUTandPATCH. On a create,idis the client’s choice in a collection that declarescustom_id, and refused in one that doesn’t.
Create
POST /v1/shop/orders/items
Content-Type: application/json
{ "name": "Widget", "price": 12.5, "status": "active" }
The body is validated against the collection’s schema.json. The response is 201 Created with the stored document and a Location header pointing at it.
Custom ids
A collection that declares custom_id takes the id from the client, which must send it in the body:
POST /v1/shop/billing/invoices
Content-Type: application/json
{ "id": "INV-2026-0042", "customer": "Ada", "total": 120 }
201 Created
Location: /v1/shop/billing/invoices/INV-2026-0042
- It never overwrites. An id that exists is
409 id_taken, andPUTandPATCHstay update-only (404for an id that doesn’t exist). - Mistakes with the id are
400:id_required(a collection withcustom_idand noid),invalid_id(outside the safety envelope or the pattern, or not the creating user’s id withcustom_id: user; the detail says which check, and never echoes other data) andid_not_allowed(anidsent to a collection that generates its ids). - The order of the checks is fixed: the id is validated, then the
createrule runs (and sees the id asdata.id), and only then is the document inserted. A caller the rule doesn’t allow gets403for every id, so only callers allowed to create can learn that an id exists. - Duplicates are limited. After the collection’s
duplicate_limit(20 by default) answers ofid_takenin a minute, a caller gets429withRetry-After, for any create in that collection. The count is per caller (user, API key or address) and per backd instance.backd_id_conflicts_totalcounts them per collection. - Custom ids explains when to use them and how to keep them safe.
- The same applies to batch creates (
"id"on the operation),ctx.dbandctx.admin.dbin functions, and the admin data route. The admin routes skip thecreaterule, not the id checks.
Organizations
In a collection with organization: a document is created in an organization, named with ?org=<id> (.inOrg(id) in the clients):
POST /v1/acme/app/projects?org=d3c9ljp8hc2g00b6s1m0
- The caller must be a member that counts, checked before the
createrule: a non-member and an unknown organization both answer403 not_org_member. - A
requiredcollection answers400 org_requiredwithout?org=; a collection without organizations answers400 org_not_allowedwith it. - The organization is
_meta.org, server-owned and fixed: a PUT or PATCH naming another one answers400 immutable_field. Moving a document is an administrator’s transfer. - A batch takes
?org=for all its creates. - API keys without a user have no memberships, so they answer
403 not_org_member; administrators use the admin data route, which accepts any active organization.
A list takes ?org=<id> as a shorthand for where={"_meta.org": "<id>"}, ANDed with where. The read rule still decides what is seen: an organization the caller doesn’t belong to lists nothing. In a collection with teams, ?team=<id> is the same shorthand for _meta.team (and _meta.team can be used in where and order_by); a create names its team with ?team= next to ?org= (.inTeam(id) in the clients).
Safe retries with Idempotency-Key
A create whose answer is lost (a timeout, a dropped connection) can’t be retried blindly: it may have worked, and the retry would make a second document. Send an Idempotency-Key and retry with the same one:
POST /v1/shop/orders/items
Idempotency-Key: order-42
It is the mechanism of function calls, applied to creates and to batches:
- The first request with a key runs, and its answer is remembered for 24 hours. The same key with the same body returns that answer again,
201with the document as it was created (even if it was changed or deleted since), withIdempotent-Replayed: true. Two bodies with the same content, in any key order or spacing, are the same body. - The same key with another body answers
422 idempotency_key_reused. The same key while the first request is still running answers409 request_in_progress: wait and retry. - Only a success is remembered. A request that fails (invalid body, refused by the rules, a unique conflict) created nothing, so the key stays free for the corrected retry.
- A key is private to the collection and the caller: another user, or an API key, can use the same string without meeting it. 1–200 characters of letters, digits,
.,_,:and-; anything else is400. - It needs a signed-in user or an API key (anonymous callers all look alike, so they could read each other’s answers) and a realm with authentication enabled (it is where keys are kept): otherwise
400, rather than ignoring a promise of safety. - If backd stores the document but fails to store the answer, the key stays “running” for its 24 hours: a retry is told
409rather than creating a duplicate.
Validation errors
A document that fails its schema returns 400 validation_error with one detail per failing field, using dot paths for nested fields:
{
"error": {
"code": "validation_error",
"message": "document failed schema validation",
"details": [
{ "path": "address.city", "reason": "got number, want string" },
{ "path": "age", "reason": "minimum: got -1, want 0" },
{ "path": "extra", "reason": "is not allowed" },
{ "path": "name", "reason": "is required" }
],
"request_id": "d2fk1a30h2ipb32i2hh0"
}
}
Revalidating a read (If-None-Match)
A client that already holds a document can ask whether it is still current. Send the ETag you got in If-None-Match:
GET /v1/shop/main/orders/ord_1
If-None-Match: "3"
304 Not Modifiedwith no body when the document, as this caller may read it, still has that tag. The response repeatsETag,Cache-ControlandVary.200with the document and its newETagotherwise.404when the document is gone or the caller may no longer read it: the read rule runs before the tag is compared, so a revalidation never confirms a document the caller can’t see.- The header can list several tags (
"3", "4") or be*; aW/prefix is ignored. - A response with
?file_links=trueis never304: its links are made per response and expire.
See Caching and conditional GET for lists, public caching and the client’s built-in cache. If-None-Match is for reads, If-Match is for writes: the first says “send it only if it changed”, the second “write only if it didn’t”. The ETag of a response that differs from the stored document for the caller carries a suffix ("3.r1a2b3c4d"); send it back unchanged, If-Match only looks at the version.
Concurrency and If-Match
Every write is conditional on the version it read, so concurrent writers never silently overwrite each other.
If-Match
PUT, PATCH and DELETE accept an optional
If-Matchheader with the version you expect, as returned inETag:PATCH /v1/shop/orders/items/d2fjps30h2ipb32i2hg0 Content-Type: application/merge-patch+json If-Match: "3" { "price": 14 }If the document’s current version differs, the response is
412 version_mismatch, with the current version inETag. This also happens when another write lands between the read and the write.If-Matchcan list several versions ("3", "4") or be*, which means “any existing document”.Weak tags (
W/"3") never match. Malformed values return400 invalid_header.
Without If-Match
- PUT and PATCH retry automatically when another write lands between their read and write: re-read, re-merge (PATCH), re-validate, write. They try up to 3 times, with a short random backoff.
- If every attempt loses the race, the response is
409 write_conflict, and nothing from the request was applied. Clients may retry. - A DELETE without
If-Matchdeletes whatever version is current. When access rules apply, the delete is conditional on the version the rule was checked against, and retries like PUT and PATCH: losing every attempt answers409 write_conflict.
Unique conflicts
A create, replace or update that would repeat a value covered by a unique index (see indexes.json) returns 409 conflict. The details name the index’s fields:
{
"error": {
"code": "conflict",
"message": "a document with the same unique value already exists",
"details": [ { "path": "email", "reason": "must be unique" } ],
"request_id": "darf9mq5mh5g00aq9df0"
}
}
For a compound unique index, each field is listed with the reason must be unique in combination with a, b.
List
GET /v1/shop/orders/items?limit=10&skip=20
{ "items": [ ... ], "limit": 10, "skip": 20, "has_more": true }
By default documents are ordered by creation time (_meta.created_at), then id. The endpoint also supports filtering (where), sorting (order_by), counting (count) and paging with a cursor (after, from the next_cursor of the previous page). See Querying.
A list response carries a weak ETag (W/"…") over the whole page: the id, version and view of every returned document, has_more, the cursor and the total. Send it back in If-None-Match and an unchanged page answers 304 with no body. The query and the rules always run, so a document that was added, removed, edited, or hidden from or revealed to the caller changes the tag. A list with ?file_links=true has no tag and is never 304.
Expanding references
A collection can declare references to other collections. With expand a read returns the referenced documents next to the result, in _expanded, in the same request:
GET /v1/shop/blog/posts?expand=author(name,avatar),tags(name),category
{
"id": "p_1", "title": "Hello", "category": "c_9", "tags": ["t_1", "t_2", "t_3"],
"_meta": { "owner": "u_9", "version": 4 },
"_expanded": {
"author": { "id": "pr_7", "name": "Ana", "avatar": "ana.png" },
"category": { "id": "c_9", "name": "News" },
"tags": [ { "id": "t_1", "name": "go" }, null, { "id": "t_3", "name": "db" } ]
}
}
- Explicit only. Nothing is expanded unless
expandnames it, and only references the collection declares can be named.name(f1,f2)selects fields (theidis always there;_metaonly when selected); without parentheses the whole document is returned.name.nestedfollows a reference declared on the target collection, two levels at most. - Rules apply to every expanded document, with the caller’s identity: a referenced document is returned only if the caller could read it directly. A reference that points at nothing, at a document the caller can’t read, or at one of another organization is
null, the same in every case. In amanyreference the positions are kept. - Bounded. One query per path per level for the whole page; at most 5 entries, 2 levels, 100 ids per array (
{"items": [...], "truncated": true}when there were more) and 2,000 expanded documents per response. A wrongexpandis400 invalid_expandnaming the part; one that would return too many documents is400 expand_too_large, before any partial data. The instance’s values areBACKD_EXPAND_MAX_*(configuration), and a collection can only lower them. - Cost headers.
X-Backd-Expand-QueriesandX-Backd-Expand-Docssay what the expansion made and returned; the access log has them too, with the paths and the time spent. - Caching. The
ETagof a document stays its own version, soIf-Matchkeeps working; an expanded response isprivate, no-cacheand never304. - Not a join.
whereandorder_byuse the collection’s own fields: a path through a reference is400 invalid_query. Filter on the reference’s source field instead. The reference page has the syntax, the startup errors, the cost guide and the alternatives. - Writes never expand, and
_expandedin a body is ignored, like_meta. A batch only writes, so it has nothing to expand. In functions,ctx.dbexpands as the caller andctx.admin.dbwith admin access.
Replace (PUT)
The body becomes the complete new document and is validated as a whole. Fields that aren’t in the body are removed. id and _meta.created_at are kept.
Update (PATCH)
The body is a JSON Merge Patch that is merged into the stored document:
- members in the patch overwrite stored values;
- nested objects are merged recursively;
nullremoves a field.
The merged document is then validated against the schema.
PATCH /v1/shop/orders/items/d2fjps30h2ipb32i2hg0
Content-Type: application/merge-patch+json
{ "price": 14, "discount": null }
Delete
Deletes are permanent unless the collection soft-deletes. The response is 204 No Content, or 404 if the document doesn’t exist (or, in a collection that soft-deletes, is already deleted). If-Match works as for any write.
Batch writes
A batch accepts an Idempotency-Key too: a retry returns the original results.
POST /v1/{realm}/{database}/_batch creates, replaces, patches and deletes documents across the database’s collections in one MongoDB transaction: either every operation applies, or none does.
POST /v1/shop/orders/_batch
Content-Type: application/json
{
"operations": [
{ "op": "create", "collection": "orders", "document": { "item": "widget", "qty": 2 } },
{ "op": "patch", "collection": "stock", "id": "d2fjps30h2ipb32i2hg0", "patch": { "qty": 8 }, "if_match": "\"3\"" }
]
}
{
"results": [
{ "id": "o7", "item": "widget", "qty": 2, "_meta": { "version": 1, ... } },
{ "id": "d2fjps30h2ipb32i2hg0", "qty": 8, "_meta": { "version": 4, ... } }
]
}
Each operation is
{ "op": "create" | "replace" | "patch" | "delete", "collection": "...", ... }.createandreplaceneeddocument;patchneedspatch(a JSON Merge Patch, same rules as PATCH);replace,patchanddeleteneedid. Any of them acceptsif_match, exactly like theIf-Matchheader (a version such as"3", or"*"for any existing document).resultshas one entry per operation, in the same order: the written document forcreate,replaceandpatch; just{ "id": ... }fordelete.Up to 100 operations per batch.
Each operation is validated against its collection’s
schema.jsonand, in realms withauth: enabled, checked against the collection’screate/update/deleteaccess rule — exactly like a single write. API keys bypass rules, as they always do.The first operation that fails aborts the whole batch: nothing from any operation is applied. The response is the usual error envelope, with the failing operation’s position (
0-based) as thepathindetails:{ "error": { "code": "version_mismatch", "message": "operation 1: If-Match does not match the current version \"4\"", "details": [ { "path": "1", "reason": "If-Match does not match the current version \"4\"" } ], "request_id": "darf9mq5mh5g00aq9df0" } }A schema validation failure inside an operation prefixes the field path too, e.g.
"1.qty".On a version mismatch a batch answers
412; see the note below.The JS client:
client.db('orders').batch([{ op: 'create', collection: 'orders', document: {...} }, { op: 'patch', collection: 'stock', id, patch: {...}, ifMatch }]).
Soft delete
A collection with soft_delete keeps deleted documents recoverable. DELETE answers the same 204, but the document stays, marked in _meta:
"_meta": { "version": 2, "deleted_at": "2026-10-04T09:00:00Z", "deleted_by": "user:d2fjps30h2ipb32i2hg0", "purge_at": "2026-11-03T09:00:00Z" }
- Everywhere else it is gone: get, list, count,
PUT,PATCH,DELETEand batches answer as if it didn’t exist, and its unique values are free again. - The trash is read with
deleted=only(just the deleted ones) ordeleted=include(both) onGET /{collection}andGET /{collection}/{id}. It needs thereadrule and therestorerule to allow the document, so who may restore decides who may look. Anything else fordeleted, or the parameter on a collection that doesn’t soft-delete, is400 invalid_query. The marks are queryable:where={"_meta.deleted_at":{"$gte":"2026-10-01T00:00:00Z"}}. POST /{collection}/{id}/restorebrings a deleted document back, as it was, at the next version (200with the document). It needs therestorerule, honoursIf-Match, and re-checks the document against the schema as it is now (400if it no longer fits) and its unique values (409if a live document took them meanwhile). A document that isn’t in the trash is404.DELETE /{collection}/{id}?purge=trueremoves a document for good, deleted or not, under thepurgerule; the caller must also see it (thereadrule, and for a deleted one therestorerule). A collection without a retention keeps deleted documents until someone purges them.- Callers outside the rules, API keys, see and restore everything; the rules decide for everyone else, and without a
restoreorpurgerule nobody else may.
Fixing data as an administrator
The admin API serves these same operations past the collections’ rules, at /v1/{realm}/_admin/data/{database}/{collection}, for administrators who hold the data area. Writes there are audited and documents they create have no owner.
Two operations exist only there, for organizations:
- Moving a document.
POST /{id}/_transferwith{"org": "<id>"}(nulltakes it out of its organization, in a collection where that is allowed) moves one document. It is never an update: the target must exist and not be deleted, the version goes up,If-Matchis honored, files and image versions travel with it, and the audit record names both organizations.backd data transferdoes the same from the terminal. In a collection with teams the body also takes"team": "<id>"(a team of the target organization) ornull: moving to another organization leaves the team, so a collection that requires one needs it named; the same call with the document’s own organization puts a document that has no team into one (backd data transfer --team <id|none>). - Adopting a collection. A collection that starts using organizations has documents without one, and with
organization: requiredmembers can’t see them.POST /_assign_orgwith{"org": "<id>", "where": {…}}queues a job that gives the organization to the documents that have none and matchwhere, and never moves one that has an organization;?dry_run=trueonly counts them (backd data assign-org). At startupbackdlogs the collections that still hold documents without one.
Schema changes
After schema.json changes, existing documents are returned as they are, even if they no longer match. Every write must produce a document that is valid against the current schema.