Skip to content
Talk to our solutions team

Stateflows and operations

The routes on this page invoke what the tenant declared — stateflows, actions, scripted endpoints — move data in and out in bulk, reshape the tenant’s database, and report the service’s own runtime state. Generic entity CRUD lives on REST and GraphQL; the shared read parameters are on Query parameters.

Paths below are public paths. The gateway matches the prefix /data/ and strips it before forwarding, so /data/admin/data/plans reaches data.svc as /admin/data/plans. Every route except the two probes requires the four CPET headers and a bearer token; PATCH /data/bulk is the one route that also accepts an API key. See the request conventions.

MethodPathGate
GET/data/stateflowsBearer
GET/data/stateflow/{name}Bearer
GET POST PUT PATCH DELETE/data/actions/{name}Bearer
POST/data/rest/{entity}/action/{action}Bearer
GET POST PUT PATCH DELETE/data/x/{path}Bearer, then the endpoint’s own mode
DELETE/data/cacheBearer
GET/data/contentstore/listBearer
POST/data/bulkBearer
PATCH/data/bulkBearer or API key
POST/data/admin/data/plansGenerate
GET/data/admin/data/plans/{id}Read
POST/data/admin/data/plans/{id}/applyApply
GET/data/admin/data/appliedRead
POST/data/admin/data/seeds/runsSeed
GET/data/admin/data/seedsRead
POST/data/admin/data/retention/runsRetention
GET/data/admin/data/retention/previewRead
POST/data/admin/data/retention/handoff/claimRetention
POST/data/admin/data/retention/handoff/confirmRetention
GET/data/admin/data/retention/handoff/pendingRead
GET/data/admin/data/retention/archivesRead
GET/data/admin/data/retention/entitiesRead
GET/data/admin/data/locksRead
DELETE/data/admin/data/locks/{resource}Locks
GET/data/admin/data/actionsRead
GET/data/admin/data/materialize/pendingRead
GET/data/admin/data/materialize/statsRead
POST/data/admin/data/materialize/drainMaintenance
GET/data/admin/data/references/{entity}Read
GET/data/admin/data/hashesRead
GET/data/superadmin/tenantsuperadmin
DELETE/data/superadmin/tenant/{tenant}/cachesuperadmin
/data/superadmin/tenant/{tenant}/…superadmin — one mirror per admin route
GET/data/healthnone
GET/data/readynone

Two read-only routes. They report the shape of the declared machines so a client can decide which transitions to offer; they do not start, signal, or advance anything.

MethodPathPurpose
GET/data/stateflowsEvery declared machine, sorted by name
GET/data/stateflow/{name}One machine

GET /data/stateflow/{name} returns the machine object directly.

{
"name": "order_lifecycle",
"field": "status",
"initial_state": "draft",
"states": {
"draft": {
"name": "draft",
"events": { "submit": { "name": "submit", "target": "submitted" } }
},
"submitted": {
"name": "submitted",
"events": {
"approve": { "name": "approve", "target": "approved", "has_guard": true },
"reject": { "name": "reject", "target": "draft" }
}
},
"shipped": { "name": "shipped", "type": "final" }
}
}
FieldTypeNotes
namestringMachine name — the {name} path segment
fieldstringEntity field the machine governs
initial_statestringState applied on create when the field is omitted
statesobjectKeyed by state name
states.*.namestringSame as the key
states.*.typestringfinal when declared; omitted otherwise
states.*.eventsobjectKeyed by event name; omitted when the state declares none
states.*.events.*.targetstringDestination state — the value a caller writes to fire it
states.*.events.*.has_guardboolPresent only when true
states.*.events.*.has_actionsboolPresent only when true

Guard, action, entry and exit bodies are never serialised. A client learns that an event has a guard, not what the guard tests.

GET /data/stateflows wraps the same objects:

