REST — Mutations¶
Writes go through POST (insert), PATCH (partial update), PUT (replace),
and DELETE. Every write uses the Content-Profile: <schema> header for
multi-schema routing (reads use Accept-Profile).
For the GraphQL equivalent of every mutation below, see GraphQL overview.
Insert — POST¶
curl -X POST https://api.example.com/api/v1/issues \
-H "Authorization: Bearer $JWT" \
-H "Content-Type: application/json" \
-H "Content-Profile: kanban" \
-H "Prefer: return=representation" \
-d '{"title": "New issue", "status": "todo", "priority": "high"}'
Returns 201 with the created row (see Response shape):
{ "data": { "id": 42, "title": "New issue", "status": "todo", "priority": "high", "created_at": "2026-04-16T01:00:00+00:00" } }
Bulk insert — send an array:
curl -X POST https://api.example.com/api/v1/issues \
-H "Content-Profile: kanban" \
-H "Content-Type: application/json" \
-d '[
{ "title": "First", "status": "todo" },
{ "title": "Second", "status": "todo" }
]'
Upsert on conflict — use the Prefer: resolution=merge-duplicates header:
curl -X POST https://api.example.com/api/v1/issue_labels \
-H "Content-Profile: kanban" \
-H "Content-Type: application/json" \
-H "Prefer: resolution=merge-duplicates" \
-d '{"issue_id": 1, "label_id": 5}'
Without the header, a conflict returns 409 Conflict. The upsert merges on the primary key; name another unique
key with on_conflict (comma-separated columns):
curl -X POST "https://api.example.com/api/v1/products?on_conflict=sku" \
-H "Prefer: resolution=merge-duplicates" \
-d '{"sku": "E-5", "name": "Cola Zero"}'
Dry run — Prefer: tx=rollback executes the statement and returns the
result but rolls back the transaction. Useful for testing write paths:
curl -X POST https://api.example.com/api/v1/issues \
-H "Prefer: tx=rollback" \
-d '{"title": "will not persist"}'
Update — PATCH¶
Update rows matching a filter. Same filter syntax as reads.
curl -X PATCH "https://api.example.com/api/v1/issues?id=eq.42" \
-H "Content-Profile: kanban" \
-H "Content-Type: application/json" \
-d '{"status": "in_progress"}'
Bulk update via a broader filter:
curl -X PATCH "https://api.example.com/api/v1/issues?project_id=eq.1&status=eq.todo" \
-H "Content-Profile: kanban" \
-d '{"priority": "medium"}'
Replace — PUT¶
Replace the full row (all non-PK columns are required in the body).
curl -X PUT "https://api.example.com/api/v1/issues?id=eq.42" \
-H "Content-Profile: kanban" \
-H "Content-Type: application/json" \
-d '{"title": "Updated", "status": "done", "priority": "low", "project_id": 1, "story_points": 3}'
Delete — DELETE¶
curl -X DELETE "https://api.example.com/api/v1/issues?id=eq.42" \
-H "Authorization: Bearer $JWT" \
-H "Content-Profile: kanban"
Bulk delete with a broader filter:
curl -X DELETE "https://api.example.com/api/v1/issues?status=eq.done&created_at=lt.2024-01-01" \
-H "Content-Profile: kanban"
Response shape¶
A write answers {"data": ...} when representation is requested
(Prefer: return=representation): a single-object POST answers the row as an
object, while an array POST, PATCH, PUT and DELETE answer an array of rows.
This is kept as it is for existing clients; send a one-element array to always get an
array back. A row the role may not read is not represented. A tracked function that
returns a single row answers {"data": {...}}, or {"data": null} when it returns no
row; a set-returning one answers {"data": [...]}.
Returning a representation¶
By default write responses include the affected row(s). To get full
Postgres-style representation control, use the Prefer: return=* headers:
| Header | Effect |
|---|---|
Prefer: return=representation |
Return the full row (default) |
Prefer: return=minimal |
Return 204 No Content with no body — faster for bulk writes |
Prefer: return=headers-only |
Return only the Location header pointing at the new row |
curl -X POST https://api.example.com/api/v1/issues \
-H "Prefer: return=minimal" \
-H "Content-Profile: kanban" \
-d '[{"title":"a"},{"title":"b"},{"title":"c"}]'
# HTTP/1.1 204 No Content
Transaction control¶
| Header | Effect |
|---|---|
Prefer: tx=commit |
Default — commit the statement |
Prefer: tx=rollback |
Execute and return result but roll back the transaction (dry-run writes) |
Nested transactions / multi-statement transactions are NOT supported via a
single REST call. Use GraphQL mutations (which compose in one document) or
a tracked VOLATILE function if you need atomicity across multiple writes.
Functions¶
Tracked functions are called at GET/POST /rpc/<fn>; procedures are not exposed.
See Functions.
Error responses¶
| Status | code |
Meaning |
|---|---|---|
400 |
foreign_key_violation |
The referenced row does not exist, or a referencing row still does (constraint names the key) |
400 |
check_violation |
A CHECK constraint failed (constraint names it) |
400 |
not_null_violation |
A required column has no value (column names it) |
400 |
exclusion_violation |
An EXCLUDE constraint failed (constraint names it) |
400 |
raised_exception |
A trigger or function raised an error (Postgres RAISE EXCEPTION, MySQL SIGNAL); message is the raised message only |
400 |
invalid_value |
A value the column's type cannot hold, in a filter or the body (column names it when known) |
400 |
— | Malformed filter, unknown column or operator, invalid JSON body (error says which) |
401 |
— | Missing/invalid JWT |
403 |
permission_denied |
A method the role holds no permission for, or a column it may not write (column names it); a column the permission presets says it is set by the server |
403 |
permission_check_failed |
A written row does not pass the role's permission check |
404 |
— | Missing Accept-Profile / Content-Profile header in multi-schema mode, or unknown table |
409 |
unique_violation |
Primary key or unique constraint violation without Prefer: resolution=merge-duplicates (constraint names it) |
500 |
internal_error |
A fault on the server's side; the body says nothing more and the cause is logged |
Errors return a JSON body with a stable code, a message, the same text in
error for older clients, and the constraint or column it is about when
known:
{
"code": "unique_violation",
"message": "duplicate key value violates unique constraint \"issues_pkey\"",
"error": "duplicate key value violates unique constraint \"issues_pkey\"",
"constraint": "issues_pkey"
}
The message is the database's primary message only: never the PL/pgSQL context
(Where: PL/pgSQL function … line N), the Detail line with row values, the
SQL text or a stack trace.
A write whose rows fail the role's permission check (403) is typed and rolls
back whole; a method the role holds no permission for on a table it can see is
403 permission_denied. The message never contains database text; error
mirrors message for older clients:
{
"code": "permission_check_failed",
"message": "A written row does not pass the role's permission check",
"error": "A written row does not pass the role's permission check"
}
See the error contract.