Developersv1.0.0

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.

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

FieldWhat it means
uidTheir stable public user id. Key on this — it never changes.
handleTheir 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.
nameThe name they chose, or null when they set none — render @handle then.
photo_urlA 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.

FieldWhat it means
created_atWhen the record was written. Never changes.
updated_atWhen it last changed.
happened_atThe 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_atA file’s EXIF taken-date.
at, next_fire_at, fired_at, last_fired_at, acknowledged_atThe revisit object — a chosen-return reminder’s schedule and history. You set at; the rest are written for you.
last_interacted_atWhen 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 sendYou get back
Headings # to ###h1, h2, h3
#### and deeperh3 — 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 listsul, ol, li
Task lists (- [ ] / - [x])Plain list items — the tick box is not kept. Make a checklist in the app.
Links, blockquotes, code blocks, rulesa (http/https/mailto only, always rel="noopener noreferrer"), blockquote, pre + code, hr
Images422 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 listsKept — - [ ] and - [x] become the editor’s own task list, tick and all
Tables422 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: linksDropped, 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.