Skip to content

Documents

Create, read, replace, patch and delete documents; versions, If-Match and If-None-Match.

Every configured collection gets the same set of endpoints:

MethodPathBehaviorSuccess
POST/v1/{realm}/{database}/{collection}Create a document201 + Location + ETag
GET/v1/{realm}/{database}/{collection}List documents200
GET/v1/{realm}/{database}/{collection}/{id}Fetch one document200 + ETag
PUT/v1/{realm}/{database}/{collection}/{id}Replace the whole document200 + ETag
PATCH/v1/{realm}/{database}/{collection}/{id}Partial update (JSON Merge Patch)200 + ETag
DELETE/v1/{realm}/{database}/{collection}/{id}Delete the document204
POST/v1/{realm}/{database}/_batchCreate, replace, patch and delete atomically200
  • An unknown realm, database, collection or id returns 404 not_found. PUT and PATCH never create documents: there are no upserts, so create with POST and let the server choose the id.
  • In a realm with auth: enabled, requests are decided by the collection’s access rules, or allowed outright with an API key in Authorization: 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
}
  • id is generated by the server (xid: 20 characters, sortable by creation time). Clients can never choose it.
  • _meta is owned by the server:
    • created_at is set on create and never changes.

    • updated_at is set on every write.

    • Both timestamps are RFC3339 in UTC with millisecond precision.

    • version is 1 on create and increases by one on every write.

    • In realms with auth: enabled, three ownership fields are added:

      • owner is the id of the signed-in user who created the document (or the user an API key acted for), or null when an API key created it on its own. It never changes.
      • created_by is who created it, and never changes: user:<id>, key:<name>, or anonymous when a create rule lets callers without credentials write.
      • updated_by is who made the last write, in the same form.

      Realms with auth: disabled don’t have them. Documents stored before these fields existed don’t gain owner or created_by; their next write adds updated_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 example ETag: "3", and answer 304 to a matching If-None-Match.
  • Documents written before versioning existed have no version. They get version: 1 on their next write.
  • _id and _meta in request bodies are always ignored, and so is id on PUT and PATCH. On a create, id is the client’s choice in a collection that declares custom_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, and PUT and PATCH stay update-only (404 for an id that doesn’t exist).
  • Mistakes with the id are 400: id_required (a collection with custom_id and no id), invalid_id (outside the safety envelope or the pattern, or not the creating user’s id with custom_id: user; the detail says which check, and never echoes other data) and id_not_allowed (an id sent to a collection that generates its ids).
  • The order of the checks is fixed: the id is validated, then the create rule runs (and sees the id as data.id), and only then is the document inserted. A caller the rule doesn’t allow gets 403 for 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 of id_taken in a minute, a caller gets 429 with Retry-After, for any create in that collection. The count is per caller (user, API key or address) and per backd instance. backd_id_conflicts_total counts 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.db and ctx.admin.db in functions, and the admin data route. The admin routes skip the create rule, 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 create rule: a non-member and an unknown organization both answer 403 not_org_member.
  • A required collection answers 400 org_required without ?org=; a collection without organizations answers 400 org_not_allowed with it.
  • The organization is _meta.org, server-owned and fixed: a PUT or PATCH naming another one answers 400 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, 201 with the document as it was created (even if it was changed or deleted since), with Idempotent-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 answers 409 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 is 400.
  • 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 409 rather 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 Modified with no body when the document, as this caller may read it, still has that tag. The response repeats ETag, Cache-Control and Vary.
  • 200 with the document and its new ETag otherwise.
  • 404 when 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 *; a W/ prefix is ignored.
  • A response with ?file_links=true is never 304: 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-Match header with the version you expect, as returned in ETag:

    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 in ETag. This also happens when another write lands between the read and the write.

  • If-Match can list several versions ("3", "4") or be *, which means “any existing document”.

  • Weak tags (W/"3") never match. Malformed values return 400 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-Match deletes 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 answers 409 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 expand names it, and only references the collection declares can be named. name(f1,f2) selects fields (the id is always there; _meta only when selected); without parentheses the whole document is returned. name.nested follows 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 a many reference 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 wrong expand is 400 invalid_expand naming the part; one that would return too many documents is 400 expand_too_large, before any partial data. The instance’s values are BACKD_EXPAND_MAX_* (configuration), and a collection can only lower them.
  • Cost headers. X-Backd-Expand-Queries and X-Backd-Expand-Docs say what the expansion made and returned; the access log has them too, with the paths and the time spent.
  • Caching. The ETag of a document stays its own version, so If-Match keeps working; an expanded response is private, no-cache and never 304.
  • Not a join. where and order_by use the collection’s own fields: a path through a reference is 400 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 _expanded in a body is ignored, like _meta. A batch only writes, so it has nothing to expand. In functions, ctx.db expands as the caller and ctx.admin.db with 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;
  • null removes 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": "...", ... }. create and replace need document; patch needs patch (a JSON Merge Patch, same rules as PATCH); replace, patch and delete need id. Any of them accepts if_match, exactly like the If-Match header (a version such as "3", or "*" for any existing document).

  • results has one entry per operation, in the same order: the written document for create, replace and patch; just { "id": ... } for delete.

  • Up to 100 operations per batch.

  • Each operation is validated against its collection’s schema.json and, in realms with auth: enabled, checked against the collection’s create/update/delete access 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 the path in details:

    {
      "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, DELETE and 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) or deleted=include (both) on GET /{collection} and GET /{collection}/{id}. It needs the read rule and the restore rule to allow the document, so who may restore decides who may look. Anything else for deleted, or the parameter on a collection that doesn’t soft-delete, is 400 invalid_query. The marks are queryable: where={"_meta.deleted_at":{"$gte":"2026-10-01T00:00:00Z"}}.
  • POST /{collection}/{id}/restore brings a deleted document back, as it was, at the next version (200 with the document). It needs the restore rule, honours If-Match, and re-checks the document against the schema as it is now (400 if it no longer fits) and its unique values (409 if a live document took them meanwhile). A document that isn’t in the trash is 404.
  • DELETE /{collection}/{id}?purge=true removes a document for good, deleted or not, under the purge rule; the caller must also see it (the read rule, and for a deleted one the restore rule). 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 restore or purge rule 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}/_transfer with {"org": "<id>"} (null takes 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-Match is honored, files and image versions travel with it, and the audit record names both organizations. backd data transfer does the same from the terminal. In a collection with teams the body also takes "team": "<id>" (a team of the target organization) or null: 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: required members can’t see them. POST /_assign_org with {"org": "<id>", "where": {…}} queues a job that gives the organization to the documents that have none and match where, and never moves one that has an organization; ?dry_run=true only counts them (backd data assign-org). At startup backd logs 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.

Edit this page · Last updated 19 Oct 2026