Every read and write of your data goes through one endpoint, POST /data. A
request carries one or more operations; the response carries one result per
operation. The SDKs build these requests for you — this page shows
the JSON they send, which is also what you send with curl.
POST /data
Authorization: Bearer <access token>
Content-Type: application/json
{
"key_format": "camel",
"operations": [
{"op": "search", "entity": "movie",
"args": {"_where": {"release_year": {"_gt": 1990}}, "_limit": 10},
"selections": {"title": null, "genres": {"name": null}}}
]
}
{"results": [
{"ok": true, "data": [{"xid": "…", "title": "Dune", "genres": [{"xid": "…", "name": "Sci-Fi"}]}]}
]}
results[i] answers operations[i]. A failed operation does not stop the
others; it answers {"ok": false, "error": {"code": "…", "message": "…"}}.
Every operation runs under the caller's access rules — the entities, rows and
attributes it may not see are not there (ACCESS.md). A trusted
backend can run a request as one of its users with "acting_as": "<user xid>"
(OAUTH.md).
| Field | |
|---|---|
operations | Required, non-empty array. |
key_format | Casing of the keys in the result: snake, kebab, camel or pascal. Omitted: snake, as stored. Keys you send are accepted in any casing. |
acting_as | Run as this user. Trusted confidential clients only. |
Bodies may be application/json, application/transit+json or
application/edn.
Entity names accept spaces, _ or - in any letter case: "Movie Actor",
movie_actor and movie-actor are the same entity. camelCase is not split —
movieActor is not. Attribute keys in
the result are the model's names in snake case ("Birth Date" → birth_date).
| Type | On the wire |
|---|---|
xid | a string of 22 letters and digits, URL-safe |
string, enum | a string |
int, float | a number |
boolean | true / false |
timestamp | an ISO 8601 string in UTC to the second, "2026-10-04T14:30:00Z". Send any RFC 3339 date-time; it is stored and returned in UTC. |
json, encrypted | any JSON value. An encrypted attribute arrives and leaves as plain JSON; only the database holds ciphertext. |
hashed | send the plain string; a hash is stored and the plain value is never returned |
user, group, role | a reference to one IAM record, written and selected like a relation: "owner": {"xid": "…"} |
searchRows matching args, shaped by selections.
{"op": "search", "entity": "movie",
"args": {"_where": {"title": {"_ilike": "%star%"}},
"_order_by": [["release_year", "desc"]],
"_limit": 20, "_offset": 0},
"selections": {"title": null, "release_year": null}}
getOne row by a unique value. args is a flat map, not _where:
{"op": "get", "entity": "movie", "args": {"xid": "…"}, "selections": {"title": null}}
List what you want back; nothing else is returned. A scalar is null (or
true); a relation is an object with its own selections, nested as deep as the
model goes. The record's xid is always included.
{"title": null, "actors": {"name": null, "movies": {"title": null}}}
A relation with no matching rows is left out of the result rather than returned
as [].
To filter, order or page a relation, or to select the same relation twice, use
the full form — a list of configs, each with selections, optional args and
an alias:
{"email": [
{"alias": "work", "args": {"_where": {"kind": {"_eq": "work"}}}, "selections": {"address": null}},
{"alias": "home", "args": {"_where": {"kind": {"_eq": "home"}}}, "selections": {"address": null}}
]}
A relation never drops its parent: a movie with no matching actors is still
returned. Add "_join": "inner" to a relation's args to return only the
parents that have a match.
| Key | |
|---|---|
_where | {field: {operator: value}} |
_and, _or | a list of _where maps |
_not | a _where map, negated |
_maybe | a _where map on a relation that does not drop parents |
_order_by | a list of [field, "asc"\|"desc"] pairs — always a list, so the order survives |
_limit, _offset | paging |
_distinct | a list of fields |
_join | "inner" on a relation, see above |
Operators: _eq, _neq, _lt, _le, _gt, _ge, _in, _not_in,
_like, _ilike (case-insensitive), _is_null.
_count and _agg compute over a relation in the database, without sending
the related rows. They are easiest to write in XSQL:
{"title": "Dune", "_count": {"actors": 7},
"_agg": {"ratings": {"value": {"avg": 4.3}}}}
For an entity related to itself (tree cardinality), get-tree returns a root
and everything below it, and search-tree returns the matches with every
ancestor. on is the label of the relation to follow. Rows come back as a
flat list; the SDKs nest them for you.
{"op": "get-tree", "entity": "category", "on": "parent", "root": "<xid>",
"selections": {"name": null}}
sql-templateAnalytical SQL that still runs under access rules. Name entity tables in
braces — {movie}, {user_rating.value} — and pass typed parameters as
?name:type:
{"op": "sql-template",
"template": "SELECT count(*) AS n FROM {user_rating} WHERE value >= ?min:int",
"params": {"min": 4}}
A bare table name (FROM movie) is refused, because it would bypass access
rules.
The same reads written as a query document — {"op": "xsql", "xsql": "…"}.
See XSQL.md.
Records are identified by xid. Send one to update that record, or mint one on
the client to create a record you can reference in the same batch.
| Op | |
|---|---|
stack | Create or update, and add the relations you send. Relations already on the record stay. |
sync | Create or update, and make each relation you send exactly what you send — members not listed are unlinked. Relations you leave out are not touched. |
slice | Unlink the relations named in selections from the records matching args. |
delete | Delete by identity: a unique-key map (one record) or a list of xids (up to 1000). |
purge | Delete everything a search with the same args and selections would return. |
{"op": "stack", "entity": "user_rating",
"data": {"value": 5, "movie": {"xid": "…"}}}
data may be one record or a list. Nested records in data are created or
updated and linked in the same transaction.
stack and sync differ only in what happens to the relations you send:
{"op": "sync", "entity": "movie",
"data": {"xid": "…", "genres": [{"xid": "…"}, {"xid": "…"}]}}
After this sync the movie has exactly these two genres; any other it had is
unlinked. The same data under stack adds the two and keeps the rest. A
relation absent from data is untouched by both.
A one-to-one target has one owner, and a one-to-many child has one parent.
Linking it to a record — or listing the child under a parent — moves it
there: the record that held it is unlinked in the same write, under stack
and sync alike. Taking it is a change to that previous owner, so your access
rules must let you see and write it. One write that gives a target or a child
two owners is refused. Listing a child its parent already holds changes
nothing. Many-to-many links are never moved.
delete names records by identity, never by filter:
{"op": "delete", "entity": "movie", "args": {"xid": "…"}}
args is a map of one unique key (one record) or a list of up to 1000 xids.
For "everything matching", use purge with the args of a search.
In one request, writes run first, in order, then the reads — so a request can write and read the result back.
Branch on error.code; messages may change.
| Code | |
|---|---|
UNAUTHORIZED | No or invalid token (HTTP 401). |
FORBIDDEN | Missing scope for this operation. |
ENTITY_FORBIDDEN, ATTRIBUTE_FORBIDDEN | Your roles may not touch this entity, or write this attribute (ACCESS.md). |
UNKNOWN_ENTITY, UNKNOWN_ATTRIBUTE | Not in the model, or not visible to you. Carries a hint when a close name exists. |
UNKNOWN_OPERATOR, BAD_ARGS_SHAPE | A typo in args. |
UNIQUE_VIOLATION, NOT_NULL_VIOLATION, FK_VIOLATION, CHECK_VIOLATION, TYPE_CAST_FAILURE | The database refused the write. |
DELETE_FORBIDDEN | The record exists but your access rules hide it. |
ROW_FORBIDDEN | The write changes a record your access rules let you see but not write. |
SLOT_OCCUPIED | The link you are replacing, or the one-to-one target or one-to-many child you are taking, belongs to a record your access rules hide. |
BAD_DATA_SHAPE | data contradicts the model, e.g. one write gives a one-to-one target or a one-to-many child two owners. |
XSQL_PARSE_ERROR | With line and column. |
TIMEOUT | The statement was cancelled. |
INTERNAL_ERROR | Details are in the server log only. |
Use watch in an SDK. Underneath, GET /data/events is a server-sent event stream
of what changed, scoped by POST /data/subscription/set; the SDK re-runs your query
on each event, so every refresh passes access rules again. Streams close after
SYNTHIGY_SERVER_SSE_MAX_LIFETIME_MS and SDKs reconnect.
POST /history reads the audit history of a record: its state at a point in
time, the changes between two points, and who made them. See
DATALINE.md.
Can you improve this documentation?Edit on GitHub
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |