Reading and writing data
The calls of the data interface: query, page, write, evaluate, export.
A tool talks to the instance over HTTP and JSON, with relative paths under /api/. Usually it calls flow.js for this (Layout of a tool). This page describes the calls underneath. For a particular collection, describe_api gives them with the real field names.
Two rules for every call
- Whoever sees the page is signed in. The session is a cookie.
fetchsends it along withcredentials: "same-origin". A tool knows no token. - Every request with a body carries
Content-Type: application/json. Without it the instance answers with 403 or 415.
Querying
POST /api/collections/<collection>/query. Reading is a POST, so that no filter value ends up in an address.
{
"filter": {"and": [
{"field": "status", "op": "in", "values": ["offen", "in arbeit"]},
{"field": "faellig", "op": "lte", "value": "2026-12-31"}
]},
"sort": {"field": "faellig", "direction": "asc"},
"limit": 50
}
The answer:
{
"records": [
{"id": "bd4c5a07-…", "created_by": "4b40b106-…", "created_at": "2026-10-08T14:17:06.747188Z",
"updated_at": "2026-10-08T14:17:06.747188Z", "version": 1,
"titel": "Wartungsfenster ankuendigen", "status": "offen", "faellig": "2026-10-05"}
],
"hasMore": true,
"cursor": "…"
}
A row carries the technical columns and, flat next to them, its fields.
Filter. A filter is one condition {"field", "op", "value"} or a group {"and": [...]} or {"or": [...]}. in takes a list under values, is_null takes no value and matches empty fields. Without filter you get all rows you may read.
| Type | Operators |
|---|---|
text | eq, ne, in, contains, starts_with, is_null |
integer, decimal, date, datetime | eq, ne, in, lt, lte, gt, gte, is_null |
choice, reference, person, context | eq, ne, in, is_null |
boolean | eq, is_null |
longtext | is_null |
Sorting. By exactly one field, asc or desc. You cannot sort by longtext, reference, person or context.
Paging. limit is at most 200, the default is 50. If hasMore is true, send the same body with the cursor from the answer for the next page. Never build a cursor yourself and do not reuse one when filter or sorting change.
Limits. At most 20 conditions, three levels of nesting, 100 values in one in. Exceeding one is a 422.
Following references
include brings fields of the row a reference field points at, in the same call:
{"include": [{"through": "kunde", "fields": ["name", "ort"]}]}
The values come back flat in each row, as kunde.name and kunde.ort. The reference field itself stays the id. You need read permission on the other collection. Only query takes include.
For a picker, GET /api/collections/<collection>/fields/<field>/options returns the possible targets as {"options": [{"id", "label"}], "hasMore"}.
Creating, changing, deleting
| Call | Body | Answer |
|---|---|---|
POST /api/collections/<collection> | the fields, flat | 201 with the stored row |
GET /api/collections/<collection>/<id> | the row | |
PUT /api/collections/<collection>/<id> | version and the changed fields | the row with its new version |
DELETE /api/collections/<collection>/<id> | {"version": 3} | {"status": "deleted"} |
{"version": 3, "status": "erledigt", "faellig": null}
Every change and every delete states the version you read. If it no longer matches, someone has changed the row in the meantime: the instance answers with 409 and writes nothing. Reload the row and show the current state instead of overwriting it.
null clears a field. A required field cannot be cleared.
Counting and evaluating
Do the arithmetic on the server, not in the browser. All three calls take the same filter as query.
| Call | Body | Answer |
|---|---|---|
POST …/count | {"filter"} | {"count": 23} |
POST …/stats | {"field", "filter"} | rows, values, min, max, for numbers also sum and avg |
POST …/aggregate | {"groupBy", "aggregate", "filter", "limit", "sort"} | {"groups": [...], "more": false} |
{"groupBy": ["status"], "aggregate": [{"function": "sum", "field": "aufwand"}]}
{"groups": [{"status": "offen", "sum_aufwand": 2.7500}], "more": false}
The functions are count, sum, avg, min and max. Without aggregate the call counts. It returns at most limit groups (default 100), sorted by the first measure ("sort": "measure") or by the value of the group ("sort": "value"). count and stats refuse sort, cursor and limit.
Exporting and importing CSV
POST …/export takes filter and sort and answers with all matching rows as a CSV download. In a tool: fetch, response.blob(), then a link with download. The instance records every export in the audit log.
POST …/import takes the CSV bytes in the body and the file name in the X-File-Name header, not as multipart. The first line names the fields. The import skips the technical columns of an export. It creates rows and never changes existing ones. It is all or nothing: with one bad row it writes nothing and names the line number. The answer is {"created": 12}.
Errors
An error answer is application/problem+json:
{
"title": "Invalid request",
"status": 400,
"code": "UNKNOWN_FIELD",
"detail": "There is no such field in collection aufgaben",
"correlationId": "cc366e9e-…",
"errors": [
{"code": "UNKNOWN_FIELD", "field": "nix",
"allowedValues": ["aufwand", "faellig", "status", "titel"],
"message": "There is no such field in collection aufgaben"}
]
}
Show message next to the field named in field. The correlationId helps the administration find the request.
| Status | Meaning |
|---|---|
| 400 | invalid request, with the field named in errors |
| 401 | the session has ended. Send the person to / |
| 403 | no permission. Signing in again does not help |
| 404 | unknown, or not visible to you |
| 409 | the version no longer matches |
| 413 | the body is too large |
| 415 | wrong content type |
| 422 | a limit of the request is exceeded |