{
"stateflows": [ { "name": "order_lifecycle", "field": "status", "initial_state": "draft", "states": {} } ],
"count": 1
}
StatusCodeCause
400v2.missing_nameDetail route called with an empty name
403v2.no_tenantNo tenant key on the request context
404v2.stateflow_not_foundNo machine of that name in the tenant’s schema
500v2.engine_unavailableThe tenant’s engine bundle could not be resolved

There is no transition endpoint. You fire a transition by writing the state field on the entity’s normal update route; the engine resolves the current state, matches an event by its target, evaluates the guard, and either allows or rejects the write.

MethodPathBehaviour
POST/data/rest/{entity}Create. Applies initial when the field is omitted; an undeclared state is rejected
PATCH/data/rest/{entity}/id/{id}Fire — send the state field with the target value
PUT/data/rest/{entity}/id/{id}Same handler, same behaviour
PATCH / PUT/data/rest/{entity}Same, with the primary key in the body
Terminal window
curl -X PATCH https://<host>/data/rest/sales_order/id/01J... \
-H 'Authorization: Bearer <token>' \
-H 'Content-Type: application/json' \
-d '{"status":"approved","approved_by":"u-admin"}'

A rejected transition returns 422 with code v2.update_failed; a guard that fails to compile or throws returns 500 with the same code. The full rejection matrix and the per-message status mapping are on the Stateflows block page.

An action is a named, parameterised SQL statement declared in the tenant’s definitions. One route serves all of them; the HTTP verb selects which section of the action runs.

MethodSectionParameters come from
GETreadQuery string
POSTcreateJSON body
PUTupdateJSON body
PATCHupdateJSON body
DELETEdeleteQuery string

_method and _format are stripped from the parameter map before binding — they never reach the statement. A request with no body is valid when every parameter has a default.

Terminal window
curl 'https://<host>/data/actions/orders_by_customer?customer_id=01J2...' \
-H 'Authorization: Bearer <token>'
{ "total": 0, "data": [ { "id": "01J8...", "total_amount": 4200 } ], "count": 1 }

total is the affected-row count and is always present. data and count appear only when the statement returned rows.

StatusCodeCause
400v2.bad_pathEmpty {name} segment
400v2.bad_paramsUnreadable body or invalid JSON
403v2.no_tenantNo tenant key on the request context
404v2.action_not_foundNo action of that name, or the tenant has no registry
405v2.action_method_not_allowedThe action declares no section for that verb. An Allow header and details.allowed[] list what it does declare
500v2.action_failedThe statement failed
500v2.engine_unexpected_typeHost wiring fault

The catalogue of what a tenant declares is at GET /data/admin/data/actions. There is no anonymous mirror — declared actions are a service contract, not an anonymously discoverable surface.

POST /data/rest/{entity}/action/{action} invokes the same registry through the older URL shape. It defaults to the read section; send "_method": "PUT" in the body to select another. The key is uppercased and removed before binding.

{ "data": [], "count": 0, "total": 3 }

The alias always returns all three keys, unlike /data/actions/{name}.

Additional codes on this route: 400 v2.bad_json, 400 v2.body_read, 500 v2.registry_unavailable.

/data/x/{path} dispatches to an endpoint declared in the tenant’s definitions and returns whatever the script returns. Five verbs are registered, but each endpoint accepts exactly one — its declared verb, or POST for a mutate endpoint and GET for a query endpoint when no verb is declared.

AspectBehaviour
Response bodyThe script’s return value, serialised as the whole body. No data/count envelope
StatusAlways 200 on success
Request bodyJSON, capped at 1 MiB. A GET on a query endpoint reads no body
Query stringReaches the script as string values; a repeated key collapses to the first
HeadersOnly an allow-listed subset is exposed to the script
Path parametersDeclared templates are not matched. Lookup is exact-path, so only literal paths resolve
X-CacheHIT or MISS on endpoints that declare a response-cache TTL
StatusCodeCause
400v2.bad_bodyUnparseable JSON, or a body over 1 MiB
401v2.unauthenticatedThe endpoint requires a principal and none resolved
403v2.forbiddenThe endpoint’s auth mode refused the caller
403v2.no_tenantNo tenant key on the request context
404v2.endpoint_not_foundNo endpoint at that path, none declared, or it is disabled
404v2.endpoint_path_missingEmpty path after /data/x/
405v2.method_not_allowedWrong verb. The Allow header names the right one
429v2.rate_limitedThe endpoint’s own rate-limit profile refused the call
500v2.script_errorThe script threw
500v2.script_engine_missingNo script engine for this tenant

