Pagination & Envelopes
Almost every endpoint answers in one of four shapes: a single object, a page of a list, an empty success, or a problem. Learn the four and the reference can surprise you with what is inside a body, not with the body itself. Three endpoints sit outside them and say so on their own pages: POST /api/v1/ask streams Server-Sent Events, and GET /api/v1/media/image plus GET /api/v1/drive/files/{file_id}/content hand back bytes — a 302 to storage by default, or the bytes themselves. Branch on the response Content-Type, never on the parameter you sent.
The four shapes
| Shape | When | Body |
|---|---|---|
{ data } | One resource — a read, a create, an update. | The resource under data. Never read the body directly. |
{ data, next_cursor } | Any list. | An array under data, plus the cursor for the next page. |
204 | A single-resource delete. | Nothing at all — not an empty object, no body. |
application/problem+json | Any failure. | RFC 9457. See the Errors page. |
There is no third key, anywhere — no meta, no exceptions. Anything that is genuinely a property of an item lives on the item (is_active on a world). Anything that is about something else gets its own resource: the per-user canvas pointers (last_active_view_id, pinned_view_ids, pinned_world_ids) belong to no single view, so they answer at GET /api/v1/canvas/views/pointers rather than riding the views list.
Cursor pagination
Lists are cursor-paginated — there are no page numbers and no offsets. Ask for a page, then hand back the cursor you were given:
GET /api/v1/concepts?limit=20
{ "data": [ … ], "next_cursor": "WzE2OTg3NjU0MzIsImNvbl84ZjNhMmIxY0FIIl0.dHM4WlNyOFhx…" }
next_cursor is always present. It is a string while another page exists and null on the last page — it is never omitted, so you read body.next_cursor without checking whether the key is there. A list that is one page by nature (a by-ids read, a ranked search page) still ships the key as null, so “one page, by construction” and “the last page” look the same to you.
A sort forfeits the cursor. On GET /api/v1/concepts, any sort ranks the whole live set in memory and answers a single top-limit page, so next_cursor comes back null and sending a cursor alongside one is a 400. Widen with limit instead. The plain browse list is the mode that threads pages.
The cursor is opaque and signed. Do not construct one, decode one, or edit one — a tampered cursor is a 400, not an empty page. Loop until the cursor is null:
let cursor = null; do { const url = new URL('https://acme.madless.com/api/v1/concepts'); url.searchParams.set('limit', '50'); if (cursor) url.searchParams.set('cursor', cursor); const res = await fetch(url, { headers: { Authorization: `Bearer ${key}` } }); const body = await res.json(); for (const concept of body.data) handle(concept); cursor = body.next_cursor; // null ends the loop } while (cursor);
| Parameter | Default | What it does |
|---|---|---|
limit | 20 | Items per page, 1–100 — the window most lists keep. A handful of resources set their own, tabled just below. |
cursor | — | The opaque continuation token from the previous page. |
sort | created_at | The field to order by, where a list offers ordering at all — /concepts and /drive/files today. Each names the fields it accepts on its own page. |
order | desc | asc or desc, alongside sort. The three /history reads take it on its own: newest first by default, asc for oldest first. A cursor continues the order it was minted under, so send the same order on every page. |
The default and the ceiling are per-resource, and the resources below are not 20/100. Size your pages off the endpoint you are calling, never off the house number — the corpus read is two orders of magnitude wider than a search page, and both are correct.
| Endpoint | Default | Range | Why |
|---|---|---|---|
GET /concepts | 200 | 1–500 | A whole canvas load is one read, and every lens (`placement`, `revisit`, `lifecycle`) pages through this same window. |
GET /labels | 500 | 1–500 | The label taxonomy is bounded and served whole — default and ceiling are the same number on purpose. |
GET /notifications | 50 | 1–100 | A notification list is read in one sitting; 50 covers a normal backlog without a second call. |
GET /threads/{anchor_type}/{anchor_key} | 50 | 1–100 | A thread reads newest-first in screenfuls. |
GET /concepts/{concept_id}/similar | 10 | 1–50 | Ranked neighbours — past the first handful the ranking stops meaning anything. |
GET /search | 5 | 1–100 | A ranked page. The default is small because the first few results are the answer. |
Stay inside the window. Asking for more than a resource’s ceiling is not a way to request everything — it is a 400 with errors[].code invalid_limit, never a page quietly capped at the ceiling.
When there is a next page, the same cursor also arrives as an RFC 8288 Link header, ready to follow without rebuilding the URL. The header is emitted only when a next page exists — its absence means the same thing as next_cursor: null.
Link: <https://madless.com/api/v1/concepts?limit=20&cursor=WzE2OT…>; rel="next"
The URL in that header is built on the apex, https://madless.com, not on the host you called. It resolves either way, but if you call a space subdomain and follow the header verbatim you will change origin mid-loop. Take the cursor from next_cursor and keep your own base URL — the loop above does exactly that, which is why it is the one worth copying.
Caching & polling
A handful of reads emit a weak, content-derived ETag. Send it back as If-None-Match and an unchanged answer costs you a 304 with no body. A 304 carries the ETag and cache headers only — no X-Request-Id, no X-RateLimit-*; the edge strips the rest, so read those off a 200. Poll this way and you stay well inside your rate limit.
# 1. Read, and keep the validator. curl -sD- 'https://acme.madless.com/api/v1/concepts?limit=50' -H 'Authorization: Bearer mad_sk_abc123...' # → 200 OK # ETag: W/"a3f9c1e0…" # 2. Ask again with it. curl -s -o /dev/null -w '%{http_code} ' 'https://acme.madless.com/api/v1/concepts?limit=50' -H 'Authorization: Bearer mad_sk_abc123...' -H 'If-None-Match: W/"a3f9c1e0…"' # → 304
Which reads answer 304: GET /concepts (browse and signal modes only — a ?q= search, the trash lens and a by-ids read emit none), GET /labels, GET /drive/files, GET /notifications, GET /users/me, GET /directory, GET /metadata and one item, GET /concepts/{concept_id}. Every tag is weak (W/-prefixed) and content-derived, so it stays stable while the data does — it is not a timestamp that rotates under you.
A concept’s tag is its whole body. It is hashed after the labels, files and placements are filled in, so a renamed label or a new preview changes it even when the concept’s own updated_at did not move.
To reconcile rather than poll, use the change feed. GET /api/v1/history/changes is the durable record of what actually moved — concept.created, concept.updated, relation.created — cursor-paginated and bounded by since and until (seven days back when since is omitted). Ask with since set to the newest change you have seen and order=asc, and you get the deltas rather than the corpus. An ETag tells you whether something changed; the change feed tells you what.
Verifying a webhook
Deliveries follow Standard Webhooks. Three lowercase headers ride on every request — no X- prefix:
| Header | What it is |
|---|---|
webhook-id | The message’s whm_… id — the message_id its rows carry in the delivery log. The SAME on every retry and replay of it, so dedupe on it. |
webhook-timestamp | Unix SECONDS. Reject anything far outside your clock skew. |
webhook-signature | One or more space-separated v1,<base64> tokens. Accept if ANY of them matches. |
The signed string is {webhook-id}.{webhook-timestamp}.{rawBody}, HMAC-SHA256 keyed on your signing secret’s decoded bytes (strip whsec_, base64-decode the rest), base64-encoded. Sign the RAW body bytes, before any JSON parse — re-serialising changes the whitespace and the signature will never match again. The secret is shown exactly once, in the response that creates the subscription. A subscription’s own headers (a receiver’s Authorization, say) ride beside these three and can never take one of their names.
import { createHmac, timingSafeEqual } from 'node:crypto'; function verify(rawBody, headers, secret) { const id = headers['webhook-id']; const ts = headers['webhook-timestamp']; // Replay window — reject anything older than five minutes. if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false; // The secret is "whsec_" + base64(key); the key is the DECODED bytes. const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64'); const expected = createHmac('sha256', key).update(id + '.' + ts + '.' + rawBody).digest('base64'); // Multiple signatures ride during a key rotation — any match is a pass. return String(headers['webhook-signature']) .split(' ') .some(token => { const sig = token.startsWith('v1,') ? token.slice(3) : token; const a = Buffer.from(sig); const b = Buffer.from(expected); return a.length === b.length && timingSafeEqual(a, b); }); }
The body is one envelope: { space_id, event_id, event_type, schema_version, occurred_at, data }. schema_version is 2; branch on it if you ever need to. occurred_at is ISO 8601 like every other moment on this wire — only the webhook-timestamp HEADER is Unix seconds, because Standard Webhooks says so. A retry re-sends the same event_id, so deduplicate on that, never on webhook-id.
The DELETE rule
Most DELETEs answer 204 with no body. The rule turns on one question: does the response tell you something you could not compute from the request? You named the concept; the concept is gone, and a body would only be another shape to version.
6 answer 200 with a body, because each leaves you knowing something you did not send. A bulk delete resolves the set server-side, so “empty the trash” cannot tell you how many files that was unless it says so. A cascade delete moves what it did not remove — deleting a World reassigns its Views to an heir the server picks. And a delete that changes a resource rather than removing it hands the changed resource back.
This is the complete list. Everything else answers 204.
| Route | Returns |
|---|---|
DELETE /api/v1/drive/trash | { purged_count, bytes_reclaimed } |
DELETE /api/v1/concepts/trash | { purged_count } |
DELETE /api/v1/notifications | { deleted_count, has_more } — the sweep is batched, so has_more says whether to call again |
DELETE /api/v1/canvas/worlds/{world_id} | { world_id, reassigned_to, moved_view_count } |
DELETE /api/v1/canvas/views/{view_id} | { view_id, removed_concept_ids } — which concepts lost their placement. The concepts themselves survive; a view is an arrangement, not a container |
DELETE /api/v1/canvas/views/{view_id}/people | { windows_deleted, nodes_removed, board_lines_deleted, threads_deleted } — Remove everyone is immediate and total, so the body says what went with the people |
Whether a second call is safe is per-operation, and every operation declares it. These treat “already gone” as the state you asked for, answer the same way forever, and declare no 404 at all:
DELETE /api/v1/concepts/{concept_id}DELETE /api/v1/relations/{relation_id}DELETE /api/v1/labels/{label_id}DELETE /api/v1/drive/files/{file_id}DELETE /api/v1/people/connections/{connection_id}DELETE /api/v1/webhooks/subscriptions/{subscription_id}DELETE /api/v1/concepts/trashDELETE /api/v1/drive/trashDELETE /api/v1/notifications
Every other DELETE answers 404 the second time, and says so on its reference page.
Dates and ids on the wire
Every timestamp is an ISO 8601 UTC string, in both directions. If a field ends in _at, it is a string. When you send one, include a zone — 2026-07-25T12:00:00Z or 2026-07-25T13:00:00+01:00. A bare 2026-07-25T12:00:00 is rejected rather than guessed at.
Every id carries a typed prefix — con_ for a concept, vw_ for a view — so you can branch on what you were handed without a lookup. The prefix is part of the contract; the full table is on Base Types.
Fields are snake_case both ways (label_ids, not labelIds), and nothing you send is silently dropped. An unknown body key is a 400 naming it. A query parameter the route does not read is a 400 with errors[].code unknown_parameter, and one sent twice is repeated_parameter — a list is one value, comma-separated (?kinds=a,b), never the name repeated.
When it fails
Failures are RFC 9457 problem documents, served as application/problem+json. Branch on type, never on detail — that one is prose and will change. Every body also carries instance (the request path) and request_id, which is what to quote when something goes wrong. request_id is a bare 26-character ULID with no prefix — log it verbatim rather than parsing it.
{ "type": "https://developers.madless.com/errors#validation_error", "title": "validation error", "status": 400, "detail": "Invalid concept data", "instance": "/api/v1/concepts", "request_id": "01KZ92SE71CC9KHCVKX8H4TEVK", "errors": [ { "detail": "Must be a string", "pointer": "#/title", "code": "invalid_type" } ] }
Validation failures add an errors array with one entry per field. pointer is an RFC 6901 JSON Pointer into the body you sent, written in its fragment form — #/title, or #/files/0/file_id for something nested. The leading # is always there, so compare with it or strip it deliberately; a client matching on a bare /title will miss every one. code is the stable machine handle; detail is for a human. The full catalogue of type values is on the Errors page.