The records query endpoint (POST /v1/records/query) is the comprehensive filter + sort surface: it can filter any of the 501 filterable dimensions across the 32 record roots and sort on any sortable field. Resource list endpoints (GET /v1/<resource>) accept the same sort + cursor model with a curated subset of filters.
The filter request model
A filter is a FilterConfig: a field, an operator, a value, and a conjunction that joins it to the next rule.
| Param | Type | Meaning |
|---|
field | string (dot path) | The dimension to filter, e.g. status, issuer.name, instructed_amount.amount. |
operator | enum | One of the operators below (gated by the field’s data type). |
value | scalar · array · none | The comparison value. Array for in; omitted for is_null / is_not_null. |
conjunction | and · or | How this rule joins the next. Defaults to and. |
Filters compile to a Hasura whereClause; you may also pass a raw whereClause for the full Hasura reach. Sorting is orderBy ( field + direction ). See POST /v1/records/query.
Operators by data type
The operator set is gated by the field’s data type — sending an out-of-type operator is rejected.
| Data type | Allowed operators |
|---|
| text | contains, eq, neq, starts_with, ends_with, is_null, is_not_null |
| number | eq, neq, gt, gte, lt, lte, is_null, is_not_null |
| date | eq, lt, gt, is_null, is_not_null |
| boolean | eq |
| enum | eq, neq, in, is_null, is_not_null |
| workspace | eq |
is_null / is_not_null take no value. in takes an array. contains / starts_with / ends_with map to Hasura _ilike (case-insensitive).
Raw whereClause — full Hasura reach
The filters model above is the curated, type-gated layer. For anything it cannot express, POST /v1/records/query accepts a raw whereClause that is passed through to Hasura unchanged (z.object({}).passthrough()), giving you the complete Hasura boolean-expression grammar — workspace row-level security is still enforced, so passthrough never widens your scope.
Boolean connectives
Combine conditions with _and, _or, and _not (arbitrarily nested):
"whereClause": {
"_and": [
{ "status": { "_in": ["issued", "paid"] } },
{ "_or": [
{ "grand_total": { "amount": { "_gte": 1000 } } },
{ "issuer": { "name": { "_ilike": "%anthropic%" } } }
] },
{ "_not": { "deleted_at": { "_is_null": false } } }
]
}
Comparison operators (full set)
A field condition is { "<field>": { "<op>": <value> } }. The complete Hasura operator set is available through passthrough — a superset of the curated operators above:
| Category | Operators |
|---|
| Equality | _eq, _neq |
| Ordering | _gt, _gte, _lt, _lte |
| Set membership | _in, _nin |
| Null | _is_null (true / false) |
| Pattern (case-sensitive) | _like, _nlike, _similar, _nsimilar, _regex, _nregex |
| Pattern (case-insensitive) | _ilike, _nilike, _iregex, _niregex |
How the curated operators compile to Hasura: eq → _eq, neq → _neq, gt/gte/lt/lte → _gt/_gte/_lt/_lte, in → _in, is_null → _is_null: true, is_not_null → _is_null: false, contains → _ilike "%v%", starts_with → _ilike "v%", ends_with → _ilike "%v".
You can filter on the fields of a related object — the same relationships you pass to include and see under a response’s relationships. How you nest depends on the relationship cardinality, which each resource’s object-reference page lists as to-one or to-many:
To-one relationships (issuer, receiver, document, workspace, …) — nest the relationship name directly, then the field condition. The dot-path field form in the curated model does the same thing:
// raw whereClause — invoices whose issuer is US-registered
"whereClause": { "issuer": { "registry_country": { "_eq": "US" } } }
// curated filters — identical result, type-gated
{ "field": "issuer.name", "operator": "contains", "value": "Anthropic" }
To-many relationships (invoice_items, invoice_transactions, payment_means, …) — you must quantify with _some, _every, or _none; nesting a field directly on an array relation is invalid:
// invoices that have AT LEAST ONE line item over 100 units
"whereClause": { "invoice_items": { "_some": { "quantity": { "_gt": 100 } } } }
// invoices where EVERY line item is tax-exempt
"whereClause": { "invoice_items": { "_every": { "tax_rate": { "_eq": 0 } } } }
// invoices with NO matched bank transaction (unreconciled)
"whereClause": { "invoice_transactions": { "_none": {} } }
Relationships nest arbitrarily deep ({ "issuer": { "workspace": { "name": { "_eq": "Acme" } } } }), and each hop must be one of the resource’s documented relationships. Workspace row-level security still applies at every hop, so a related-object filter never widens your tenant scope.
The curated filters model is type-gated and safer for clients, and supports one-hop to-one dot-paths (issuer.name). Reach for raw whereClause when you need _some/_every/_none on a to-many relation, a deeper multi-hop path, or a boolean shape the curated model does not expose. Both compile to the same Hasura query under the same RLS.
Composite fields
Composite columns (composite_*, e.g. composite_total_amount_currency) are virtual — they are reconstructed from underlying source fields at response time and cannot be filtered or sorted directly. Filter or sort on the source field instead (each composite’s object-reference entry lists its sources): to order by the invoice total, use grand_total, not composite_total_amount_currency.
Filtering — example
curl -X POST https://api.wellapp.ai/v1/records/query \
-H "Authorization: Bearer $WELL_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"root": "invoices",
"filters": [
{ "field": "status", "operator": "in", "value": ["issued","paid"], "conjunction": "and" },
{ "field": "issuer.name", "operator": "contains", "value": "Anthropic" },
{ "field": "grand_total.amount","operator": "gte", "value": 1000 }
],
"orderBy": { "field": "issued_at", "direction": "desc" },
"limit": 50
}'
The same rules expressed as a raw Hasura whereClause are also accepted (_and, _or, _ilike, _gte, …).
Sorting
orderBy takes a field and a direction (asc / desc). Sortability is gated per field (JSONB and computed columns may be non-sortable); an unsortable field is rejected. A stable tie-break is applied automatically. List endpoints accept sort (comma-separated, - prefix for descending) for the same effect.
To-one relationships — order by a related field with a dot path; it compiles to a nested Hasura order_by:
// invoices, newest issuer first
"orderBy": { "field": "issuer.name", "direction": "asc" }
// -> Hasura order_by: { issuer: { name: asc } }
To-many relationships cannot be sorted directly. Ordering invoices by invoice_items.amount is ambiguous (each invoice has many items), so a bare array-relation sort is rejected (the sort is dropped and the default order applies — no error). To order by a to-many relation, the field must expose an aggregate sort proxy (configured per field, e.g. subtasks_aggregate.min.created_at), which sorts by the aggregate (min/max/sum) over the related rows. Where a proxy exists it is applied automatically when you sort the corresponding composite; where one does not, that relation is simply not sortable.
Composite columns can’t be sorted or filtered directly — see
Composite fields above. Sort by the underlying source field (e.g.
grand_total), not
composite_total_amount_currency. Sorting by a related field also changes pagination: a related/aggregate sort falls back to offset-based cursors (capped at 10,000 rows) instead of keyset pagination, so very deep paging on a related sort is bounded.
The full per-record dimension catalog
The complete map of which dimensions are filterable on each of the 32 roots, their data types, and the resulting operator set is maintained as the Atlas matrix — see docs/wireframes/table-views/matrix.html (the filter / sort / view-management reference). Each root’s filterable fields are also visible in its records data model. This page is the contract (operators + model); the matrix is the exhaustive per-field index.
Chat and MCP apply filters through this same model — the records query is the single filter engine behind every surface.