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
If-Matchoptional, on update and deleteThe row’s concurrency token as returned in ETag, quoted. Only entities with a concurrency strategy can check it; * is ignored

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 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="…"
ETagsingle-row responses on an entity with a concurrency strategyThe row’s token, quoted — hand it back as If-Match

Rate limiting applies when a limiter is configured; without one, requests pass and no X-RateLimit-* headers are set. Configure a limiter, or place one at your edge, for any surface reachable by callers you do not control. A denial is 429 with code rate_limited and details carrying profile, limit, remaining and retry_after.

Every REST route except the file download and the export route returns the same object. The bulk envelope returns one such object per operation (see Bulk operations envelope). Rows live under data; everything about the rows lives under meta; failures under errors; non-fatal notices under warnings.

KeyTypeWhen
dataarray of objectsAlways. A single-row read or write still returns a one-element array
metaobjectAlways. Everything about the rows — see the table below
errorsarray of {code, message, details?, path?}Only when something failed. A REST route that fails has an empty data and a non-2xx status
warningsarray of {code, message, path?}Only when the request succeeded with something worth telling you

Keys under meta:

KeyTypeWhen
entitystringAlways
countintAlways. Rows in data for this response, not the table total
totalintReads only, when the engine reports a total count
affectedintMutations only — rows the statement touched, including 0
pageobjectCursor pagination is active — {has_next_page, end_cursor}
statsobject?stats=truesql, duration_ms, total_count, include_sql[], rows_affected
schemaobject?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
{
"data": [
{ "id": "01JC8…", "code": "EUR", "name": "Euro", "symbol": "", "fraction_units": 100 }
],
"meta": { "entity": "currency", "count": 1 }
}

?expression=<jq> replaces the body wholesale: the program runs over the composed envelope above, so .data, .meta.count and .meta.stats are addressable. Zero results yield null, multiple results yield an array. A program that fails to compile returns 400 bad_expression; one that fails at run time, or exceeds the 2-second evaluation ceiling, returns 500 expression_runtime.

{ "errors": [ { "code": "entity_not_found", "message": "entity ordr not found in schema" } ] }

errors is always an array; code and details are omitted when empty. Branch on code, not on the status — six codes (query_failed, create_failed, update_failed, delete_failed, restore_failed, 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)
400bad_pathEntity path segment empty
403no_tenantNo tenant resolved from the request
404entity_not_foundEntity not declared in the resolved schema
403 / 404 / 422 / 500query_failedEngine error; status from the underlying failure
500engine_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. On an entity with a concurrency strategy the response carries ETag: "<token>"
404row_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
400download_needs_id?download=true on a list path with no row id
400missing_field?field= absent
400field_not_fileThe named field is not file-typed
404field_not_foundThe entity has no such field
404field_emptyThe field holds no stored reference
501file_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 }
],
"meta": { "entity": "currency", "count": 1 }
}
StatusCodeCondition
201Created. On an entity with a concurrency strategy the response carries ETag: "<token>"
400bad_jsonBody is not a parseable JSON object
404entity_not_foundEntity not in the schema
403 / 404 / 409 / 422 / 500create_failedEngine error, including every validation failure — a reference to a missing parent is 422 reference_missing; a unique violation is 409

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
400multipart_parseMalformed multipart body
400multipart_emptyNo parts
400multipart_bad_payloadFile part for an undeclared field, a non-file-typed field, or more than one file on one field
501file_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.

On an entity with a concurrency strategy the update may carry the token the caller read — If-Match: "<token>", or "expect": {"version": 7} / "expect": {"updatedon": "…"} in the body (expect is not written to the row). The update applies only if the row still carries that token; the response ETag is the new one.

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, with ETag on a concurrency entity
400bad_jsonBody is not a parseable JSON object
400missing_idBody-id shape with no primary key in the body
400bad_preconditionIf-Match or expect is not a token
400precondition_conflictIf-Match and expect disagree
422id_mismatchBody primary key differs from the path id
412update_failedThe row carries another token: details.code: version_mismatch, details.expected, details.current
428update_failedThe entity requires a token and none was sent: details.code: precondition_required
400 / 403 / 404 / 409 / 422 / 500update_failedEngine error; 400 with details.code: precondition_unsupported / precondition_invalid when the token cannot be checked

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
datastoreTarget datastore
statsAdd the stats block

What happens to rows that reference the deleted one is declared on the reference (onDelete): restrict refuses the delete, cascade deletes the children the same way, setnull clears their column. On a concurrency entity the delete may carry If-Match: "<token>" (or expect in a JSON body) and applies only if the row still carries it.

StatusCodeCondition
200Deleted
400bad_pathEntity or id segment empty
400bad_precondition / precondition_conflictThe token does not parse, or If-Match and expect disagree
409delete_failedChild rows still reference the row: details.code: reference_restrict, details.child_entity, details.count
412delete_failedThe row carries another token: details.code: version_mismatch
428delete_failedThe entity requires a token and none was sent
400 / 403 / 404 / 422 / 500delete_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
400bad_pathEntity or id segment empty
403 / 404 / 422 / 500restore_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
400search_body_parse_failedBody is not decodable JSON
400bad_pathEntity path segment empty
501search_text_not_supportedBody carries query — free-text search has no client wired
501search_dsl_not_supportedfilter is a string rather than an object
403 / 404 / 422 / 500search_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": [
{
"data": [{"id": "01J…"}],
"meta": {"action": "create", "entity": "customer", "count": 1, "affected": 1}
}
]
}

Each entry is the standard envelope for that operation, so a client decodes one shape everywhere. ?stats=true adds a top-level meta.batch block — {operations_run, ok, fail, duration_ms} — the batch’s own counters, not the per-query stats block.

StatusCodeCondition
200Every operation committed
400body_readBody could not be read
400bad_jsonEnvelope is not parseable
400empty_envelopeoperations empty or absent. A body that carries query or queries instead is a bulk read and is routed there
403no_tenantNo tenant resolved
422bulk_failedAn operation failed; everything rolled back
500engine_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}}}. A concurrency token likewise: {"data": {"order": {"id": "…", "status": "shipped", "expect": {"version": 7}}}} on an update or delete row — expect is read and removed, never written. A mismatched token fails the operation, and with it the batch.

QUERY /data/rest runs one query, or a named batch of queries, in one request. The QUERY verb is the contract. Clients and intermediaries that cannot send a custom verb use POST /data/rest with X-HTTP-Method-Override: QUERY or ?_method=query. For now, a POST /data/rest whose body is the read shape — query or queries, no operations — is also accepted as an alternate and reaches the same surface; do not build on it, send QUERY. The QUERY route accepts an API key as well as a bearer token; the POST forms need a bearer token.

{
"queries": {
"pending": { "from": "track_orders", "where": { "status": "pending" } },
"recent": "track_orders | where created_at > $since | order by created_at desc | limit 20"
},
"variables": { "since": "2026-09-01T00:00:00Z" }
}
FieldRule
queryOne query — a pipe-syntax string or a JSON object. The response is the plain envelope, as for GET /data/rest/{entity}
queriesAn object of name → query, at most 25. Names are identifiers (^[_A-Za-z][_0-9A-Za-z]*$), unique within the request, and never batch or queries
variablesValues for $name placeholders in the queries. A placeholder with no value fails the query

Exactly one of query and queries is required. Reads only: a mutation, a raw $expr fragment, or a where the parser could not understand are refused rather than run.

{
"data": { "pending": [ { "id": "01J…", "status": "pending" } ], "recent": null },
"meta": {
"queries": { "pending": { "entity": "track_orders", "count": 1 } },
"batch": { "queries_run": 2, "ok": 1, "fail": 1, "duration_ms": 7 }
},
"errors": [ { "code": "query_failed", "message": "", "path": ["recent"] } ]
}

data is keyed by query name in request order; meta.queries.<name> carries each query’s own meta, and meta.batch the batch counters. A query that fails sets data.<name> to null and adds an errors[] entry whose path names it, while the other queries still return their rows — the response is 200. Only a malformed batch is a 400:

StatusCodeCondition
400query_missingNeither query nor queries, or an empty queries object
400bad_queriesqueries is not a JSON object
400too_many_queriesMore than 25 queries; details.max, details.got
400bad_query_name / reserved_query_name / duplicate_query_nameA name that is not an identifier, is batch or queries, or repeats
400query_parse_failed / query_kind_refused / query_filter_unparsed / query_param_missingThe single query could not be parsed, is not a read, lost its filter, or names a variable that was not supplied. In the batch form the same failures are per-query errors[] entries
403no_tenantNo tenant resolved

/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
400missing_entity?entity= absent
400bad_formatUnsupported ?format=
403no_tenantNo tenant resolved
500import_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",…}],"meta":{"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",…}],"meta":{"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)",…}],"meta":{"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 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