REST
The entity REST surface is generated at request time from the addressed tenant’s schema. The routes below exist for every entity that schema declares; nothing is pre-generated per entity.
Concepts live in the block documentation and are not repeated here — the filter and sort grammar in Query DSL, the flag semantics in Request flags, and the full code table in Error codes.
Routes
Section titled “Routes”Paths are the public ones. The gateway matches the prefix /data/ and
strips it before forwarding, so the service itself serves /rest/{entity}; behind the gateway you
address /data/rest/{entity}.
| Method | Path | Purpose | Auth |
|---|---|---|---|
GET | /data/rest/{entity} | List rows | Bearer or API key |
GET | /data/rest/{entity}/id/{id} | Read one row, or stream a file field | Bearer |
POST | /data/rest/{entity} | Create one row (JSON or multipart) | Bearer |
PUT | /data/rest/{entity} | Update, primary key in the body | Bearer |
PUT | /data/rest/{entity}/id/{id} | Update, primary key in the path | Bearer |
PATCH | /data/rest/{entity} | Partial update, primary key in the body | Bearer |
PATCH | /data/rest/{entity}/id/{id} | Partial update, primary key in the path | Bearer |
DELETE | /data/rest/{entity}/id/{id} | Delete (soft by default) | Bearer |
POST | /data/rest/{entity}/id/{id}/restore | Undo a soft delete | Bearer |
POST | /data/rest/{entity}/search | Structured filter search | Bearer |
POST | /data/rest | Bulk operations envelope, default action create | Bearer |
PUT | /data/rest | Bulk operations envelope, default action update | Bearer |
PATCH | /data/rest | Bulk operations envelope, default action update | Bearer or API key |
DELETE | /data/rest | Bulk operations envelope, default action delete | Bearer |
POST | /data/bulk | Import rows into one entity from CSV/JSON/JSONL | Bearer |
PATCH | /data/bulk | Export rows from one entity | Bearer or API key |
Every /data/rest route above is mirrored under /data/anon/rest/… with no token, except restore;
/data/bulk has no anonymous mirror. See Anonymous mirror.
Request headers
Section titled “Request headers”| Header | Required | Value |
|---|---|---|
X-Customer | yes | Customer slug |
X-Product | yes | Product slug |
X-Env | yes | Environment slug |
X-Tenant | yes | Tenant slug — the tenant segment only, not the four-part key |
Authorization | yes, except the anonymous mirror | Bearer <token> from iam.svc |
Content-Type | on bodies | application/json, or multipart/form-data on create |
The four tenant headers are assembled into one tenant key by middleware that runs before the
handler. A request missing or malformed on any of them is rejected with 400 invalid tenant
before routing; a request that reaches a handler with no resolved tenant returns 403
v2.no_tenant.
An expired or invalid token is rejected by the authentication middleware, which writes plain text
Unauthorized with Content-Type: text/plain; charset=utf-8 — not the JSON error envelope.
Three routes accept a static API key in place of a bearer token: GET /data/rest/{entity},
PATCH /data/rest and PATCH /data/bulk. Every other route requires a bearer token.
Response headers
Section titled “Response headers”| Header | When | Value |
|---|---|---|
Content-Type | always | application/json; charset=utf-8, or the file’s own type on a download |
X-RateLimit-Limit | a limiter is installed | Requests allowed in the window |
X-RateLimit-Remaining | a limiter is installed | Requests left |
X-RateLimit-Reset | the limiter reports a reset time | Window reset, unix seconds |
Retry-After | 429 | Seconds until retry, rounded up, minimum 1 |
Content-Disposition | file download, CSV export | attachment; filename="…" |
Rate limiting fails open: when no limiter is installed the request passes and no X-RateLimit-*
headers are set. A denial is 429 with code v2.rate_limited and details carrying profile,
limit, remaining and retry_after.
Response envelope
Section titled “Response envelope”Every REST route except the bulk envelope, the file download and the export route returns the same object.
| Key | Type | When |
|---|---|---|
data | array of objects | Always. A single-row read or write still returns a one-element array |
count | int | Always. Rows in data for this response, not the table total |
total | int | When the engine reports a total count, or on a mutation with no RETURNING |
stats | object | ?stats=true — sql, duration_ms, total_count, include_sql[], rows_affected |
meta | object | ?meta=true — entity, primary_key, fields[] of {name, type, required, nullable} |
states | array | ?states=true — one object per row, keyed by stateflow name, each {field, current, events[]} |
allowed_actions | array of strings | ?allowedactions=true — actions the entity declares an access rule for; create,read,update,delete when it declares none |
page | object | Cursor pagination is active — {has_next_page, end_cursor} |
{ "data": [ { "id": "01JC8…", "code": "EUR", "name": "Euro", "symbol": "€", "fraction_units": 100 } ], "count": 1}?expression=<jq> replaces the body wholesale: the program runs over the composed envelope above,
so .data, .count and .stats are addressable. Zero results yield null, multiple results
yield an array. A program that fails to compile returns 400 v2.bad_expression; one that fails at
run time, or exceeds the 2-second evaluation ceiling, returns 500 v2.expression_runtime.
Error envelope
Section titled “Error envelope”{ "error": "entity ordr not found in schema", "code": "v2.entity_not_found" }code and details are omitted when empty. Branch on code, not on the status — six codes
(v2.query_failed, v2.create_failed, v2.update_failed, v2.delete_failed,
v2.restore_failed, v2.search_query_failed) name the operation that failed and carry a status
chosen from the underlying engine error: 403 on an access denial, 404 on a missing row or entity,
422 on a validation failure, 500 otherwise.
An unrouted path returns a bare 404 with no body — the router’s not-found handler writes no JSON.
GET /data/rest/{entity}
| Path parameter | Required | Description |
|---|---|---|
entity | yes | Entity name as declared in the schema |
Query construction
Section titled “Query construction”| Parameter | Spellings | Effect |
|---|---|---|
select | select, fields | Projection, comma-separated. select wins |
orderby | orderby, order_by | Sort, comma-separated. -field, or a trailing desc / asc |
limit | limit, pagesize | Row limit. limit wins |
offset | offset | Row offset |
page | page | 1-based. Requires a limit; converted to offset = (page-1) × limit. Ignored with an explicit offset |
after / before | Opaque cursor, passed through verbatim | |
include | Relations to expand, comma-separated | |
groupby | GROUP BY columns, comma-separated | |
filter | Legacy ?filter=<key>=<value>, split on the first = and AND-ed as an equality | |
| any unreserved key | field = '<value>', all AND-ed together |
Everything else — the flags that change engine behaviour (deleted, purge, delta,
datastore, version, unmask, untokenize, …) and the response-shape flags (stats, meta,
states, allowedactions) — is enumerated with its real effect in
Request flags.
Filtering
Section titled “Filtering”Any query-string key that is not reserved becomes an equality predicate, AND-ed with the rest.
?status=paid®ion=eu compiles to WHERE status = 'paid' AND region = 'eu'.
GET /data/rest/order?status=paid&select=id,total,status&orderby=-created_at&limit=10&include=customerThe reserved keys, which are consumed rather than turned into predicates:
select orderby order_by limit offset page pagesizeafter before include filter fields groupby havingdepth deleted stats sendstats meta sendmeta statessendstates allowedactions sendallowedactions sendhash unmaskuntokenize purge deletechildren noversion delta scdcdc customstateflow event field expression aliasversion datastore downloadMatching is case-insensitive, so ?sendStats=, ?sendstats= and ?SENDSTATS= are the same key.
Status codes
Section titled “Status codes”| Status | Code | Condition |
|---|---|---|
| 200 | — | Success, including zero matching rows (count: 0) |
| 400 | v2.bad_path | Entity path segment empty |
| 403 | v2.no_tenant | No tenant resolved from the request |
| 404 | v2.entity_not_found | Entity not declared in the resolved schema |
| 403 / 404 / 422 / 500 | v2.query_failed | Engine error; status from the underlying failure |
| 500 | v2.engine_unavailable | Tenant engine, datastore or pool could not be resolved |
Read one row
Section titled “Read one row”GET /data/rest/{entity}/id/{id}
Takes the same query parameters as the list route. The row is matched on the entity’s declared primary key.
| Status | Code | Condition |
|---|---|---|
| 200 | — | One-element data array |
| 404 | v2.row_not_found | No matching row — including a soft-deleted one, which is invisible without ?deleted=true |
A 404 immediately after a successful DELETE is expected: delete is soft by default.
File download
Section titled “File download”GET /data/rest/{entity}/id/{id}?download=true&field={fieldname}
?download=true re-routes this path to stream one file-typed field’s bytes instead of returning
the row. The response carries the stored Content-Type, Content-Disposition: attachment; filename="…", and Content-Length when the size is known. download matches only the literal
value true (any case), not the broader truthy set the other flags accept.
| Status | Code | Condition |
|---|---|---|
| 400 | v2.download_needs_id | ?download=true on a list path with no row id |
| 400 | v2.missing_field | ?field= absent |
| 400 | v2.field_not_file | The named field is not file-typed |
| 404 | v2.field_not_found | The entity has no such field |
| 404 | v2.field_empty | The field holds no stored reference |
| 501 | v2.file_downloader_unconfigured | No downloader wired at boot |
Create
Section titled “Create”POST /data/rest/{entity}
The JSON body is the row payload — there is no wrapper key. Returns 201 with the created
row (all hooks applied) as a one-element data array.
curl -sX POST https://<host>/data/rest/currency \ -H 'Authorization: Bearer <token>' \ -H 'Content-Type: application/json' \ -H 'X-Customer: acme' -H 'X-Product: forge' -H 'X-Env: prod' -H 'X-Tenant: main' \ -d '{"code":"EUR","name":"Euro","symbol":"€","fraction_units":100}'{ "data": [ { "id": "01JC8…", "code": "EUR", "name": "Euro", "symbol": "€", "fraction_units": 100 } ], "count": 1}| Status | Code | Condition |
|---|---|---|
| 201 | — | Created |
| 400 | v2.bad_json | Body is not a parseable JSON object |
| 404 | v2.entity_not_found | Entity not in the schema |
| 403 / 404 / 422 / 500 | v2.create_failed | Engine error, including every validation failure |
Multipart create
Section titled “Multipart create”Send Content-Type: multipart/form-data to the same route to upload file fields alongside scalar
values. Text parts map onto payload fields; a value beginning with {, [, ", -, t, f,
n or a digit is parsed as a JSON literal first and falls back to the literal string. File parts
map onto file-typed fields as {name, content_type, size, data}. The in-memory cap is 32 MiB;
beyond that the parse spills to disk.
| Status | Code | Condition |
|---|---|---|
| 400 | v2.multipart_parse | Malformed multipart body |
| 400 | v2.multipart_empty | No parts |
| 400 | v2.multipart_bad_payload | File part for an undeclared field, a non-file-typed field, or more than one file on one field |
| 501 | v2.file_storage_unconfigured | No storage wired at boot |
Update
Section titled “Update”PUT and PATCH run the same handler and have the same semantics: only the fields present in
the body are written. There is no full-replacement variant — a PUT with a partial body is a
partial update.
| Shape | Route | Primary key from |
|---|---|---|
| Path id | PUT|PATCH /data/rest/{entity}/id/{id} | The {id} path segment |
| Body id | PUT|PATCH /data/rest/{entity} | The primary-key field in the body |
The primary key is stripped from the SET clause and used only as the filter, so an entity whose
key is marked immutable still updates cleanly. A body key that contradicts the path id is
refused rather than ignored.
curl -sX PATCH https://<host>/data/rest/currency/id/01JC8… \ -H 'Authorization: Bearer <token>' \ -H 'Content-Type: application/json' \ -H 'X-Customer: acme' -H 'X-Product: forge' -H 'X-Env: prod' -H 'X-Tenant: main' \ -d '{"name":"Euro (updated)"}'| Status | Code | Condition |
|---|---|---|
| 200 | — | Updated; the row is returned as a one-element data array |
| 400 | v2.bad_json | Body is not a parseable JSON object |
| 400 | v2.missing_id | Body-id shape with no primary key in the body |
| 422 | v2.id_mismatch | Body primary key differs from the path id |
| 403 / 404 / 422 / 500 | v2.update_failed | Engine error |
Delete and purge
Section titled “Delete and purge”DELETE /data/rest/{entity}/id/{id}
Delete is soft by default: the row is retained with its delete markers stamped and stops being
returned by reads. ?purge=true makes it a hard DELETE, bypasses version bookkeeping, and
stacks the entity’s purge access rule.
| Query parameter | Effect |
|---|---|
purge | Hard delete instead of soft |
deletechildren | Parsed, then ignored — no cascade-delete hook is registered |
datastore | Target datastore |
stats | Add the stats block |
| Status | Code | Condition |
|---|---|---|
| 200 | — | Deleted |
| 400 | v2.bad_path | Entity or id segment empty |
| 403 / 404 / 422 / 500 | v2.delete_failed | Engine error |
A soft delete compiles to an UPDATE, so the returned row is the post-image with the delete
markers set. A purge returns the row as it last existed.
Restore
Section titled “Restore”POST /data/rest/{entity}/id/{id}/restore
Undoes a soft delete. No body required. The caller must hold the entity’s restore action
permission; the access gate enforces it.
| Status | Code | Condition |
|---|---|---|
| 200 | — | Restored |
| 400 | v2.bad_path | Entity or id segment empty |
| 403 / 404 / 422 / 500 | v2.restore_failed | Engine error, including an entity with no soft-delete trait |
Search
Section titled “Search”POST /data/rest/{entity}/search
A body-driven filter that is AND-ed onto a query otherwise built from the query string — so pagination, sort, projection and includes still come from the URL and apply here unchanged.
{ "filter": { "status": "shipped", "region": "eu" }}curl -sX POST 'https://<host>/data/rest/order/search?limit=20&orderby=-created_at' \ -H 'Authorization: Bearer <token>' \ -H 'Content-Type: application/json' \ -H 'X-Customer: acme' -H 'X-Product: forge' -H 'X-Env: prod' -H 'X-Tenant: main' \ -d '{"filter":{"status":"shipped","region":"eu"}}'The response is the standard envelope. An empty body is legal and behaves as a list.
| Status | Code | Condition |
|---|---|---|
| 200 | — | Success |
| 400 | v2.search_body_parse_failed | Body is not decodable JSON |
| 400 | v2.bad_path | Entity path segment empty |
| 501 | v2.search_text_not_supported | Body carries query — free-text search has no client wired |
| 501 | v2.search_dsl_not_supported | filter is a string rather than an object |
| 403 / 404 / 422 / 500 | v2.search_query_failed | Engine error |
Bulk operations envelope
Section titled “Bulk operations envelope”POST · PUT · PATCH · DELETE on /data/rest, with no entity segment. The whole batch
runs inside one transaction.
{ "operations": [ {"action": "create", "data": {"customer": {"name": "Acme"}}}, {"action": "update", "data": {"order": {"id": "01J…", "status": "shipped"}}}, {"action": "delete", "data": {"item": {"id": "01J…"}}} ]}| Field | Rule |
|---|---|
operations | Non-empty array. Results come back in input order |
action | create, update, delete, restore, upsert. Omit it to inherit the verb default |
data | Object with exactly one key — the entity name. Zero keys or two keys fail the operation |
The HTTP verb supplies the default action for operations that omit one: POST → create,
PUT and PATCH → update, DELETE → delete.
Success is 200:
{ "operations": [ {"action": "create", "entity": "customer", "data": [{"id": "01J…"}], "affected_rows": 1} ]}?stats=true adds a stats block, and on this route it has its own shape —
{operations_run, operations_ok, operations_fail}, not the query stats block.
| Status | Code | Condition |
|---|---|---|
| 200 | — | Every operation committed |
| 400 | v2.body_read | Body could not be read |
| 400 | v2.bad_json | Envelope is not parseable |
| 400 | v2.empty_envelope | operations empty or absent |
| 403 | v2.no_tenant | No tenant resolved |
| 422 | v2.bulk_failed | An operation failed; everything rolled back |
| 500 | v2.engine_unavailable | Engine could not be resolved |
What the envelope does not support
Section titled “What the envelope does not support”| Limit | Detail |
|---|---|
| Custom primary keys | update, delete, restore read the literal key id, and upsert conflicts on the literal column id. An entity with a differently-named primary key is unaddressable here — use the single-row routes, which honour the declared key |
| Multi-row payloads | Only create accepts an array of objects. update, delete, restore and upsert reject anything but a single object |
| Multi-entity operations | One entity per operation; use one operation per entity |
| Per-operation datastore | ?datastore= applies to the whole batch |
A hard delete inside the envelope is expressed per row: {"data": {"item": {"id": "…", "purge": true}}}.
Import and export
Section titled “Import and export”/data/bulk is a separate route from the operations envelope: it moves rows in and out of one
entity in a file format, and does no transaction wrapping.
| Method | Purpose | Query parameters |
|---|---|---|
POST | Import; the request body is the raw data | entity (required), format, chunk (default 1000), skiperrors, datastore |
PATCH | Export; empty request body | entity (required), format, limit, fields, orderby, filter (repeatable k=v), datastore |
format accepts csv, json, jsonl, and ndjson as an alias for jsonl. It defaults to
json. No other format is accepted.
curl -sX POST 'https://<host>/data/bulk?entity=customer&format=csv&chunk=500&skiperrors=true' \ -H 'Authorization: Bearer <token>' \ -H 'X-Customer: acme' -H 'X-Product: forge' -H 'X-Env: prod' -H 'X-Tenant: main' \ --data-binary @customers.csv{ "imported": 482, "skipped": 3, "errors": [] }curl -sX PATCH 'https://<host>/data/bulk?entity=customer&format=csv&fields=id,name&orderby=-created_at&filter=region=eu' \ -H 'Authorization: Bearer <token>' \ -H 'X-Customer: acme' -H 'X-Product: forge' -H 'X-Env: prod' -H 'X-Tenant: main' \ -o customers.csvExport sets Content-Type to text/csv; charset=utf-8 (plus Content-Disposition: attachment; filename="<entity>.csv"), application/x-ndjson; charset=utf-8, or
application/json; charset=utf-8.
| Status | Code | Condition |
|---|---|---|
| 200 | — | Success |
| 400 | v2.missing_entity | ?entity= absent |
| 400 | v2.bad_format | Unsupported ?format= |
| 403 | v2.no_tenant | No tenant resolved |
| 500 | v2.import_failed | Import failed |
Anonymous mirror
Section titled “Anonymous mirror”Every route below is served under /data/anon/rest/… with no Authorization header. The four
tenant headers are still required, and the request is still treated as external, so
access rules and
row-level security apply in full — an
anonymous caller sees exactly what the rules grant an unauthenticated principal.
| Mirrored | Not mirrored |
|---|---|
POST|PUT|PATCH|DELETE /data/anon/rest | POST /data/rest/{entity}/id/{id}/restore |
GET /data/anon/rest/{entity} | POST /data/rest/{entity}/action/{action} |
GET /data/anon/rest/{entity}/id/{id} | POST|PATCH /data/bulk |
POST /data/anon/rest/{entity} | |
PUT|PATCH /data/anon/rest/{entity} | |
PUT|PATCH /data/anon/rest/{entity}/id/{id} | |
DELETE /data/anon/rest/{entity}/id/{id} | |
POST /data/anon/rest/{entity}/search |
Worked lifecycle
Section titled “Worked lifecycle”Create, read back, update, delete, and confirm the soft delete — the sequence the block’s own functional suite runs against PostgreSQL, asserting each step in the response and directly in the database.
curl -sX POST https://<host>/data/anon/rest/currency \ -H 'Content-Type: application/json' \ -H 'X-Customer: acme' -H 'X-Product: forge' -H 'X-Env: dev' -H 'X-Tenant: dev' \ -d '{"code":"EUR","name":"Euro","symbol":"€","fraction_units":100}'201 {"data":[{"id":"01JC8…","code":"EUR",…}],"count":1}curl -s https://<host>/data/anon/rest/currency/id/01JC8… \ -H 'X-Customer: acme' -H 'X-Product: forge' -H 'X-Env: dev' -H 'X-Tenant: dev'200 {"data":[{"id":"01JC8…","code":"EUR",…}],"count":1}curl -sX PATCH https://<host>/data/anon/rest/currency/id/01JC8… \ -H 'Content-Type: application/json' \ -H 'X-Customer: acme' -H 'X-Product: forge' -H 'X-Env: dev' -H 'X-Tenant: dev' \ -d '{"name":"Euro (updated)"}'200 {"data":[{"id":"01JC8…","name":"Euro (updated)",…}],"count":1}curl -sX DELETE https://<host>/data/anon/rest/currency/id/01JC8… \ -H 'X-Customer: acme' -H 'X-Product: forge' -H 'X-Env: dev' -H 'X-Tenant: dev'200 delete succeeded — the row is retained with its delete markers stamped404 the same GET now returns v2.row_not_foundContinue with
Section titled “Continue with”- GraphQL — the same entities over
POST /data/graphql - Entity metadata — schema introspection and codegen exports
- Request flags — every flag, its spellings, and which ones are inert
- Query DSL — the operator grammar the query string cannot reach
- Error codes — the complete code table