POST /api/v1/query returns aggregates over one dataset: counts, sums, averages, percentiles, grouped by field and bucketed by time. It’s built for dashboards and reports. It isn’t SQL: every query is a JSON description that Rills checks against an allowlist of datasets, fields, and operations before anything runs.
#Request shape
{
"dataset": "workflowExecutions",
"resourceId": "00000000-0000-4000-8000-000000000000",
"timeRange": { "from": "2026-01-01T00:00:00Z", "to": "2026-01-08T00:00:00Z" },
"timeBucket": { "field": "createdAt", "interval": "day" },
"filters": [{ "field": "status", "op": "eq", "value": "failed" }],
"groupBy": [],
"measures": [{ "alias": "failedRuns", "fn": "count" }],
"orderBy": [{ "field": "bucket", "direction": "asc" }],
"limit": 1000
}
| Key | Required | Description |
|---|---|---|
dataset | Yes | One of the datasets below. |
resourceId | Depends | Table ID for tableRows and form ID for formSubmissions (required). Optional workflow ID for workflowExecutions. Not accepted for other datasets. |
timeRange | Depends | from (inclusive) and to (exclusive) as ISO 8601 timestamps with a zone, at most 366 days apart. |
timeBucket | No | field (a date field) and interval: minute, hour, day, week, or month. |
filters | No | Up to 10 conditions, all of which must match. |
groupBy | No | Up to 3 distinct field names. |
measures | Yes | 1 to 10 aggregates, each with a unique alias. |
orderBy | No | Up to 2 sorts on bucket, a grouped field, or a measure alias. |
limit | No | Maximum rows returned, 1 to 1,000. Default 1,000. |
The body must be application/json and at most 64 KiB. Unknown keys are rejected.
#Datasets and fields
| Dataset | Required scopes | Fields |
|---|---|---|
tableRows | tables:read, payloads:read | createdAt, updatedAt, and your table’s fields |
formSubmissions | forms:read, payloads:read | createdAt, updatedAt, and the form’s table fields |
workflowExecutions | workflows:read | createdAt, workflowId, workflowName, status, durationMs |
approvals | approvals:read | createdAt, decidedAt, workflowId, status, decisionKind, priority, source, timeToDecisionMs, confidenceScore |
decisionRecords | records:read | createdAt, category, action, resourceType, actorType |
agentConversations | agents:read | createdAt, updatedAt, agentId, status |
files | files:read | createdAt, source, contentType, sizeBytes |
Every listed field can be filtered, grouped, and measured. Only date fields (createdAt, updatedAt, decidedAt, and date-type table fields) can be bucketed.
Useful values:
workflowExecutions.status:pending,running,success,failed,paused_quota,paused_billing_lapse.approvals.status:pending,committing,approved,rejected,expired,auto_approved.committingmeans decided but still undoable until the decision’seffectiveAt.approvals.decisionKind:approved,rejected,edited,auto_approved; null until decided.approvals.priority:low,normal,high,urgent.decisionRecords.actorType:user,system,api_token.files.source:formorworkspace.durationMsandtimeToDecisionMsare integer milliseconds; they’re null while a run is unfinished or an approval is undecided.
agentConversations reports counts and status only. Message content isn’t queryable.
#Table and form fields
For tableRows and formSubmissions, refer to your own fields by slug. GET /tables/{tableId} lists each field’s slug and fieldType. Field types map to query types:
numberfields are numeric;datefields are calendar dates and can be bucketed;boolfields are true/false;- other field types are treated as text;
jsonandfilefields and archived fields can’t be queried.
A formSubmissions query covers rows that the form wrote into its table.
#Filters and measures
| Operator | Works on | Value |
|---|---|---|
eq, ne | Any field | One value |
in, notIn | Any field | Array of 1 to 100 values |
lt, lte, gt, gte | Number and date fields | One value |
isNull, isNotNull | Any field | Omit value |
Values must match the field’s type: strings for text and IDs, numbers for numeric fields, true or false for booleans, and ISO 8601 strings for dates. Strings are limited to 500 characters.
| Measure | Field | Notes |
|---|---|---|
count | Omit field | Counts matching rows. |
countDistinct | Any field | Counts distinct non-null values. |
sum, avg | Numeric field | Nulls are skipped. |
min, max | Numeric or date field | Nulls are skipped. |
percentile | Numeric field | Add percentile strictly between 0 and 1, for example 0.95. |
Aliases must start with a lowercase letter, use letters, digits, or underscores, and can’t be bucket or a grouped field name. A table field named bucket can’t be grouped in a query that also sets timeBucket.
#Time buckets
Buckets are ISO 8601 UTC timestamps at the start of each interval, for example 2026-01-05T00:00:00.000Z. Days start at 00:00 UTC, weeks start Monday 00:00 UTC, and months start on the 1st. Workspace time zones aren’t applied.
When timeBucket is set, timeRange filters on the bucketed field. Without a bucket, timeRange filters on createdAt. So bucketing approvals on decidedAt counts decisions made in the range, regardless of when the request was created.
#Rules
Some shapes are rejected with 400 QUERY_TOO_EXPENSIVE before anything runs:
- Every bucketed query needs
timeRange. - Fixed datasets need
timeRange(workflowExecutions,approvals,decisionRecords,agentConversations,files). The one exception is a plain total of pending approvals: a singlecountwith only the filterstatuseqpending. - Tables and forms need
timeRangefor grouping, bucketing, non-count measures, and range or null filters. Without a range, you can run a plaincountwhose filters areeqorinon text fields. Tables larger than 64 MiB always need a range. frommust be earlier thanto, at most 366 days apart.- Every field must exist in the dataset, every operator and measure must suit the field’s type, and
orderBycan only namebucket, a grouped field, or an alias.
Queries are cancelled after 5 seconds and return 503 QUERY_TIMEOUT with Retry-After: 30. Narrow the range or grouping before retrying. Each query costs 10 request units; see rate limits.
#Response
{
"data": [
{ "bucket": "2026-01-05T00:00:00.000Z", "status": "success", "runs": 42 },
{ "bucket": "2026-01-05T00:00:00.000Z", "status": "failed", "runs": 3 }
],
"meta": { "dataset": "workflowExecutions", "generatedAt": "2026-01-08T09:15:02.481Z" }
}
Each row is flat: bucket when you bucket, then each grouped field, then each measure alias. Without orderBy, grouped rows are ordered by bucket and then by grouped fields.
Grouping by a number field (such as durationMs or a number table field) returns the key as an exact decimal string, for example "0.1", so two different stored values never share a key. Those keys sort as text; order by a measure if you need a different order. Measures are always JSON numbers.
- A scalar
countwith no matches returns one row with0. - A grouped or bucketed query with no matches returns
"data": []. Buckets with no rows are omitted, not zero-filled. - The response is capped at 2 MiB (
413 RESPONSE_TOO_LARGE).
#Examples
These bodies match the examples in the OpenAPI document. Replace 00000000-0000-4000-8000-000000000000 with your table, form, or workflow ID, and set timeRange to the period you want.
#Leads not yet contacted
Count rows in a leads table whose contactStatus text field is not_contacted. A plain text-equality count can omit timeRange to count all time. Requires tables:read and payloads:read.
{
"dataset": "tableRows",
"resourceId": "00000000-0000-4000-8000-000000000000",
"filters": [{ "field": "contactStatus", "op": "eq", "value": "not_contacted" }],
"measures": [{ "alias": "leadCount", "fn": "count" }]
}
Returns [{ "leadCount": 128 }].
#Daily runs by status for one workflow
Requires workflows:read. resourceId is the workflow ID.
{
"dataset": "workflowExecutions",
"resourceId": "00000000-0000-4000-8000-000000000000",
"timeRange": { "from": "2026-01-01T00:00:00Z", "to": "2026-01-08T00:00:00Z" },
"timeBucket": { "field": "createdAt", "interval": "day" },
"groupBy": ["status"],
"measures": [{ "alias": "runs", "fn": "count" }],
"orderBy": [{ "field": "bucket", "direction": "asc" }]
}
#Average and p95 run duration by day
Requires workflows:read. Add resourceId to limit it to one workflow. Unfinished runs have no duration and are skipped.
{
"dataset": "workflowExecutions",
"timeRange": { "from": "2026-01-01T00:00:00Z", "to": "2026-01-08T00:00:00Z" },
"timeBucket": { "field": "createdAt", "interval": "day" },
"measures": [
{ "alias": "averageMs", "fn": "avg", "field": "durationMs" },
{ "alias": "p95Ms", "fn": "percentile", "field": "durationMs", "percentile": 0.95 }
],
"orderBy": [{ "field": "bucket", "direction": "asc" }]
}
#Pending approvals by priority
Requires approvals:read. Grouping needs a range, and the range applies to createdAt, so make it at least as long as your oldest pending request can be. Standalone requests expire after at most 30 days.
{
"dataset": "approvals",
"timeRange": { "from": "2026-01-01T00:00:00Z", "to": "2026-01-08T00:00:00Z" },
"filters": [{ "field": "status", "op": "eq", "value": "pending" }],
"groupBy": ["priority"],
"measures": [{ "alias": "pending", "fn": "count" }]
}
For one total across all time, drop timeRange and groupBy:
{
"dataset": "approvals",
"filters": [{ "field": "status", "op": "eq", "value": "pending" }],
"measures": [{ "alias": "pending", "fn": "count" }]
}
#Approval decisions by day and kind
Requires approvals:read. Bucketing on decidedAt puts each decision on the day it was made.
{
"dataset": "approvals",
"timeRange": { "from": "2026-01-01T00:00:00Z", "to": "2026-01-08T00:00:00Z" },
"timeBucket": { "field": "decidedAt", "interval": "day" },
"filters": [{ "field": "decidedAt", "op": "isNotNull" }],
"groupBy": ["decisionKind"],
"measures": [{ "alias": "decisions", "fn": "count" }]
}
#Form submissions by day
Requires forms:read and payloads:read. resourceId is the form ID.
{
"dataset": "formSubmissions",
"resourceId": "00000000-0000-4000-8000-000000000000",
"timeRange": { "from": "2026-01-01T00:00:00Z", "to": "2026-01-08T00:00:00Z" },
"timeBucket": { "field": "createdAt", "interval": "day" },
"measures": [{ "alias": "submissions", "fn": "count" }]
}