Base Types
The shapes every endpoint shares. Read this once and the rest of the reference stops surprising you — most integration bugs start on this page, not in any individual endpoint.
Typed IDs
Every resource carries a Stripe-style typed prefix: <prefix>_<tail>, where the tail is 20 base62 characters. The prefix is a wire contract — con_xyz is unmistakably a concept, and you can branch on it without a lookup.
| Prefix | Resource |
|---|---|
con_ | Concept — the atomic unit on the Canvas |
not_ | Note embedded in a concept |
nvr_ | Note version — a checkpoint of a note, kept for you on edit, delete and restore |
skt_ | Sketch embedded in a concept |
rel_ | Connection between two concepts. Untyped, with an optional caption |
lbl_ | Label |
vw_ | View |
wld_ | World — an area grouping views |
fil_ | File |
prp_ | Proposal — a write filed for review instead of applied |
itk_ | Intake — a keep in flight, before it is admitted or refused |
ask_ | Ask answer — one question, one checked answer |
tlm_ | Act-log row on GET /history/events |
trl_ | Trail row — something you opened |
sch_ | A committed search on the trail |
inv_ | Invitation — a place held for one person on one view |
msg_ | Thread message |
ntf_ | Notification |
whk_ | Webhook subscription |
evt_ | Webhook event — the business event a delivery carries, the same across every retry |
whm_ | Webhook message — one event bound for one subscription; what a retry re-sends |
whd_ | Webhook delivery attempt — the webhook-id header and its row in the delivery log |
Three identifiers are not minted from this table. API key tokens are mad_sk_<hex>. A person is their raw account uid — uid, created_by, added_by — with no prefix. A space is its subdomain: the acme in acme.madless.com.
People
A person has one shape. Wherever one is embedded — the people on a view, a pending invitation, a connection, who kept a card — it is person, with the same four keys, all of them always present. What the row says about the relationship — role, permission, joined_at, status — sits beside person, never inside it.
| Field | What it means |
|---|---|
uid | Their stable public user id. Key on this — it never changes. |
handle | Their handle — the mutable display and mention layer, or null when they have not chosen one. Never key on it: it can move to somebody else. |
name | The name they chose, or null when they set none — render @handle then. |
photo_url | A relative /api/v1/people/{handle}/avatar?v=… path for a photo Madless hosts (fetch it with your credential; v changes on every upload), a third party’s absolute URL for an avatar they signed in with, or null. |
Two reads are a person and open with the same four keys: a profile card (GET /people/{handle}, plus joined_at) and your own account (user on GET /users/me, plus what only you may see). A person merely referenced — owner_uid, created_by, added_by, author_uid — is a bare uid.
Timestamps
Every timestamp is an ISO 8601 UTC string, in both directions. If a field ends in _at, it is a string — reading or writing, nested or top-level.
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: nothing here decides whose noon you meant.
| Field | What it means |
|---|---|
created_at | When the record was written. Never changes. |
updated_at | When it last changed. |
happened_at | The real-world date a concept’s subject happened — distinct from when you kept it. Often set for you from a photo’s EXIF taken-date. Null on most concepts. |
taken_at | A file’s EXIF taken-date. |
at, next_fire_at, fired_at, last_fired_at, acknowledged_at | The revisit object — a chosen-return reminder’s schedule and history. You set at; the rest are written for you. |
last_interacted_at | When the concept was last touched. See also client_touched_at — when a non-human caller last wrote to it. Both need history.read. |
The response envelope
Every successful response is wrapped: read response.data, never the body directly. A single-resource delete returns 204 with no body at all; the handful that answer with one are listed in full on the Pagination page.
Request bodies are snake_case (label_ids, not labelIds) and strict — an unknown field returns 400 with an errors[] entry naming the key. Do not guess field names.
List endpoints are cursor-paginated, and every page carries next_cursor — a string while more pages exist, null on the last one, never absent. Pagination & Envelopes is where that contract lives in full: the cursor, the Link header, the per-resource page windows, conditional reads, and the DELETE rule.
Markdown in, HTML stored and returned
A note’s content is an HTML string — the same markup the in-app editor produces — capped at 40,000 of your characters: the text, not the markup around it (the stored HTML may run to 60,000). There is one read format: HTML is what is stored and what comes back, every time.
You can write in Markdown. Send content_format: "markdown" and the body is read as GFM and converted before it is stored — on POST /concepts/{id}/notes, PATCH …/notes/{id}, and on each entry of notes[] when you create a concept. The field defaults to "html"; without it, Markdown is stored verbatim and renders as literal asterisks.
Either way the HTML passes through one sanitizer, which keeps only what the editor can hold. That vocabulary is complete below — send something outside it and you get the row’s stated result, not a surprise.
| You send | You get back |
|---|---|
Headings # to ### | h1, h2, h3 |
#### and deeper | h3 — the editor holds three levels, so deeper headings flatten rather than lose the heading |
Bold, italic, inline code, ~~strike~~ | strong, em, code, s — b, i, del and strike are accepted and rewritten to these |
| Bulleted and numbered lists | ul, ol, li |
| Task lists (- [ ] / - [x]) | Plain list items — the tick box is not kept. Make a checklist in the app. |
| Links, blockquotes, code blocks, rules | a (http/https/mailto only, always rel="noopener noreferrer"), blockquote, pre + code, hr |
| Images | 422 with errors[].code = "unsupported_image", in markdown and in HTML alike. A note is text — media lives on the concept, via POST /api/v1/keep |
| Task lists | Kept — - [ ] and - [x] become the editor’s own task list, tick and all |
| Tables | 422 with errors[].code = "unsupported_markdown_table" — in markdown or in HTML. A note cannot hold a table, and running the cells together while losing the header row is worse than a clear refusal |
script, iframe, audio, video, event handlers, javascript: links | Dropped, silently and always — on both formats |
A few leftovers survive the write but not the first edit in the app — a wrapper div, an attribute the editor has no use for — so don’t build on them.
Errors
RFC 9457 application/problem+json. Branch on type (and errors[].code for validation failures) — never on detail, which is prose and will change.
The Errors page has the rest: the full type catalogue, the shape of errors[], the status table, and how idempotency keys behave on a retry.
Ask, don’t assume
GET /api/v1/users/me answers in three blocks: user (who you are), access (how this credential authenticated — auth_method — its expanded scopes, the space’s entitlements, roles and proposal_review), and deployment (the environment and the base_url you reached — the host you called). Introspect a key there rather than inferring it from which calls return 403. It is identity and access only: your plan is not on it.
The plan and its meters are a separate question and live at GET /api/v1/usage — the plan, then credits, minutes, images, storage and views with people, each a used / limit / remaining triple. Read it before a billable call rather than meeting a cap as a 402. Quotas vary by space; none of them are constants worth hardcoding.