Query
Vincent Depassier · September 17, 2026
POST /{workspaceId}/queryThe one data endpoint. Reads and writes both go through it, and which one you get is decided by the body, not by the path or the method.
Auth: API key (sk_live_ or pk_live_), or an end-user JWT.
Request
POST https://gateway.praxsuite.com/{workspaceId}/query
Authorization: Bearer sk_live_...
Content-Type: application/json{
"refs": { "Customers": "a1b2c3d4-e5f6-7890-abcd-ef1234567890" },
"query": {
"from": "Customers",
"select": ["Name", "Email"],
"where": [{ "field": "Status", "op": "eq", "value": "Active" }],
"orderBy": [{ "field": "Name", "dir": "asc" }],
"limit": 50
},
"includeTotalCount": false
}Field | Type | Notes |
| object | Your aliases → table GUIDs. Required, 1–20 entries |
| object | A read. Mutually exclusive with |
| object | A write. Mutually exclusive with |
| boolean | Fills |
refs is also the complete list of what the request is allowed to touch, which is what lets the gateway check it against your scopes before doing any work.
query
Field | Type | Notes | |
| string | An alias from | |
| array | Column names, relation objects, or aggregate objects. Omit for every readable column | |
| array | Conditions, AND-ed at the same level | |
| array | `{ "field": …, "dir": "asc" \ | "desc" }` |
| array | Column names, for aggregate queries | |
| array | Conditions applied after grouping | |
| number | Default 50, ceiling 1000, and a table scope may cap it lower | |
| number | Rows to skip |
An aggregate select item is { "field": "Total", "fn": "sum", "alias": "TotalRevenue" }.
select: "*" is not an error when your column scope is narrow — it returns the columns you may read, and nothing tells you the others exist. Naming a table you have no scope for is an error: 403 SCOPE_VIOLATION.
mutation
Field | Type | Notes |
| string |
|
| string | An alias from |
| array | For |
| object | For |
| array | Required for |
| bool or array | For |
| object | Announce the write on an Event Bus bus once it commits |
notify carries no payload of its own: { "bus": "{topic}:{instance}" }. The event body is built from what the database returned, never from the request, so a write cannot be used to relay arbitrary content. The topic must be declared, enabled, and publishable by the caller — all checked before the mutation runs, so a write never commits and then fails to announce. If the check fails you get 403 NOTIFY_DENIED and nothing is written.
How a request is checked
Your scopes apply in both directions, on reads and on writes alike. Nothing your client sends can remove either one — which is why a delete can never reach rows the credential could not have read.
Response — read
{
"data": [
{ "Name": "Acme Corp", "Email": "hello@acme.com" }
],
"meta": {
"count": 1,
"limit": 50,
"offset": 0,
"total": null,
"durationMs": 4
}
}meta.total is null unless you asked for includeTotalCount. Read meta.limit rather than assuming yours was honoured.
Response — write
{
"affectedRows": 1,
"data": [{ "Id": "…", "Name": "Acme Corp" }],
"meta": { "type": "insert", "table": "Customers", "durationMs": 7 }
}data appears only for an insert that asked for returning.
Response headers
Header | When |
| The credential has caching enabled |
| The workspace is past its included calls on pay-as-you-go |
With caching on, send a previous ETag back as If-None-Match to get 304 Not Modified instead of the body.
Errors
The structured envelope, with a code. See Errors and limits for the full table — the ones specific to this endpoint are INVALID_REQUEST, INVALID_REFS, INVALID_QUERY, INVALID_MUTATION, SCOPE_VIOLATION, NOTIFY_DENIED, QUERY_TIMEOUT and MUTATION_TIMEOUT.
Every call — including the ones that fail — is written to the query log, with the body, the caller, the source IP and the duration. API Gateway → Logs.
Going deeper
The grammar itself — operators, relations, aggregations, mutation semantics, the security model — is documented in full under API Gateway → PraxQL in the documentation, and can be tried without writing any code in the Playground.