Appearance
Conventions
The API is FastAPI-generated and consistent in the ways that matter. Learn these once and most endpoints need no further reading.
Pagination
List endpoints take page and limit:
| Parameter | Default | Notes |
|---|---|---|
page | 1 | 1-based. Values below 1 are clamped to 1. |
limit | 50 | Clamped to at least 1, and to a per-endpoint ceiling — usually 200, 500 on the larger collections. |
and return an envelope rather than a bare array:
json
{
"items": [ … ],
"page": 1,
"limit": 50,
"total": 137,
"has_more": true
}total is the count matching your filters, not the collection size. Prefer has_more over comparing page * limit against total — it is computed server-side and stays correct when your requested limit was clamped. The envelope echoes the limit actually applied, so compare against that rather than against what you asked for.
Not everything paginates. Endpoints returning a bounded set — a single record's comments, the tag list, a summary — return a plain array or object.
Filtering and search
Filters are query parameters named after the field, and they combine with AND:
GET /tasks?project_id=…&status=open&status=blocked&due_before=2026-01-01Repeating a parameter, as with status above, means OR within that field.
q is a free-text search over the resource's own text fields, available on most list endpoints. It is a filter like any other, so it narrows rather than replaces the rest of the query. For searching across every kind of record at once, use the dedicated search endpoint instead.
Dates and times
Everything crossing the wire is ISO 8601. Timestamps are UTC and carry an offset; date-only filters such as due_before take a plain YYYY-MM-DD.
Calendar data is the exception you would expect: events come from CalDAV collections and carry their own timezone information, because "09:00 in Amsterdam" and "08:00 UTC" stop meaning the same thing twice a year.
Soft deletion
Several resources are soft-deleted: DELETE marks the record and hides it rather than removing it. Those list endpoints take include_deleted=true if you want it back in the results. Where a hard delete exists it is a separate, explicitly named operation — and where deleting one thing can cascade into others, that is an opt-in flag on the request (delete_contacts, delete_events, delete_emails), never a silent side effect.
Errors
Failures return a JSON body with a detail field and a conventional status:
| Status | Means |
|---|---|
401 | Not signed in, or the token expired. See Authentication. |
403 | Signed in, but the endpoint needs a role you do not have. |
404 | No such record — or one you are not allowed to see. |
409 | The change conflicts with the current state. |
422 | The request did not validate. |
401 and 404 are deliberately not distinguished from "exists but is not yours": a record you cannot see is a record that does not exist, as far as the API will tell you.
Most of these carry a plain string: {"detail": "task not found"}.
422 is the exception. It is FastAPI's schema-validation error, so detail is an array — one entry per problem, each locating the offending field:
json
{
"detail": [
{
"loc": ["body", "password"],
"msg": "String should have at least 8 characters",
"type": "string_too_short"
}
]
}Handle both shapes. A hand-written 422 raised by a route's own check — rather than by schema validation — uses the string form like every other status.
Identifiers
Most records are addressed by a single opaque string id. Contacts and calendar events are the exception: they live in DAV collections and are addressed by the pair of collection and item — /contacts/{addressbook_id}/{uid}, /events/{calendar_id}/{uid}. A uid is only unique within its collection.
Linking records
Cross-references between records are their own resource rather than a field on each side. A link names both endpoints by {type, id}, which is what lets any kind of record link to any other. See links for the edges and graph for walking them.