Skip to content
Documentation
Contents

Building tools

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. fetch sends it along with credentials: "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.

TypeOperators
texteq, ne, in, contains, starts_with, is_null
integer, decimal, date, datetimeeq, ne, in, lt, lte, gt, gte, is_null
choice, reference, person, contexteq, ne, in, is_null
booleaneq, is_null
longtextis_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

CallBodyAnswer
POST /api/collections/<collection>the fields, flat201 with the stored row
GET /api/collections/<collection>/<id>the row
PUT /api/collections/<collection>/<id>version and the changed fieldsthe 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.

CallBodyAnswer
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.

StatusMeaning
400invalid request, with the field named in errors
401the session has ended. Send the person to /
403no permission. Signing in again does not help
404unknown, or not visible to you
409the version no longer matches
413the body is too large
415wrong content type
422a limit of the request is exceeded