Quickstart
A first integration, in six steps: let three things in — a line of text, a web page, a document — wait for each to be read, connect two of them, and lay all three out on a view. Then the three doors out: find the line again, read the meters, take everything with you.
Every body on this page is parsed, in our tests, by the same schema its route reads it with. A field renamed on the wire breaks our build before it can break yours.
Before you start
Mint a key in Settings → Developer → API Keys — every key needs the Pro plan; see Authentication — and give it the scopes these requests need, and nothing more:
| Scope | For |
|---|---|
keep.write | POST /api/v1/keepGET /api/v1/keep/intake/{intake_id} |
drive.upload | POST /api/v1/drive/uploads/signPOST /api/v1/drive/files |
graph.write | POST /api/v1/relations |
canvas.write | POST /api/v1/canvas/viewsPOST /api/v1/canvas/views/{view_id}/concepts/batchPATCH /api/v1/canvas/views/{view_id}/concepts/batch |
concepts.read | GET /api/v1/searchPOST /api/v1/users/me/exportsGET /api/v1/users/me/exports/{export_id}DELETE /api/v1/users/me/exports/{export_id} |
graph.read | POST /api/v1/users/me/exportsGET /api/v1/users/me/exports/{export_id}DELETE /api/v1/users/me/exports/{export_id} |
canvas.read | POST /api/v1/users/me/exportsGET /api/v1/users/me/exports/{export_id}DELETE /api/v1/users/me/exports/{export_id} |
history.read | POST /api/v1/users/me/exportsGET /api/v1/users/me/exports/{export_id}DELETE /api/v1/users/me/exports/{export_id} |
drive.read | POST /api/v1/users/me/exportsGET /api/v1/users/me/exports/{export_id}DELETE /api/v1/users/me/exports/{export_id} |
people.read | POST /api/v1/users/me/exportsGET /api/v1/users/me/exports/{export_id}DELETE /api/v1/users/me/exports/{export_id} |
threads.read | POST /api/v1/users/me/exportsGET /api/v1/users/me/exports/{export_id}DELETE /api/v1/users/me/exports/{export_id} |
notifications.write | POST /api/v1/users/me/exportsGET /api/v1/users/me/exports/{export_id}DELETE /api/v1/users/me/exports/{export_id} |
admin.read | POST /api/v1/users/me/exportsGET /api/v1/users/me/exports/{export_id}DELETE /api/v1/users/me/exports/{export_id} |
GET /api/v1/usage needs none. Put the key in your shell, and every request below reads it from there:
export MADLESS_API_KEY=mad_sk_…Every API request goes to https://api.madless.com — a key resolves its own space, so there is no slug to get right. Every write sends an Idempotency-Key; here $(uuidgen) mints a fresh one per call. In your own code, mint one per write and send it again on that write’s retries: the same key with the same body replays the first answer instead of writing twice, and the same key with a different body is refused. POST /api/v1/keep will not run without one.
The answers are the reference’s own examples, trimmed to what matters here, so the ids in them are not yours. Carry your own forward from each answer to the next request.
1 · Keep a short text
POST /api/v1/keep is the one door in: one request, one thing. It answers the moment the material is staged, and the reading runs behind the answer.
curl -X POST 'https://api.madless.com/api/v1/keep' \ -H "Authorization: Bearer $MADLESS_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H 'Content-Type: application/json' \ -d '{ "intent": { "kind": "text", "title": "Why spaced repetition works", "content": "Retrieval, not review, is what strengthens memory." } }'
{ "data": { "id": "itk_9m2vq7c31tehwr5x8kda", "state": "reading", "kind": "text", "name": "Why spaced repetition works", "concept_id": null, "refusal": null } }
There is no concept yet, and that is the point: nothing is kept until Madless has read it. The receipt names the intake and its phase, and Location names the one door that will say how it went. Step 4 waits there.
2 · Keep a web link
The same door, with "kind": "link". Madless fetches the page and reads it; a page with nothing readable behind it is refused by name — link_unreadable — and no concept is made.
curl -X POST 'https://api.madless.com/api/v1/keep' \ -H "Authorization: Bearer $MADLESS_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H 'Content-Type: application/json' \ -d '{ "intent": { "kind": "link", "url": "https://en.wikipedia.org/wiki/Spaced_repetition" } }'
The same receipt as the first, with its own id. Keep the Location. The page itself is kept too, as a link-archive file beside the concept, so what you read stays readable; a purge of the concept takes it with it. Keeping an address you already kept makes a second concept — Madless asks the person, in Review, whether the two are one.
3 · Keep a small document
Bytes never ride the keep body. They go in on the file path first — sign, put, register — and the keep names the file by its id.
Sign an upload
curl -X POST 'https://api.madless.com/api/v1/drive/uploads/sign' \ -H "Authorization: Bearer $MADLESS_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H 'Content-Type: application/json' \ -d '{ "name": "report.pdf", "mime": "application/pdf", "size_bytes": 1048576 }'
{ "data": { "url": "https://…", "object_path": "spaces/acme/files/2026/03/20/5f3c9b1a-8e2d-4a7c-9f01-2b6d4e8a1c30-report.pdf", "expires_at": "2026-03-20T14:19:05.000Z" } }
url takes the PUT for fifteen minutes — the upload has to start in that window, not finish in it. Keep it and object_path for the next two calls. size_bytes is optional here and worth sending: it is what the storage check reads before a byte moves.
Put the bytes
curl -X PUT "$UPLOAD_URL" \ -H 'Content-Type: application/pdf' \ --data-binary @report.pdf
Straight to storage, never through the API — so no Authorization header: the url is signed. The Content-Type must be the mime it was signed for.
Register the file
curl -X POST 'https://api.madless.com/api/v1/drive/files' \ -H "Authorization: Bearer $MADLESS_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H 'Content-Type: application/json' \ -d '{ "name": "report.pdf", "mime": "application/pdf", "size_bytes": 1048576, "object_path": "spaces/acme/files/2026/03/20/5f3c9b1a-8e2d-4a7c-9f01-2b6d4e8a1c30-report.pdf", "checksum": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "staged_for_keep": true }'
{ "data": { "id": "fil_xyzAHTd92kLm0pQrSt1u", "name": "report.pdf", "mime": "application/pdf", "size_bytes": 1048576, "lifecycle": { "kind": "live" } } }
checksum is the SHA-256 of the exact bytes you put — shasum -a 256 report.pdf prints it — and it is checked against the bytes that landed, as size_bytes and mime are. staged_for_keep says the bytes belong to a keep that has not happened yet: a refused keep takes the file with it, and one that never comes is swept about six hours later. Leave it off for a file meant to live on its own.
Keep it
curl -X POST 'https://api.madless.com/api/v1/keep' \ -H "Authorization: Bearer $MADLESS_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H 'Content-Type: application/json' \ -d '{ "intent": { "kind": "document", "file_id": "fil_xyzAHTd92kLm0pQrSt1u" } }'
A document carries its file id and nothing else — the file already knows its name, its type and its size. What Madless can read, format by format, is on Supported Formats; anything else is refused at the door.
4 · Wait for it
Each receipt’s Location is its door. ?wait=25 holds the request open and answers the instant the verdict lands — or at 25 seconds, still reading, and you wait again. There is no cadence to guess.
curl 'https://api.madless.com/api/v1/keep/intake/itk_9m2vq7c31tehwr5x8kda?wait=25' \ -H "Authorization: Bearer $MADLESS_API_KEY"
{ "data": { "id": "itk_9m2vq7c31tehwr5x8kda", "state": "admitted", "kind": "text", "name": "Why spaced repetition works", "concept_id": "con_8k3fq2mv7cw31tehxr5d", "refusal": null } }
| state | What it means |
|---|---|
reading | Still being read. Wait again. |
admitted | Kept. concept_id names the concept it became. |
refused | Nothing was kept, and the staged bytes are gone. refusal.code is what to branch on; refusal.detail is the sentence to show, as it stands. |
Wait on the link’s receipt and the document’s the same way; each, once admitted, names its own concept_id. While a receipt is reading, its expires_at is the reading’s deadline; once it settles, expires_at moves to 24 hours later, and after that the receipt answers 404. A refusal Madless can see in the request alone — a format it has no reader for, text past the ceiling — never gets a receipt: it is a 422 at the door, under the same named code.
5 · Connect two of them
A connection is untyped and unnamed: it says these two are connected, and nothing more — no kind, no verb, no words. Here, the note and the link.
curl -X POST 'https://api.madless.com/api/v1/relations' \ -H "Authorization: Bearer $MADLESS_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H 'Content-Type: application/json' \ -d '{ "source_id": "con_8k3fq2mv7cw31tehxr5d", "target_id": "con_8f3a2b1cAHTd92kLm0Rt" }'
{ "data": { "id": "rel_xyzRt7Wq2Zx9Kp4Vn6Bm", "directed": false, "lifecycle": { "kind": "live" }, "created_by_client": { "kind": "api_key", "name": "API key" } } }
Either way round is the same connection: connecting the pair again is a 409 already_exists that carries the relation_id already joining them. created_by_client says a key drew this one — every record a key writes says so.
6 · Make a view and place the three
A view is an arrangement, not a container: a concept can sit on many views, or on none. Make an empty one, then place all three in one call.
curl -X POST 'https://api.madless.com/api/v1/canvas/views' \ -H "Authorization: Bearer $MADLESS_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H 'Content-Type: application/json' \ -d '{ "mode": "explicit", "name": "Spaced repetition", "graph": { "nodes": {} } }'
{ "data": { "id": "vw_arch1AHTd92kLm0pQrSt", "world_id": "wld_privateAHTd92kLm0Rt7", "shared": null, "graph": { "nodes": {} } } }
"shared": null — it is private. It lands in your active World; name another with world_id. A key can make a view but never delete one: that stays with the person. To have it removed, ask — POST /api/v1/proposals with a delete_view question the person answers. The question is theirs from then on: the key that asked cannot withdraw it (dismissing is 403 human_review_required to a key), and it resolves the moment they delete the view. For a test run you can undo whole, use a test key, whose sandbox POST /api/v1/sandbox/reset wipes.
curl -X POST 'https://api.madless.com/api/v1/canvas/views/vw_arch1AHTd92kLm0pQrSt/concepts/batch' \ -H "Authorization: Bearer $MADLESS_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H 'Content-Type: application/json' \ -d '{ "concepts": [ { "concept_id": "con_8f3a2b1cAHTd92kLm0Rt", "position": { "x": 0, "y": 0 } }, { "concept_id": "con_8k3fq2mv7cw31tehxr5d", "position": { "x": 360, "y": 0 } }, { "concept_id": "con_4d6e8f0aAHTd92kLm0Rt", "position": { "x": 720, "y": 0 } } ] }'
{ "data": { "created": [ { "concept_id": "con_8f3a2b1cAHTd92kLm0Rt", "position": { "x": 0, "y": 0 } }, … ], "skipped": [] } }
Every row lands in created or in skipped, never neither: a row that cannot be placed is skipped with its own code and detail, and the rest still land. Up to 200 rows a call, and a row sent without a position is laid out beside the others.
Moving a card is the same row with new numbers in it. Send only what changes — a position, a size, a lock — and the rest of the card stays as it is:
curl -X PATCH 'https://api.madless.com/api/v1/canvas/views/vw_arch1AHTd92kLm0pQrSt/concepts/batch' \ -H "Authorization: Bearer $MADLESS_API_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H 'Content-Type: application/json' \ -d '{ "concepts": [ { "concept_id": "con_8f3a2b1cAHTd92kLm0Rt", "position": { "x": 120, "y": 340, "locked": true } } ] }'
{ "data": { "updated": [ { "concept_id": "con_8f3a2b1cAHTd92kLm0Rt", "position": { "x": 120, "y": 340, "locked": true } } ], "skipped": [] } }
The three doors out
Everything above went in. This is how it comes back.
Find the line again
One call reads by meaning and by word, and hands back the thing itself and where it sits — never a sentence about it.
curl 'https://api.madless.com/api/v1/search?q=montserrat' \ -H "Authorization: Bearer $MADLESS_API_KEY"
{ "data": [ { "id": "con_defAHTd92kLm0pQrStRt", "title": "Catalonia: A Walking History", "url": "/?concept=con_defAHTd92kLm0pQrStRt", "module": "concepts", "highlight": [ { "text": "the jagged formations of ", "bold": false }, { "text": "Montserrat", "bold": true }, { "text": " rise above the monastery…", "bold": false } ], "page": 57, "source_id": "fil_bookAHTd92kLm0pQrSt1" } ], "next_cursor": null }
highlight quotes the passage that matched, your words in bold. page says where it sits in a document, slide in a deck, and at_ms the moment in a recording the passage begins — each only where it applies. url is the path that opens it in Madless; join it to your space’s address. semantic=false is the words-only pass, for an instant first paint you follow up.
Read the meters
No scope needed, so even the narrowest key can check its headroom before a billable call.
curl 'https://api.madless.com/api/v1/usage' \ -H "Authorization: Bearer $MADLESS_API_KEY"
{ "data": { "plan": "pro", "period": { "bucket": "2026-08", "resets_at": "2026-09-01T00:00:00.000Z" }, "media_seconds": { "used": 1260, "limit": 36000, "remaining": 34740 }, "concepts": { "used": 342, "limit": null, "remaining": null }, "storage_bytes": { "used": 38975235, "limit": 107374182400, "remaining": 107335207165 }, "api_requests": { "used": 1240, "limit": 10000, "remaining": 8760, "resets_at": "2026-09-02T00:00:00.000Z" } } }
Every meter is used, limit and remaining, in its own unit — seconds, bytes, requests. "limit": null is no ceiling, and remaining is null with it. api_requests counts every request made through a key or a connected AI over a rolling day — one number for the whole space. Its resets_at is the next 00:00 UTC, when today’s tally closes; the window slides, so the count never drops to zero at that moment.
Take everything with you
Everything you let in leaves with you, in full, whenever you want it.
curl -X POST 'https://api.madless.com/api/v1/users/me/exports' \ -H "Authorization: Bearer $MADLESS_API_KEY" \ -H "Idempotency-Key: $(uuidgen)"
{ "data": { "id": "exp_8f3a2b1cAHTd92kLm0Rt", "state": "building", "created_at": "2026-11-05T09:00:00.000Z", "completed_at": null, "available_until": "2026-11-12T09:00:00.000Z", "volume_count": null, "volumes": [], "size_bytes": 0, "partial_errors": false, "failure_reason": null } }
It builds behind the answer. Poll the door Location names until state is ready — while it builds, Retry-After says when to look again:
curl 'https://api.madless.com/api/v1/users/me/exports/exp_8f3a2b1cAHTd92kLm0Rt' \ -H "Authorization: Bearer $MADLESS_API_KEY"
{ "data": { "id": "exp_8f3a2b1cAHTd92kLm0Rt", "state": "ready", "created_at": "2026-11-05T09:00:00.000Z", "completed_at": "2026-11-05T09:00:14.000Z", "available_until": "2026-11-12T09:00:00.000Z", "volume_count": 1, "volumes": [ { "index": 1, "name": "madless-export-exp_8f3a2b1cAHTd92kLm0Rt-1-of-1.zip", "size_bytes": 1048576, "url": "https://…", "expires_at": "2026-11-06T09:00:14.000Z" } ], "size_bytes": 1048576, "partial_errors": false, "failure_reason": null } }
Then download every volume’s url — a ZIP, the bytes included. The links live 24 hours and are signed afresh on every read of the door; the archive is kept seven days, until available_until. If state reads failed, failure_reason says why. Done with it sooner? Throw it away — the same answer whether it was still there or not:
curl -X DELETE 'https://api.madless.com/api/v1/users/me/exports/exp_8f3a2b1cAHTd92kLm0Rt' \ -H "Authorization: Bearer $MADLESS_API_KEY" \ -H "Idempotency-Key: $(uuidgen)"
Where next
Every refusal named here, and every other, is on Errors. Putting people on that view is Inviting People, and every operation is in the API Reference.