REST data API
Every table in your project is a REST endpoint. Row-Level Security is enforced on every request.
All requests need Authorization: Bearer <key-or-token>.
PATCH and DELETE refuse to run without at least
one filter — there is no accidental full-table write.
Reading rows
GET /rest/v1/todos?select=id,task&done=is.false&order=id.desc&limit=20
| Parameter | Meaning |
|---|---|
select | Comma-separated columns; omit or * for all. |
<column>=op.value | Filter — see operators below. Repeat for AND. |
order | column.asc / column.desc, comma-separated; append .nullsfirst / .nullslast. |
limit | 1–1000, default 100. |
offset | Skip rows, default 0. |
Filter operators
| Operator | SQL | Example |
|---|---|---|
eq / neq | = / <> | ?status=eq.active |
gt gte lt lte | comparisons | ?price=lte.100 |
like / ilike | LIKE / ILIKE (* becomes %) | ?name=ilike.*silva* |
is | IS NULL / TRUE / FALSE / UNKNOWN | ?done=is.false |
isdistinct | IS DISTINCT FROM | ?a=isdistinct.b |
in | = ANY(…) | ?id=in.(1,2,3) |
match / imatch | ~ / ~* (POSIX regex) | ?code=imatch.^ab |
fts plfts phfts wfts | @@ full-text (plain / phrase / web search) | ?body=fts.hello |
Quote items in in.(…) to include commas
(?tag=in.("a,b","c")). A full-text operator may name a text-search
config: ?body=fts(english).hello.
Negation and logical trees
Prefix any operator with not. to negate it, and combine
conditions with or=(…) / and=(…) — nestable, using
the dotted column.op.value form inside:
# NOT: rows where id is not 3
GET /rest/v1/todos?id=not.eq.3
# OR: done, or high priority
GET /rest/v1/todos?or=(done.is.true,priority.gte.5)
# nested: (done = false) AND (priority ≥ 5 OR pinned = true)
GET /rest/v1/todos?done=is.false&and=(priority.gte.5,or(pinned.is.true))
Counting rows
Ask for the total number of matching rows with the
Prefer: count=exact header. The total comes back in the
Content-Range response header (and populates the
count field that client libraries read):
curl -sI "https://<ref>.kethosbase.com/rest/v1/todos?done=is.false" \
-H "Authorization: Bearer <key>" \
-H "Prefer: count=exact"
→ Content-Range: 0-19/42
The count applies the same filters as the read, ignoring
limit and offset.
A single object instead of an array
Send Accept: application/vnd.pgrst.object+json to get one JSON
object back instead of an array. It answers 406
(code PGRST116) when the result is not exactly one row — this is
the contract behind a client's .single() (errors on zero rows)
and .maybeSingle() (maps the 406 to null):
curl "https://<ref>.kethosbase.com/rest/v1/todos?id=eq.1" \
-H "Authorization: Bearer <key>" \
-H "Accept: application/vnd.pgrst.object+json"
→ { "id": 1, "task": "ship it", "done": false }
Embedding related tables
Name a related table inside select to pull its rows in the
same request. The relationship is resolved through a foreign
key: a to-one FK embeds a JSON object (or null), a
to-many FK embeds an array.
GET /rest/v1/posts?select=id,title,author(*),comments(id,body)
→ [ { "id": 1, "title": "Hello",
"author": { "id": 7, "name": "Alice" },
"comments": [ { "id": 3, "body": "nice" } ] } ]
Add !inner to drop parent rows with no embedded match
(author!inner(*)); !left is the default. Embeds
nest, and mix freely with * and plain columns. Every embedded
subquery runs under the same Row-Level Security as the base
read — an embed can never surface a row a direct read couldn't, so it adds
reach, never privilege.
400 until
supported so nothing misbehaves silently: !hint disambiguation
when two tables share more than one FK, spread (...rel(col)),
many-to-many through a junction table, composite FKs, and filtering or
ordering on an embed. Top-level filters, order, and pagination are
unaffected.Inserting rows
POST a JSON object, or an array of objects for a batch insert:
curl -X POST "https://<ref>.kethosbase.com/rest/v1/todos" \
-H "Authorization: Bearer <key>" \
-H "Content-Type: application/json" \
-H "Prefer: return=representation" \
-d '[{"task": "one"}, {"task": "two"}]'
With Prefer: return=representation the response carries the
inserted rows (including generated ids); without it you get
201 Created and no body.
Upsert
Insert, but merge on a conflict instead of failing. POST with
Prefer: resolution=merge-duplicates (update the existing row) or
resolution=ignore-duplicates (leave it). The conflict target
defaults to the table's primary key; name a different unique column set with
?on_conflict=:
curl -X POST "https://<ref>.kethosbase.com/rest/v1/todos?on_conflict=id" \
-H "Authorization: Bearer <key>" \
-H "Content-Type: application/json" \
-H "Prefer: resolution=merge-duplicates,return=representation" \
-d '{"id": 1, "task": "revised"}'
A table with neither a primary key nor an explicit on_conflict
answers 400 with a clear message rather than a raw database
error.
Updating and deleting
curl -X PATCH "https://<ref>.kethosbase.com/rest/v1/todos?id=eq.1" \
-H "Authorization: Bearer <key>" \
-H "Content-Type: application/json" \
-d '{"done": true}'
curl -X DELETE "https://<ref>.kethosbase.com/rest/v1/todos?done=is.true" \
-H "Authorization: Bearer <key>"
Both answer 204 No Content, or the affected rows with
Prefer: return=representation.
Calling functions (RPC)
Invoke a PostgreSQL function in the public schema over
/rest/v1/rpc/{function}. POST supplies named arguments in the
JSON body; a read-only function can be called with GET and query-string
arguments:
curl -X POST "https://<ref>.kethosbase.com/rest/v1/rpc/search_todos" \
-H "Authorization: Bearer <key>" \
-H "Content-Type: application/json" \
-d '{"q": "ship"}'
A set-returning function yields a JSON array; a scalar or single-composite
function yields the bare value; a void function returns
204. The call runs under the caller's role with RLS applied, so
a function honours its own PostgreSQL EXECUTE grants — to keep a
function off the publishable key, revoke execute … from public
(and the anon role).
Responses and errors
Successful reads return a JSON array (or a single object with the
object Accept header). On the data routes errors come back as a
flat object so a client's error type populates directly:
{ "message": "…", "code": "…", "details": null, "hint": null }
An anonymous caller gets generic text with no details/
hint; only the secret key sees database detail. The auth and
storage routes keep their own { "error": { "code", "message" } }
envelope. See Limits & errors for every status code,
the 10-second statement timeout, and the daily request quotas.