API

Everything you can ask the API for

Twelve filter operators in bracket notation, multi-field sorting against a whitelist, depth-capped relationship expansion and field selection — the full query surface, including the parts that behave badly.

SchemaStack team22 Aug 2026Verified working · 26 Aug 2026Docs

The API exists whether you asked for one or not. This is the reference for what you can actually put in the query string — and, because it's a reference and not a brochure, where it disappoints.

Filtering

One condition per parameter, with the operator as a dot suffix inside the brackets:

curl "https://data.schemastack.io/api/v1/acme/demo/Order\
?filter[status]=paid\
&filter[total.gte]=100\
&filter[createdAt.gte]=2026-01-01" \
  -H "Authorization: Bearer sk_live_…"

eq is the default, so filter[status]=paid and filter[status.eq]=paid are the same thing. Every other operator has to be named.

Twelve operators, and this is all of them:

OperatorMeaning
eq neqequal, not equal
gt gte lt lteordered comparison
likesubstring match
inone of a list
isNull isNotNullpresence
startsWith endsWithanchored text match

Multiple conditions combine with AND. There is no OR, and no way to group.

filter[total.gte]=100fieldtotalmust be a real columnoperatorgteone of twelvevalue"100"still text herecoerced, then boundtotal >= ? · 100 (numeric)A date column compares as a date, not as a string. A number that will not parse matches nothing —which is the one behaviour here we would change if we were starting again. See the limits below.Multiple conditions combine with AND. There is no OR.
A filter is parsed into field, operator and value, the value is coerced to the column's real type, and the clause is built against a whitelist — never by pasting your text into SQL.

The value arrives as text and is coerced to the column's real type before comparison, so filter[createdAt.gte]=2026-01-01 compares as a date rather than lexically, and filter[total.gte]=100 compares as a number. The clause is then built with a bound parameter — your text never reaches SQL as SQL.

Sorting

&sort=createdAt,desc&sort=total,asc

Several fields, applied in order. The sort clause is assembled against a whitelist of the entity's real columns, its formula fields and its relationship columns, because building ORDER BY from user input any other way is how injection happens.

Pagination

&page=0&size=25

Zero-indexed. Default size is 20.

Expansion

&expand=customer,items.product

Related records are inlined, nested with dot notation, and capped at a configured depth. It's implemented as paginate the ids, then join-fetch those — rather than fetch everything and paginate in memory, which is the difference between a query the database can plan and one that reads the table to count to twenty.

Per entity you control which relationships may be expanded at all, the default expansion, and the maximum depth.

Field selection

&fields=id,total,createdAt,customer.name

Trims the response. Dot notation reaches into expanded relations. Separately, a column marked apiVisible=false doesn't exist as far as the API is concerned — it stays in your database and in the grid, and never appears in a response or accepts a value.

What it doesn't do (yet)

This is the honest half, and there's more of it than we'd like. It is also the list we work from, so things leave it by being fixed, not by being tidied away — and a new feature usually adds a line or two of its own.

  • OR groups are one level deep. A group's conditions are AND-ed and the groups are OR-ed, which covers (a AND b) OR c and lets you AND plain filters around the whole thing. You cannot nest a group inside a group, and you cannot AND two independent OR groups together — (a OR b) AND (c OR d) has no spelling here.
  • The generated OpenAPI document doesn't describe filter[...] at all. It documents page, size, sort, expand and fields, so a generated client won't know filtering exists — and now won't know about notIn, between or OR groups either. Same for the /bulk endpoints, which are real and undocumented. Adding filtering to the spec is the next thing worth doing here.
  • Filtering across a collection relationship only searches text columns on the far side. If the target has none there is nothing to search — that now refuses rather than silently matching everything, but the restriction itself hasn't moved: you can't filter a linked collection by a number or a date.
  • in, notIn and between aren't available across a collection relationship. A list or a range over a to-many search has no single sensible reading, so it's refused rather than quietly treated as equality, which is what it used to be.
  • between is always inclusive and takes exactly two bounds. There's no exclusive variant and no open-ended form — use gte/lte for those. It compares however the column's type compares, which is worth knowing on a text column: between a,m there is a lexicographic range, not a numeric one.
  • like has no case-insensitive variant and no escape for % in your search term.
  • No maximum page size. The default is 20 and you may ask for far more; rate limiting, not a page cap, is what bounds a large request.
  • REST only. No GraphQL.

OR, notIn and between arrived later

This post's first version said "AND only — no OR, no parenthesised groups, no NOT IN, no BETWEEN." All four exist now. notIn and between are ordinary operators (filter[status.notIn]=archived,void, filter[age.between]=18,65, inclusive, exactly two bounds or it's a 400). OR is expressed as numbered groups:

?filter[region]=EU
&filter[or][0][status]=paid
&filter[or][0][total.gte]=100
&filter[or][1][priority]=urgent

Conditions inside a group are AND-ed, groups are OR-ed, and the whole disjunction is AND-ed with the plain filters outside it — so that reads region is EU, and either it's a paid order over 100 or it's urgent.

The nesting is not just for expressiveness. Row-level security arrives as ordinary top-level conditions, so keeping the groups nested is what stops customer_id = you OR total > 100 from being a legal reading of your filter. Flatten those into one list and an OR in a query string becomes a cross-tenant read. There is a test named after exactly that.

Three of these are now fixed

When this post was written, the query layer had a habit worth naming: guessing instead of refusing. Three things answered 200 when they should have argued.

A malformed value — filter[age]=abc on an integer column — matched nothing and reported success, which is not the same statement as "no rows match" and left you unable to tell a typo from an empty result. The API reference had described it as a 400 for longer than it had been one. An unknown sort field was dropped, so you got primary-key order under a 200. And a filter across a collection whose far side had no text columns was discarded entirely, leaving 1 = 1 behind it — a filter written to narrow a result quietly returned every row.

All three now refuse with a 400 that names the field, which is what a misspelled operator and an unknown filter field always did. The query layer is consistent with itself again. A fourth thing improved on the way past: asking to expand a relationship that doesn't exist used to surface as a 500, the API blaming itself for a typo in your query string, and is now a 400 as well.

The API reference has the full syntax, and your workspace's own _openapi document is the authoritative description of your API — generated from your schema, not from ours.

Verified 26 Aug 2026: the whole workspace-api suite, 560 tests green on 2026-08-26 — filter parsing, collection-filter clause building, field selection, expansion request parsing, expansion integration, serialization and both CRUD controller integration suites — plus every query string on this page issued against a running API, after the first version of this post documented a syntax that does not work. Re-verified on 2026-08-26 because three limitations this post named were fixed — four new end-to-end cases in SlugBasedCrudControllerIntegrationTest assert that a bad filter value and an unknown sort field now answer 400 while their valid counterparts still answer 200, and CollectionFilterWhereClauseTest asserts that a collection filter with nothing searchable is refused rather than dropped..