Skip to content
Talk to our solutions team

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.

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}.

MethodPathPurposeAuth
GET/data/rest/{entity}List rowsBearer or API key
GET/data/rest/{entity}/id/{id}Read one row, or stream a file fieldBearer
POST/data/rest/{entity}Create one row (JSON or multipart)Bearer
PUT/data/rest/{entity}Update, primary key in the bodyBearer
PUT/data/rest/{entity}/id/{id}Update, primary key in the pathBearer
PATCH/data/rest/{entity}Partial update, primary key in the bodyBearer
PATCH/data/rest/{entity}/id/{id}Partial update, primary key in the pathBearer
DELETE/data/rest/{entity}/id/{id}Delete (soft by default)Bearer
POST/data/rest/{entity}/id/{id}/restoreUndo a soft deleteBearer
POST/data/rest/{entity}/searchStructured filter searchBearer
POST/data/restBulk operations envelope, default action createBearer
PUT/data/restBulk operations envelope, default action updateBearer
PATCH/data/restBulk operations envelope, default action updateBearer or API key
DELETE/data/restBulk operations envelope, default action deleteBearer
POST/data/bulkImport rows into one entity from CSV/JSON/JSONLBearer
PATCH/data/bulkExport rows from one entityBearer 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.

HeaderRequiredValue
X-CustomeryesCustomer slug
X-ProductyesProduct slug
X-EnvyesEnvironment slug
X-TenantyesTenant slug — the tenant segment only, not the four-part key
Authorizationyes, except the anonymous mirrorBearer <token> from iam.svc
Content-Typeon bodiesapplication/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.

HeaderWhenValue
Content-Typealwaysapplication/json; charset=utf-8, or the file’s own type on a download
X-RateLimit-Limita limiter is installedRequests allowed in the window
X-RateLimit-Remaininga limiter is installedRequests left
X-RateLimit-Resetthe limiter reports a reset timeWindow reset, unix seconds
Retry-After429Seconds until retry, rounded up, minimum 1
Content-Dispositionfile download, CSV exportattachment; 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.

Every REST route except the bulk envelope, the file download and the export route returns the same object.

KeyTypeWhen
dataarray of objectsAlways. A single-row read or write still returns a one-element array
countintAlways. Rows in data for this response, not the table total
totalintWhen the engine reports a total count, or on a mutation with no RETURNING
statsobject?stats=truesql, duration_ms, total_count, include_sql[], rows_affected
metaobject?meta=trueentity, primary_key, fields[] of {name, type, required, nullable}
statesarray?states=true — one object per row, keyed by stateflow name, each {field, current, events[]}
allowed_actionsarray of strings?allowedactions=true — actions the entity declares an access rule for; create,read,update,delete when it declares none
pageobjectCursor 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": "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 parameterRequiredDescription
entityyesEntity name as declared in the schema
ParameterSpellingsEffect
selectselect, fieldsProjection, comma-separated. select wins
orderbyorderby, order_bySort, comma-separated. -field, or a trailing desc / asc
limitlimit, pagesizeRow limit. limit wins
offsetoffsetRow offset
pagepage1-based. Requires a limit; converted to offset = (page-1) × limit. Ignored with an explicit offset
after / beforeOpaque cursor, passed through verbatim
includeRelations to expand, comma-separated
groupbyGROUP BY columns, comma-separated
filterLegacy ?filter=<key>=<value>, split on the first = and AND-ed as an equality
any unreserved keyfield = '<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.

Any query-string key that is not reserved becomes an equality predicate, AND-ed with the rest. ?status=paid&region=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=customer

The reserved keys, which are consumed rather than turned into predicates:

select orderby order_by limit offset page pagesize
after before include filter fields groupby having
depth deleted stats sendstats meta sendmeta states
sendstates allowedactions sendallowedactions sendhash unmask
untokenize purge deletechildren noversion delta scd
cdc customstateflow event field expression alias
version datastore download

Matching is case-insensitive, so ?sendStats=, ?sendstats= and ?SENDSTATS= are the same key.

