GraphQL
data.svc serves one GraphQL endpoint per tenant, generated at run time from the tenant’s
entity definitions. This page is the wire reference: paths, parameters, bodies, headers and
status codes. For the generation model — type mapping, relations, input exclusions, execution
behaviour — see GraphQL in the Data API block.
Endpoints
Section titled “Endpoints”| Method | Path | Auth |
|---|---|---|
POST | /data/graphql | Authorization: Bearer <token> |
POST | /data/anon/graphql | None; access rules and row-level security decide what resolves |
Both endpoints serve the same generated schema through the same resolvers. Only the resolved principal differs.
Request
Section titled “Request”Headers
Section titled “Headers”| Header | Required | Value |
|---|---|---|
Content-Type | yes | application/json |
Authorization | /data/graphql only | Bearer <token> |
X-Customer, X-Product, X-Env, X-Tenant | yes | The four tenancy slots the caller sends — see Data API |
Query parameters
Section titled “Query parameters”| Name | Type | Description |
|---|---|---|
datastore | string | Which datastore to address. Omit to use the schema’s default datastore. |
datastore selects both the schema the document is validated against and the engine that
executes it. It is the only query parameter this endpoint reads — expression and stats
apply to REST routes, not here.
{ "query": "{ widgetList { id title } }", "operationName": "", "variables": {}}| Field | Type | Description |
|---|---|---|
query | string | The GraphQL document. |
operationName | string | Which operation in the document to run. |
variables | object | Variable values, merged over the operation’s declared defaults. |
operationName and variables are optional. When operationName is empty the first
operation in the document runs — never a named default. Only that one operation executes; the
rest of the document is ignored.
Operation names
Section titled “Operation names”Names are derived from the entity name. For an entity order:
| Operation | Field | Signature |
|---|---|---|
| Get by id | order | order(id: ID!): Order |
| List | orderList | orderList(filter: OrderFilter, limit: Int, offset: Int, orderBy: String): [Order!]! |
| Count | orderCount | orderCount(filter: OrderFilter): Int! |
| Create | createOrder | createOrder(input: CreateOrderInput!): Order! |
| Update | updateOrder | updateOrder(id: ID!, input: UpdateOrderInput!): Order! |
| Delete | deleteOrder | deleteOrder(id: ID!): Boolean! |
The bare entity field is a get-by-id taking a required id and returning a single object or
null. It is not a list — the list field is <entity>List.
List arguments
Section titled “List arguments”| Argument | Type | Behaviour |
|---|---|---|
filter | <T>Filter | Equality only. Each supplied key becomes one field = value condition, ANDed. |
limit | Int | Maximum rows returned. |
offset | Int | Rows skipped. |
orderBy | String | One string, "field ASC" or "field DESC". |
filter is an input object of field/value pairs, not an expression string:
query { orderList(filter: { status: "open" }, limit: 20, offset: 40, orderBy: "createdon DESC") { id status }}Pagination is limit + offset only. The cursor pagination available on REST list routes is
not exposed in GraphQL, and <entity>Count counts rows in memory rather than pushing down a
COUNT(*) — a count above 10 000 is reported as 10 000.
Response
Section titled “Response”200 OK, Content-Type: application/json:
{ "data": { "widgetList": [ { "id": "w1", "title": "Alpha" }, { "id": "w2", "title": "Beta" } ] }}| Key | Present when |
|---|---|
data | The document parsed and an operation was selected. Omitted entirely on a parse or validation failure, and when operationName matched no operation. |
errors | One or more errors occurred. Omitted when everything resolved. |
Results are keyed by the field name, or by the alias when one is given — a createWidget
mutation returns data.createWidget, not data.widget. Only the fields you select come back;
everything else is projected out.
Status codes
Section titled “Status codes”| Status | Content type | Condition |
|---|---|---|
| 200 | application/json | Document ran. Includes every field-level and engine error. |
| 400 | text/plain | Body did not decode — invalid JSON body. An unresolvable tenant is also 400, body invalid tenant, written before the route is reached. |
| 401 | text/plain | Token missing, invalid or expired on /data/graphql — body Unauthorized. |
| 403 | application/json | No tenant resolved on the request. |
| 405 | text/plain | GET, PUT, PATCH or DELETE — body Method Not Allowed, with Allow: OPTIONS, POST. OPTIONS gets 200 and the same header. |
| 429 | application/json | Rate-limit bucket exhausted. |
| 500 | application/json | Engine or generated schema could not be built. |
Only 403, 429 and 500 use the platform error envelope {"error": "...", "code": "..."}. The
400, 401 and 405 bodies are plain text and carry no code — they are written by the handler,
the auth and tenancy middleware, and the router, all outside the error-envelope path.
A 429 carries X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset and
Retry-After, plus a details object with profile, limit, remaining and retry_after.
Anonymous traffic is bucketed per tenant under a shared anonymous user segment.
Error codes
Section titled “Error codes”Envelope errors:
| Code | Status | Meaning |
|---|---|---|
v2.no_tenant | 403 | No tenant key in the request context — the CPET headers were missing or unroutable. |
v2.engine_unavailable | 500 | The tenant’s engine could not be resolved, or the generated schema failed to parse. |
v2.rate_limited | 429 | Rate-limit bucket exhausted for this tenant, user and profile. |
Errors inside errors[], all at HTTP 200:
| Message | Cause |
|---|---|
cannot resolve field "<name>" | The field validated but has no resolver. This is what introspection queries hit. |
no operation "<name>" in document | operationName matched no operation in the document. |
| Parse and validation messages | Malformed syntax, or a field, argument or type not in the generated schema. Nothing is dispatched and data is absent. |
| Engine messages | Access denial, RLS filtering, validation failure, constraint violation or a database error, wrapped at the failing field’s path. |
Introspection and discovery
Section titled “Introspection and discovery”Introspection does not work. __schema and __type are injected into the Query type by the
parser, so an introspection query passes validation and then returns 200 with
data.__schema: null and cannot resolve field "__schema" in errors[]. Tools that
bootstrap from introspection — GraphiQL, Apollo codegen, Relay compiler — fail against this
endpoint.
Fetch the SDL instead:
curl -H "Authorization: Bearer $TOKEN" https://api.example.com/data/schema/graphql| Method | Path | Returns |
|---|---|---|
GET | /data/schema/graphql | The generated SDL, text/plain; charset=utf-8. |
GET | /data/schema | Schema summary — name lists only, application/json. |
GET | /data/schema/entities | {"entities": [{"name", "fields", "relations"}], "count"} — counts, not field detail. |
GET | /data/schema/entities/<entity> | One entity: name, table_name, primary_key, materialization, fields[], relations[]. |
DELETE | /data/cache | Drops the calling tenant’s cached schemas, pools and engines. |
Those routes, plus the OpenAPI, JSON Schema, Zod and CUE exports, are documented in full on Schema and discovery.
The generated schema is cached per tenant and datastore and is rebuilt only when the tenant’s
engine is. A definition change is invisible to this endpoint until then; DELETE /data/cache
is the explicit reset, returning {"cleared": true, "tenant": "<tenant key>"}.
Unsupported
Section titled “Unsupported”| Feature | State |
|---|---|
Introspection (__schema, __type) | Validates, then fails to resolve. |
| Subscriptions | No Subscription type is generated and no transport serves one. |
| Fragments and inline fragments | Parse and validate, then contribute no data and raise no error. |
Directives (@skip, @include) | Never evaluated. |
| Interfaces, unions, custom directives | Never generated. |
| Batched operations | Only the one selected operation runs. |
GET transport, playground | Not served. |
Aliases and variables work, at the top level and on nested fields.
Examples
Section titled “Examples”List with projection and ordering
Section titled “List with projection and ordering”POST /data/graphql?datastore=main HTTP/1.1Content-Type: application/jsonAuthorization: Bearer <token>
{"query":"query { widgetList(limit: 10, orderBy: \"qty DESC\") { id title } }"}{ "data": { "widgetList": [ { "id": "w1", "title": "Alpha" }, { "id": "w2", "title": "Beta" } ] }}qty is absent from the response because it was not selected, even though it drove the sort.
Get one row by id
Section titled “Get one row by id”{ widget(id: "w1") { id qty }}{ "data": { "widget": { "id": "w1", "qty": 3 } }}The get, update and delete fields address rows by a column named exactly id. An entity whose
primary key is named anything else is not reachable through them — use <entity>List with a
filter.
Create
Section titled “Create”mutation { createWidget(input: { title: "Created", qty: 1 }) { id title }}{ "data": { "createWidget": { "id": "w9", "title": "Created" } }}id is not in CreateWidgetInput — auto fields are excluded and the engine’s defaults fill
the primary key.
Variables
Section titled “Variables”{ "query": "query List($n: Int) { widgetList(limit: $n) { id title } }", "operationName": "List", "variables": { "n": 10 }}A field-level error
Section titled “A field-level error”{ "data": { "widgetList": null }, "errors": [ { "message": "access denied", "path": ["widgetList"] } ]}Status is 200. The document parsed and validated; the engine refused the field.