See Scripted endpoints for the declaration grammar and what a script can call.

These paths were served by the previous release and no longer exist. There is no redirect and no deprecation header — a request to any of them returns a bare 404.

Retired pathReplacement
GET /data/ramlschemaGET /data/schema (JSON), GET /data/schema/graphql (SDL), GET /data/schema/openapi.json (OpenAPI 3) — see Schema export for all eight formats
GET /data/entities/listGET /data/schema/entities
GET /data/entities/list/{entity}GET /data/schema/entities/{entity}
GET /data/entities/references/{entity}GET /data/admin/data/references/{entity} — now admin-gated
GET /data/hashes/listGET /data/admin/data/hashes — admin-gated, and a stub
GET /data/entities/diffPOST /data/admin/data/plans
POST /data/entities/diff/syncPOST /data/admin/data/plans then POST /data/admin/data/plans/{id}/apply
POST /data/entities/diff/sync/{entity}None. Plans are per datastore; there is no per-entity apply
POST /data/entities/index/{entity}None
POST /data/entities/validateNone
GET /data/stateflows/{name}GET /data/stateflow/{name}

Nothing in the service emits RAML, and no tenant property re-enables it. The schema routes need no configuration flag — they serve whatever the tenant’s definitions resolve to.

The xlsx upload and download that shared the /data/bulk path are also gone. That path now imports and exports CSV, JSON and JSONL — see Bulk import and export.

DELETE /data/cache drops every cached per-tenant resource for the calling tenant — folded schema, connection pool, engine, endpoint and action registries. The next request rebuilds them from source.

{ "cleared": true, "tenant": "acme:forge:prod:main" }
StatusCodeCause
403v2.no_tenantNo tenant key on the request context
500v2.cache_clear_failedInvalidation failed

Rows are not cached, so this route never affects query results — only which definitions the next request compiles against. See Caching.

GET /data/contentstore/list returns the tenant’s content-store catalogue as a JSON map, sourced from the product configuration stream. Only data.svc mounts this route; sibling hosts that embed the same API do not. Failure is 500 with code v2.contentstore_list_failed.