StatusCodeCondition
200Success, including zero matching rows (count: 0)
400v2.bad_pathEntity path segment empty
403v2.no_tenantNo tenant resolved from the request
404v2.entity_not_foundEntity not declared in the resolved schema
403 / 404 / 422 / 500v2.query_failedEngine error; status from the underlying failure
500v2.engine_unavailableTenant engine, datastore or pool could not be resolved

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.

StatusCodeCondition
200One-element data array
404v2.row_not_foundNo 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.

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.

StatusCodeCondition
400v2.download_needs_id?download=true on a list path with no row id
400v2.missing_field?field= absent
400v2.field_not_fileThe named field is not file-typed
404v2.field_not_foundThe entity has no such field
404v2.field_emptyThe field holds no stored reference
501v2.file_downloader_unconfiguredNo downloader wired at boot

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.

Terminal window
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
}
StatusCodeCondition
201Created
400v2.bad_jsonBody is not a parseable JSON object
404v2.entity_not_foundEntity not in the schema
403 / 404 / 422 / 500v2.create_failedEngine error, including every validation failure

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.

StatusCodeCondition
400v2.multipart_parseMalformed multipart body
400v2.multipart_emptyNo parts
400v2.multipart_bad_payloadFile part for an undeclared field, a non-file-typed field, or more than one file on one field
501v2.file_storage_unconfiguredNo storage wired at boot

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.

ShapeRoutePrimary key from
Path idPUT|PATCH /data/rest/{entity}/id/{id}The {id} path segment
Body idPUT|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.

Terminal window
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)"}'
StatusCodeCondition
200Updated; the row is returned as a one-element data array
400v2.bad_jsonBody is not a parseable JSON object
400v2.missing_idBody-id shape with no primary key in the body
422v2.id_mismatchBody primary key differs from the path id
403 / 404 / 422 / 500v2.update_failedEngine error

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 parameterEffect
purgeHard delete instead of soft
deletechildrenParsed, then ignored — no cascade-delete hook is registered
datastoreTarget datastore
statsAdd the stats block
StatusCodeCondition
200Deleted
400v2.bad_pathEntity or id segment empty
403 / 404 / 422 / 500v2.delete_failedEngine 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.

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.

StatusCodeCondition
200Restored
400v2.bad_pathEntity or id segment empty
403 / 404 / 422 / 500v2.restore_failedEngine error, including an entity with no soft-delete trait

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"
}
}
Terminal window
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.

StatusCodeCondition
200Success
400v2.search_body_parse_failedBody is not decodable JSON
400v2.bad_pathEntity path segment empty
501v2.search_text_not_supportedBody carries query — free-text search has no client wired
501v2.search_dsl_not_supportedfilter is a string rather than an object
403 / 404 / 422 / 500v2.search_query_failedEngine error

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…"}}}
]
}
FieldRule
operationsNon-empty array. Results come back in input order
actioncreate, update, delete, restore, upsert. Omit it to inherit the verb default
dataObject 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: POSTcreate, PUT and PATCHupdate, DELETEdelete.

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.

StatusCodeCondition
200Every operation committed
400v2.body_readBody could not be read
400v2.bad_jsonEnvelope is not parseable
400v2.empty_envelopeoperations empty or absent
403v2.no_tenantNo tenant resolved
422v2.bulk_failedAn operation failed; everything rolled back
500v2.engine_unavailableEngine could not be resolved
LimitDetail
Custom primary keysupdate, 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 payloadsOnly create accepts an array of objects. update, delete, restore and upsert reject anything but a single object
Multi-entity operationsOne 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}}}.

/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.

MethodPurposeQuery parameters
POSTImport; the request body is the raw dataentity (required), format, chunk (default 1000), skiperrors, datastore
PATCHExport; empty request bodyentity (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.

Terminal window
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": [] }
Terminal window
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.csv

Export 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.

StatusCodeCondition
200Success
400v2.missing_entity?entity= absent
400v2.bad_formatUnsupported ?format=
403v2.no_tenantNo tenant resolved
500v2.import_failedImport failed

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.

MirroredNot mirrored
POST|PUT|PATCH|DELETE /data/anon/restPOST /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

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.

Terminal window
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}
Terminal window
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}
Terminal window
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}
Terminal window
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 stamped
404 the same GET now returns v2.row_not_found
  • 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