Custodian
ReferenceHTTP API

Records

Create, read, update and delete records over HTTP, named by their collection.

A record holds field values in one collection. Its shape follows the schema applied to the vault. These routes are principal-authenticated and reach only the caller's own vault; a record, collection or parent in another vault reads as not_found.

Every read returns the read-resolution envelope, which labels each field by how it resolved for the caller. This page links there rather than restating it.

POST /v1/collections/:name/records — create a record

A create writes the record and all of its field values in one transaction. How a field's value is supplied depends on its sensitivity: a plain, masked or tokenised field takes a cleartext value the server can see — it stores a plain value in the clear, and seals (encrypts) a masked or tokenised value before storing it; a client_encrypted field takes the parts the caller sealed on its own device — ciphertext, nonce and token, plus blind_index where the field is searchable.

A file field is the one exception. It is client_encrypted, but its bytes are not supplied in a record body — naming a file field here is refused (see File fields go through their own route below), and its ciphertext is written through its own route instead.

POST /v1/collections/policies/records
Authorization: Bearer <token>
Content-Type: application/json

{
  "external_ref": "policy-4471",
  "record_id": "018f4e2a-7c1b-7a3e-b9d4-2f6c8a1e5b70",
  "schema_version": 4,
  "wrapped_record_key": "<base64>",
  "parent_record_id": "018f4e2a-6b0d-7c22-a1f4-9e7b3c5d2a10",
  "fields": {
    "policy_number": { "value": "PY-4471" },
    "sa_id_number": {
      "ciphertext": "<base64>",
      "nonce": "<base64>",
      "token": "tok_AOWVT36VNVCIESCL"
    }
  }
}

The body carries the record's own identifiers as well as its fields:

  • record_id is required, and you supply it. The client mints the record's identifier — a version-7 UUID — and binds it into every client_encrypted value before the record exists, so the server pins that shape and refuses anything that is not a well-formed version-7 UUID. This is the value the read routes return as id and the value you name in the path of a later read, patch or delete. It is produced on an enrolled device (see Using the HTTP API).
  • wrapped_record_key is required on every create: the record's own key, wrapped to the vault's master key. A client_encrypted field's value is sealed under this per-record key on your device, and the server stores the wrapped key without ever holding it unwrapped. It, too, is produced on an enrolled device.
  • external_ref is required: your own reference for the record.
  • schema_version states the version the values were encrypted under.
  • parent_record_id is optional and names the parent by its record_id; the parent must be in the collection this collection hangs off.

On success the response is 201 Created and returns the record in the read-resolution envelope shape:

{
  "id": "018f4e2a-7c1b-7a3e-b9d4-2f6c8a1e5b70",
  "external_ref": "policy-4471",
  "schema_version": 4,
  "parent_record_id": "018f4e2a-6b0d-7c22-a1f4-9e7b3c5d2a10",
  "fields": {
    "policy_number": { "resolution": "revealed", "value": "PY-4471" },
    "sa_id_number": { "resolution": "tokenised", "token": "tok_AOWVT36VNVCIESCL" }
  }
}

The refusals

  • schema_version_mismatch (409) — the create states a version that is not the vault's current one:

    {
      "code": "schema_version_mismatch",
      "message": "This create states schema version 3, but the vault's current version is 4. Re-read the schema and encrypt under the current version.",
      "details": { "expected_version": 4, "provided_version": 3 }
    }
  • duplicate_external_ref (409) — a live record in the collection already holds that external_ref:

    {
      "code": "duplicate_external_ref",
      "message": "A live record (018f4e2a-7c1b-7a3e-b9d4-2f6c8a1e5b70) in this collection already holds external_ref \"policy-4471\"; a re-run import stops here rather than overwriting it.",
      "details": { "existing_record_id": "018f4e2a-7c1b-7a3e-b9d4-2f6c8a1e5b70", "external_ref": "policy-4471" }
    }

A value that contradicts its field's sensitivity — a sealed part on a plain field, a plain value on a client_encrypted one, a missing required value, a value outside an enum — is a plain 422 naming the field, with no error code.

File fields go through their own route

A create or patch that names a file field in its fields is refused with a plain 422, pointing at the route that does take a file's ciphertext — PUT …/files/:field:

{ "error": "field \"passport_scan\" is a file field: its ciphertext is written through PUT /v1/collections/policyholders/records/:id/files/passport_scan, one file per request, not an ordinary record write." }

A file's bytes can be several megabytes, so they get their own route rather than riding in a record body — there is one way in, not two. Write the file with PUT …/files/:field after the record exists.

GET /v1/collections/:name/records/:id — read a record

GET /v1/collections/policies/records/018f4e2a-7c1b-7a3e-b9d4-2f6c8a1e5b70
Authorization: Bearer <token>

Add ?include=children to expand one level of child records. A child is expanded only where the caller holds a read grant on the child's own collection — grants do not inherit across the parent relationship — and a child none of whose fields the caller may read is omitted whole.

A read by a principal that holds no read grant on any field of the collection returns not_found — the same response a nonexistent, tombstoned or other-vault record takes — so a record's existence never leaks to a credential that may read nothing of it.

A read that would reveal a plaintext value on a vault whose keys have been destroyed is refused up front — before the audit row that read would write — naming the destruction, rather than failing with an unexplained error:

{
  "code": "vault_keys_destroyed",
  "message": "This vault's keys have been destroyed, and it refuses this request.",
  "details": { "destroyed_at": "2026-08-28T10:10:00Z" }
}

A read that discloses no plaintext — one that returns only tokens or masked values — still succeeds on a destroyed vault.

PATCH /v1/collections/:name/records/:id — update named fields

A patch replaces the named fields whole and leaves the rest untouched. The record's schema version is never changed, and the patch must state it.

PATCH /v1/collections/policies/records/018f4e2a-7c1b-7a3e-b9d4-2f6c8a1e5b70
Authorization: Bearer <token>
Content-Type: application/json

{
  "schema_version": 4,
  "fields": {
    "policy_number": { "value": "PY-4471-R" }
  }
}

A client_encrypted field must be patched with all of its parts together — ciphertext, nonce, token, and blind_index where searchable. A file field is the exception: it may not be patched in a record body either, and naming one is refused (see File fields go through their own route). A subset of a non-file client_encrypted field's parts is refused, because it would leave an old token or blind index pointing at a new value:

{
  "code": "incomplete_client_encrypted_patch",
  "message": "Patching client_encrypted field \"sa_id_number\" must supply its parts together (nonce missing); a subset leaves an old token or blind index pointing at a new value.",
  "details": { "missing": ["nonce"] }
}

DELETE /v1/collections/:name/records/:id — delete a record

A delete discards the record's wrapped key, deletes every field value, and stamps a tombstone — reaching every descendant, not just one level. The record row itself stays so the audit trail's references to it still resolve.

DELETE /v1/collections/policies/records/018f4e2a-7c1b-7a3e-b9d4-2f6c8a1e5b70
Authorization: Bearer <token>
{
  "id": "018f4e2a-7c1b-7a3e-b9d4-2f6c8a1e5b70",
  "deleted_at": "2026-08-28T10:00:00Z",
  "values_destroyed": true,
  "wrapped_record_key_discarded": true,
  "descendants_tombstoned": 2,
  "detail": "This record's field values are destroyed and its wrapped record key discarded, so none of its values — including its tokenised and masked values, which the server could otherwise still read — can be produced again. The record row remains only so the audit trail still resolves references to it. 2 descendants were tombstoned in the same transaction."
}

Deletion is idempotent: a second delete reports the original deleted_at and tombstones nothing further.

On this page