/data/admin/data/* is plane-of-control: it reshapes the tenant’s database rather than its rows. Every route resolves its tenant from the caller’s own CPET headers, so an admin can only ever act on their own tenant.

Each route belongs to one operation class, and each class has its own gate. On data.svc the destructive classes are lifted to superadmin, because the service runs shared endpoint pools where one tenant’s long-running ALTER starves every other tenant on that pool.

ClassGate on data.svcRoutes
Readadmin or superadminplan read, ledger, seed list, retention preview and discovery, handoff pending, lock list, action catalogue, materialize views, references, hashes
Generateadmin or superadminPOST /plans
Seedadmin or superadminPOST /seeds/runs
Maintenanceadmin or superadminPOST /materialize/drain
Applysuperadmin onlyPOST /plans/{id}/apply
Retentionsuperadmin onlyPOST /retention/runs, both handoff writes
Lockssuperadmin onlyDELETE /locks/{resource}

The role check is case-insensitive and superadmin satisfies every admin gate. A denied request returns 403 with v2.requires_admin or v2.requires_superadmin in the coded envelope.

Every admin route targets one datastore, but the two planes read the name from different places. Omit it either way to address the tenant’s default datastore.

RoutesWhere datastore is read
Every GET, DELETE /locks/{resource}, and POST /materialize/drain?datastore=<name> query parameter
POST /plans, POST /plans/{id}/apply, POST /seeds/runs, POST /retention/runs, both handoff writesthe datastore field of the JSON body

Applying DDL is a two-step operation: generate a plan, review it, then apply it by id. The concepts — risk levels, refusals, the ledger, locking — are on Migrations.

POST /data/admin/data/plans

{ "datastore": "", "allow_drops": false }

Both fields are optional; an empty body is valid. The route writes a plan row and runs no DDL.

{
"plan_id": "01J8QK...",
"source": "yaml",
"max_risk": "medium",
"warnings": ["column orders.notes widened"],
"refusals": [],
"steps": [
{
"Order": 1,
"Kind": 0,
"SchemaOp": "add_column",
"Entity": "orders",
"Identifier": "notes",
"SQL": "ALTER TABLE orders ADD COLUMN notes text",
"Expected": "",
"Risk": 0,
"Notes": "",
"Transactional": false,
"Checksum": ""
}
]
}
FieldNotes
plan_idIdentifier for the apply and ledger routes
sourceOrigin of the plan. yaml for every plan this route generates
max_risklow, medium or high — the highest risk of any step
warningsOmitted when empty
refusalsOmitted when empty. A non-empty list still returns 200
stepsThe ordered steps, in execution order

POST /data/admin/data/plans/{id}/apply — the only route that executes schema DDL.

{ "datastore": "", "confirm_max_risk": "low", "dry_run": false }
FieldDefaultNotes
datastoretenant default
confirm_max_risklowlow, medium or high. Any other value returns 400. An empty string means low, so a medium-risk plan is refused unless you raise it
dry_runfalsePlans the execution without running it
{
"plan_id": "01J8QK...",
"status": "applied",
"steps_applied": 4,
"steps_failed": 0,
"steps_skipped": 0
}

failed_step, halted_step and error appear only when set.

StatusMeaning
200status is applied
207Anything else — partial application, halted plan, or an error that still produced a result
400Missing plan id, unparseable body, or an invalid confirm_max_risk
500The apply failed before producing any result
MethodPathParametersResponse
GET/data/admin/data/plans/{id}datastore{tenant, plan, steps}; 404 when the id is unknown
GET/data/admin/data/appliedplan_id required, datastore{tenant, plan_id, steps}

plan carries ID, PlanID, Source, Status, Trigger, TriggeredBy, MaxRisk, StartedAt, FinishedAt, AppliedCount, FailedCount, TotalSteps, Notes, CreatedOn. Each ledger step carries ID, PlanID, StepOrder, Kind, SchemaOp, Entity, Identifier, SQL, ExpectedResult, Checksum, Status, StartedAt, FinishedAt, DurationMs, Error, Attempt, SourceURI, CreatedOn. A plan row that exists with zero steps is legal — plans/{id} fetches steps best-effort and still returns the plan.

GET /data/admin/data/applied without plan_id returns 400 — there is no date or status filter, and no way to list every plan.

MethodPathPurpose
POST/data/admin/data/seeds/runsRun seeds. Synchronous — the request blocks until the run finishes
GET/data/admin/data/seedsDeclared seeds with their applied state from the ledger

Request body, all fields optional:

{
"datastore": "",
"names": ["countries", "currencies"],
"environment": "production",
"dry_run": false,
"force": false
}

An empty body runs every eligible seed. names restricts the run; environment filters by the seed’s declared environment.

{
"plan_id": "01J8QM...",
"dry_run": false,
"applied_count": 2,
"skipped_count": 1,
"failed_count": 0,
"outcomes": [ { "Name": "countries", "Status": "applied", "Reason": "", "Rows": 249, "Duration": 412000000 } ]
}

Each outcome carries Name, Status (applied, skipped or failed), Reason, Rows and Duration — nanoseconds, not milliseconds.

The response is 207 whenever failed_count > 0, including when every seed failed. The body is the source of truth; the status is a hint.

GET /data/admin/data/seeds returns {tenant, datastore, seeds} where each entry carries name, entity, source, strategy, status, and — once applied — applied_at, applied_checksum and error. A seed with no ledger row reports status: "pending". A ledger lookup that fails is reported in that seed’s error rather than failing the request.

Retention runs are triggered, never scheduled. The phase model, thresholds and archive declarations are on Retention and archives.

MethodPathPurpose
POST/data/admin/data/retention/runsExecute a pass. Synchronous
GET/data/admin/data/retention/previewEligible row counts. Writes nothing
POST/data/admin/data/retention/handoff/claimA pipeline claims queued rows
POST/data/admin/data/retention/handoff/confirmA pipeline reports outcomes
GET/data/admin/data/retention/handoff/pendingQueue depth and age
GET/data/admin/data/retention/archivesDeclared archive targets
GET/data/admin/data/retention/entitiesEntities with an active policy
{
"datastore": "",
"entities": [],
"phases": ["anonymize", "archive", "archive_delete", "purge"],
"dry_run": false,
"batch_size": 0,
"max_rows_per_pass": 0,
"grace_hours": 0
}

Every field is optional. entities empty means every retention-enabled entity; phases empty means all four. grace_hours is applied only when greater than zero.

{
"plan_id": "01J8QN...",
"dry_run": true,
"total_applied": 0,
"total_failed": 0,
"entities": [],
"eligible": { "audit_log": { "purge": 128400 } }
}

eligible is present only on a dry run. The status is 207 when total_failed > 0. Each entry in entities carries per-phase counters under Go field names — Entity, Anonymized, Archived, ArchiveDeleted, Purged, FKBlocked, Errors.

GET /data/admin/data/retention/preview takes datastore and entity as query parameters and returns {tenant, datastore, plan_id, eligible} — the same counts, without executing anything.

POST /data/admin/data/retention/handoff/claim

{ "datastore": "", "target_name": "cold_storage", "entity": "invoice", "limit": 500, "claimer_id": "airflow-task-9134" }

claimer_id is required — 400 without it. It is a tracking label, not a credential.

{ "claimed": [ { "handoff_id": "01J8QP...", "entity": "invoice", "row_pk": "01J2...", "target_name": "cold_storage", "target_type": "s3" } ] }

POST /data/admin/data/retention/handoff/confirm

{
"datastore": "",
"confirmations": [
{ "handoff_id": "01J8QP...", "target_location": "s3://bucket/invoice/01J2.json", "checksum": "sha256:...", "failed": false, "error": "" }
]
}

An empty confirmations array is 400. The response is {"confirmed": N, "failed": N}.

GET /data/admin/data/retention/handoff/pending accepts datastore, target_name, entity and status, and returns buckets:

{
"tenant": "acme:forge:prod:main",
"datastore": "",
"buckets": [ { "entity": "invoice", "target_name": "cold_storage", "status": "claimed", "count": 412, "oldest_age_seconds": 8123 } ]
}

GET /data/admin/data/retention/archives reads the schema only — no database access.

{
"tenant": "acme:forge:prod:main",
"datastore": "",
"archives": [ { "name": "cold_storage", "type": "s3", "suffix": "_archive", "settings": { "bucket": "acme-archive" } } ]
}

GET /data/admin/data/retention/entities lists entities with an active policy. Archive companion tables are excluded.

{
"tenant": "acme:forge:prod:main",
"datastore": "",
"entities": [
{
"entity": "invoice",
"table_name": "invoice",
"pii_fields": ["customer_email"],
"retention": { "active": "168h0m0s", "archive": "8760h0m0s", "archive_to": "cold_storage", "age_field": "created_at" }
}
]
}

Durations are rendered in Go’s duration format, and zero-valued policy fields are omitted. pii_fields lists the columns carrying a compliance type of pii, phi, pci or gdpr; other compliance types do not put a column on this list.

MethodPathParameters
GET/data/admin/data/locksdatastore, prefix, op_kind, include_expired
DELETE/data/admin/data/locks/{resource}datastore

include_expired is compared against the literal string true; any other value keeps expired rows out of the list.

{
"tenant": "acme:forge:prod:main",
"datastore": "",
"locks": [
{
"id": "01J8QR...",
"resource": "migrate:acme:forge:prod:main:default",
"holder_id": "01J8QQ...",
"op_kind": "apply",
"acquired_at": "2026-07-27T09:14:02Z",
"expires_at": "2026-07-27T09:19:02Z",
"hostname": "data-7f9c",
"plan_id": "01J8QK...",
"is_expired": false,
"seconds_to_expiry": 214
}
]
}

Timestamps are RFC 3339 in UTC. There is no count field on this response.

{
"tenant": "acme:forge:prod:main",
"resource": "migrate:acme:forge:prod:main:default",
"released": true,
"note": "lock row deleted; any current holder will fail next heartbeat"
}
MethodPathPurpose
GET/data/admin/data/materialize/pendingPending and failed tasks
GET/data/admin/data/materialize/statsQueue depth by status, plus runner counters
POST/data/admin/data/materialize/drainRun one drain pass now

pending takes datastore and an after cursor. Page size is fixed at 100 and cannot be changed.

{
"tenant": "acme:forge:prod:main",
"datastore": "",
"count": 2,
"tasks": [
{
"id": "01J8QS...",
"target_entity": "customer",
"target_field": "order_total",
"target_row_id": "01J2...",
"source_entity": "order",
"source_op": "create",
"enqueued_at": "2026-07-27T09:02:11Z",
"attempts": 1
}
]
}

stats returns {tenant, datastore, counts, runner}. The runner block is omitted when no runner exists for the tenant — a tenant with no cross-entity triggers has none. drain takes no body and returns {tenant, datastore, dispatched}.

See Materialized views and refresh for which declarations actually enqueue work.

GET /data/admin/data/actions returns the declared custom actions, sorted by name. SQL bodies are never included.

{
"tenant": "acme:forge:prod:main",
"datastore": "",
"count": 1,
"actions": [
{
"name": "list_orders",
"description": "Orders for one customer",
"entities": ["orders"],
"source": "product",
"methods": ["GET"],
"has_access": true,
"sections": { "read": { "param_count": 2, "returning": [], "access_override": false } }
}
]
}

source is product, tenant or runtime — the layer the declaration came from. A tenant with no registry returns count: 0.

GET /data/admin/data/references/{entity} lists every foreign key pointing at the named entity — the “what breaks if I drop this” view.

{
"tenant": "acme:forge:prod:main",
"datastore": "",
"entity": "customer",
"count": 1,
"references": [ { "from_entity": "order", "from_field": "customer_id", "to_field": "id", "on_delete": "cascade" } ]
}

Sorted by from_entity then from_field. on_delete and on_update render as cascade, setnull, restrict or noaction, and are omitted when unset. An unknown entity is 404.

GET /data/admin/data/hashes answers 200 with count: 0 and an empty hashes array every time. The compiled-query cache exposes no enumeration API, so the route is a reachability probe for the cache, not a data source. The superadmin mirror behaves identically.

/data/superadmin/* is the cross-tenant plane. Every route requires the superadmin role strictly — admin does not pass.

MethodPathPurpose
GET/data/superadmin/tenantTenant keys this service holds live resources for
DELETE/data/superadmin/tenant/{tenant}/cacheCache-bust one tenant
{ "tenants": ["acme:forge:prod:main", "acme:forge:prod:sandbox"], "count": 2 }

The cache-bust returns {"invalidated": "<full tenant key>"}.

Every admin route has a superadmin mirror at /data/superadmin/tenant/{tenant}/… with the admin/data segment dropped. The handler bodies, request shapes and responses are identical; only the target tenant differs.

Admin routeSuperadmin mirror
/data/admin/data/seeds/runs/data/superadmin/tenant/{tenant}/seeds/runs
/data/admin/data/seeds/data/superadmin/tenant/{tenant}/seeds
/data/admin/data/applied/data/superadmin/tenant/{tenant}/applied
/data/admin/data/plans/data/superadmin/tenant/{tenant}/plans
/data/admin/data/plans/{id}/data/superadmin/tenant/{tenant}/plans/{id}
/data/admin/data/plans/{id}/apply/data/superadmin/tenant/{tenant}/plans/{id}/apply
/data/admin/data/retention/runs/data/superadmin/tenant/{tenant}/retention/runs
/data/admin/data/retention/preview/data/superadmin/tenant/{tenant}/retention/preview
/data/admin/data/retention/handoff/claim/data/superadmin/tenant/{tenant}/retention/handoff/claim
/data/admin/data/retention/handoff/confirm/data/superadmin/tenant/{tenant}/retention/handoff/confirm
/data/admin/data/retention/handoff/pending/data/superadmin/tenant/{tenant}/retention/handoff/pending
/data/admin/data/retention/archives/data/superadmin/tenant/{tenant}/retention/archives
/data/admin/data/retention/entities/data/superadmin/tenant/{tenant}/retention/entities
/data/admin/data/locks/data/superadmin/tenant/{tenant}/locks
/data/admin/data/locks/{resource}/data/superadmin/tenant/{tenant}/locks/{resource}
/data/admin/data/actions/data/superadmin/tenant/{tenant}/actions
/data/admin/data/materialize/pending/data/superadmin/tenant/{tenant}/materialize/pending
/data/admin/data/materialize/stats/data/superadmin/tenant/{tenant}/materialize/stats
/data/admin/data/materialize/drain/data/superadmin/tenant/{tenant}/materialize/drain
/data/admin/data/references/{entity}/data/superadmin/tenant/{tenant}/references/{entity}
/data/admin/data/hashes/data/superadmin/tenant/{tenant}/hashes
StatusCodeCause
403v2.requires_superadminCaller lacks the role
400v2.missing_tenantEmpty {tenant} segment
400v2.invalid_tenantA full four-part key was passed
500v2.list_tenants_failedTenant inventory unreadable
500v2.invalidate_failedCache-bust failed for the target tenant

One path, two verbs. Both require ?entity= and run through the same engine as REST, so validation, access rules, row-level security and audit apply to every row.

MethodPathDirectionAuth
POST/data/bulkImport — body is the dataBearer
PATCH/data/bulkExport — body empty, response is the dataBearer or API key
ParameterApplies toDefaultMeaning
entitybothRequired. 400 v2.missing_entity without it
formatbothjsoncsv, json, jsonl; ndjson is an alias for jsonl. Anything else is 400 v2.bad_format
datastorebothtenant default
chunkimport1000Rows per insert chunk. Non-numeric or non-positive values fall back to the default
skiperrorsimportfalsetrue continues past per-row errors
limitexport10000Row cap
fieldsexportallComma-separated column list
filterexportRepeatable ?filter=k=v. Equality only
orderbyexportComma-separated; prefix a column with - for descending

Import responds with a JSON result:

{ "imported": 2481, "skipped": 3, "errors": [] }

Export sets the content type from the format — text/csv; charset=utf-8 with Content-Disposition: attachment; filename="<entity>.csv", application/x-ndjson; charset=utf-8, or application/json; charset=utf-8.

Terminal window
curl -X PATCH 'https://<host>/data/bulk?entity=invoice&format=csv&limit=50000&orderby=-created_at' \
-H 'Authorization: Bearer <token>' \
-o invoice.csv
StatusCodeCause
400v2.missing_entity?entity= absent
400v2.bad_formatUnsupported ?format=
403v2.no_tenantNo tenant key on the request context
500v2.import_failedThe import failed
500v2.engine_unavailableThe tenant’s engine bundle could not be resolved

Both probes bypass the tenancy middleware: no headers, no token, no tenant. The bodies are on the Data API reference.

MethodPathSuccessFailure
GET/data/health200 JSON500, same body with "healthy": false
GET/data/ready200 text/plain ready:true500 ready:false

In the health body, memstats values are MiB and NumGC is a count. dependencies is keyed by dependency name, each entry carrying a State boolean and a Message string. version is always the empty string — nothing in this service sets it.