API
The REST API you didn't write
Every workspace publishes a full REST API generated from its schema — filtering, sorting, pagination, relationship expansion and an OpenAPI document — with no code generation step and nothing to deploy.
Define a schema in SchemaStack and an API exists. Not a scaffold you then deploy, not a client you generate — a running HTTP API in front of the Postgres or MySQL database you own, with CRUD for every entity, and an OpenAPI document describing it.
You can look at one right now without signing up. Our public demo workspace publishes its own description:
https://data.schemastack.io/api/v1/acme/demo/_openapi
That's OpenAPI 3.0.3, 19 paths and 46 operations across 9 entities — categories, products, customers, orders, order lines, suppliers, reviews, tags. Point Swagger, Postman, Insomnia, an OpenAPI client generator or an AI assistant at it. There's a browsable version at _docs in the same place. Neither needs a credential; the data endpoints do.
The shape
The path is your schema. The query string is the API.
# Paid orders, newest first, with the customer inlined, trimmed to four fields
curl "https://data.schemastack.io/api/v1/acme/demo/Order\
?filter[status]=paid\
&sort=createdAt,desc\
&expand=customer\
&fields=id,total,createdAt,customer.name\
&page=0&size=25" \
-H "Authorization: Bearer sk_live_…"Filtering takes twelve operators — eq, neq, gt, gte, lt, lte, like, in, isNull, isNotNull, startsWith, endsWith — with the operator as a dot suffix inside the brackets — filter[total.gte]=100, or just filter[status]=paid since eq is the default. Multiple conditions combine with AND. Values are coerced to the column's real type, so filter[createdAt.gte]=2026-01-01 compares as a date, not a string.
Sorting takes several fields, and only fields that exist: the sort clause is built against a whitelist of the entity's columns, formula fields and relationship columns, because assembling ORDER BY from user input any other way is how injection happens.
Expansion inlines related records — expand=customer,items.product — nested and depth-capped. It's implemented as paginate-the-ids-then-join-fetch rather than fetch-everything-then-paginate, which is the difference between a query the database can plan and one that pulls the table into memory to count to twenty.
Field selection trims the response: fields=id,total,customer.name, dot notation for expanded relations.
What you can turn off
The API is generated, not thrown open. Per entity you control which relationships may be expanded, which fields may be filtered, the default expansion and field set, the maximum expansion depth, and a request rate limit. Per column, apiVisible hides a field from every response — the column stays in your database and in the grid, and simply doesn't exist as far as the API is concerned.
Access is by workspace API key, read-only or read-write, rotatable and revocable. An entity can also be marked publicly readable or publicly writable when that's genuinely what you want — a public product catalogue, say.
For end-users of your own application there's a better door than a shared key: point the workspace at your identity provider and let them call the API as themselves, with row-level security narrowing every query to their rows.
What it doesn't do (yet)
- The generated OpenAPI document doesn't describe filtering. It documents
page,size,sort,expandandfields, but notfilter[...]— so a generated client won't know the feature exists, and won't know aboutnotIn,betweenor OR groups either. Same for the/bulkendpoints, which are real but absent from the spec. This is the gap we'd most like to close next, because it's the one that makes the others invisible. - OR groups don't nest.
(a AND b) OR cis expressible, and you can AND plain filters around the whole disjunction.(a OR b) AND (c OR d)is not. - Filtering across a collection relationship searches the far side's text columns. If the target has none there is nothing to search, and the filter is refused rather than silently dropped — but the restriction stands: you cannot filter a linked collection by a number or a date.
in,notInandbetweenaren't available across one either. likehas no case-insensitive variant, and no way to escape a literal%.- No maximum page size. The default is 20 and you can ask for far more; rate limiting, not a page cap, is what bounds it.
- REST only — there's no GraphQL.
Filters used to be AND-only, with no NOT IN and no BETWEEN. All three arrived later: filter[status.notIn]=archived,void, filter[age.between]=18,65, and OR as numbered groups — filter[or][0][status]=paid&filter[or][1][priority]=urgent, where a group's conditions are AND-ed, groups are OR-ed, and the result is AND-ed with whatever sits outside. Row-level security stays outside those groups on purpose, so an OR in a query string can never widen it into someone else's rows.
Three more entries left this list, and they were the same mistake three times: answering 200 instead of arguing. A malformed filter value (filter[age]=abc on an integer column) returned an empty list, which reads as "no rows match" — a claim about your data rather than about your query. An unknown sort field was ignored, giving you primary-key order under a 200. And a filter across a collection with nothing searchable on the far side was dropped altogether, so a filter meant to narrow the result returned every row. All three now refuse with a 400 naming the field, matching how an unknown filter field was always treated.
The API reference has the full syntax, and each workspace's own _openapi is the authoritative description of your API — because it's generated from your schema, not from ours.
Verified 26 Aug 2026: the demo workspace's OpenAPI document read live from production (19 paths, 46 operations, 9 entities); query behaviour covered by the whole workspace-api suite, 560 tests green on 2026-08-26 — filtering, field selection, expansion, serialization and the CRUD controller integration tests. Re-verified on 2026-08-26 because three limitations named here were fixed — new end-to-end cases assert that a bad filter value and an unknown sort field now answer 400 rather than a misleading 200, and that a collection filter with nothing searchable on the far side is refused rather than silently matching every row..