Authentication
Every API request must include a valid API key as a Bearer token in the Authorization header. Create and manage keys in Settings → Developer → API Keys inside your space.
Getting a key
Only a space admin can mint a key. Minting one requires:
- The Pro plan, which carries the
api-keysentitlement — for every key, live or test. - The
admin.writescope on whoever mints it — a fact about the minter, never a scope the key needs: the key carries only the scopes you pick for it.
A key’s scopes are clamped to the scopes of the admin who created it — you cannot mint a key more privileged than yourself. Rotate or revoke a key at any time; a revoked key stops working within 30 seconds.
Each space can hold up to 3 active live keys and 1 active test key. Native Madless apps use separate device credentials and do not consume either allowance. Mint past the relevant limit and the API returns 409 Conflict. Revoked keys don’t count toward the limits.
Bearer Token
The header value must be Bearer <your-api-key>.
curl 'https://acme.madless.com/api/v1/concepts' \ -H 'Authorization: Bearer mad_sk_abc123...'
When authentication fails
A request that cannot prove who sent it is a 401 unauthorized, whichever host it reached. The body carries a code naming which of these it was — branch on that, never on the detail:
- No credentials at all —
no_credential. The detail says how to send a key — a key in the URL or the body is never read — and the challenge is a bareWWW-Authenticate: Bearer realm="madless", with noerror, because nothing was presented. - An Authorization header in another scheme —
Basic, a typo inBearer:unsupported_scheme. The detail says Bearer is the only scheme, and the challenge carrieserror="invalid_request". - A bare key —
mad_sk_…with no scheme in front of it:unsupported_scheme. The detail shows the one shape that works; the challenge carrieserror="invalid_request". - A key this environment has never seen —
unknown_key. Keys are bound to the environment that minted them — development and production are separate backends — so the body names the one you reached inenvironmentand the detail points you at the other. A revoked or mistyped key lands here too. The challenge carrieserror="invalid_token". - A token that has expired or been revoked —
expired_tokenwhen its own expiry has passed, otherwiseinvalid_token; a Bearer value that is neither a key nor a token ismalformed_credential. The challenge carrieserror="invalid_token".
Each cause reads the same on your space’s host, on the apex and on api.madless.com.
A key that is valid but sent to another space’s subdomain is a 421 misdirected_request. The key names its own space, so the host naming a different one is said out loud rather than quietly answered from the key’s own space. The body carries base_url, where the key belongs — your space’s own address. (The deployment.base_url on GET /users/me is a different fact: the environment’s apex.) The apex madless.com and api.madless.com always find a key’s own space.
{ "type": "https://developers.madless.com/errors#misdirected_request", "title": "misdirected request", "status": 421, "detail": "This key belongs to the space at https://acme.madless.com, not to other.madless.com. Send it there, or to the apex, where a key always finds its own space.", "base_url": "https://acme.madless.com", "instance": "/api/v1/concepts", "request_id": "01KZ92SE71CC9KHCVKX8H4TEVK" }
Once a credential is proven, a 403 forbidden says what it may not do, and its code says why — the scopes and entitlements below.
API Key Format
Every key is prefixed, and there are two kinds — live, and test:
mad_sk_a1b2c3d4e5f6... # live — your real space mad_sk_test_a1b2c3d4e5f6... # test — an isolated sandbox
mad_Platform prefix — identifies this as a Madless credential.
sk_Key type — secret key (server-side only).
test_Sandbox marker — a test key only ever reaches an isolated sandbox. See Test keys & the sandbox below.
Entitlements
Some endpoints need a plan entitlement as well as a scope. If your space does not have the one an endpoint requires, the API returns 403 Forbidden with the error type forbidden and a code of missing_entitlement (with details.requested and details.missing listing the entitlements). The discriminator is the code field, not the error type.
canvasaskpeoplethreadsdriveapi-keyswebhooksimportsmcplabsAI credits
Endpoints that run a paid AI model draw down your space’s AI credit allowance — monthly on a paid plan, a one-time grant on Starter. When the allowance is spent, those endpoints return 402 with an ai_credits_exhausted error type. On a paid plan, credits reset at the start of each billing cycle. Check headroom before a billable call at GET /api/v1/usage — every meter in one read, and it needs no scope.
The same 402 carries a different error type when a USD budget is hit rather than the credit count: ai_usd_budget_exhausted (your space’s USD ceiling), ai_daily_usd_cap_exhausted (a per-day cap, which clears the next day), ai_platform_budget_exhausted or ai_platform_monthly_budget_exhausted (platform-wide safety limits, daily and monthly). Branch on the error type, not the bare 402.
Two more 402 types are not about credits at all, so topping up credits will not clear them — media_minutes_exhausted (your transcription minutes) and images_exhausted (your image allowance). Both carry a reset_date — null on Starter, whose grant does not reset — and they are what an upload or a keep is most likely to return.
Three separate meters sit behind those types:
- Credits — Ask only. One credit per question, flat per call: a long question costs the same as a short one.
- Minutes — audio and video you let in, billed by duration. Clip length is capped per plan, so the per-clip cost is bounded.
- Images — one per image described, counted rather than priced.
Routine embeddings and background enrichment (e.g. re-embedding a concept you edit) are included in your plan and draw down none of the three. Allowances differ by plan and can change — the split above is the contract, not the numbers.
Scopes
A key’s scopes control which endpoints it can reach, in a family.verb pattern. A family is a capability rather than a URL prefix, and every endpoint in the API Reference names the ones it needs.
Two subtleties the grid cannot draw. concepts.delete is its own verb because trashing is reversible and rides concepts.write, while hard-deleting and emptying the trash do not. And proposals.write is a posture rather than a power: it files a proposal of any kind while holding no write scope at all, and confers nothing at resolution — accepting is still priced like the write it performs, and the destructive kinds stay person-only however the key is scoped.
| Family | read | write | delete | manage | other |
|---|---|---|---|---|---|
| Keep | |||||
| concepts | ● | ● | ● | ● | · |
| keep | · | ◎ | · | · | · |
| drive | ● | ● | · | · | upload◎ |
| See | |||||
| graph | ● | ● | · | ● | · |
| proposals | · | ● | · | · | · |
| canvas | ● | ● | · | · | · |
| history | ● | ● | · | · | · |
| activity | ● | · | · | · | · |
| Ask | |||||
| ask | ● | ● | · | · | run◎ |
| Platform | |||||
| people | ● | ● | · | · | · |
| webhooks | · | · | · | ● | · |
| threads | ● | ● | · | ● | · |
| notifications | · | ● | · | · | · |
| admin | ● | · | · | · | · |
| mcp | · | · | · | · | raw |
● the scope exists. ◎ it grants no read — keep.write lets one thing in, drive.upload signs one upload, ask.run poses one question, and none of them opens the collection behind it. Otherwise write implies read, and manage implies the lower verbs in its family.
If a request is missing a required scope, the API returns 403 Forbidden with error type forbidden, code of missing_scope, and a details object naming the scope, e.g. { "requested": ["concepts.write"], "missing": ["concepts.write"] }.
Four acts stay with the person however a key is scoped: merging concepts, emptying the trash, and deleting a View or a World. An API key or MCP connector calling one of them directly gets 403 with code human_review_required and the proposal_kind to file instead — the person accepts it in Madless.
The manage tier
Three scopes — concepts.manage, graph.manage and threads.manage — gate no URL of their own. Search the reference for one and you will find nothing behind it. That is not an omission.
Manage is a modifier on writes you already hold, not a door of its own. Every write is ownership-checked first: you may edit the concept you created, delete the connection you drew, delete the message you sent. Anything else answers 403. A key carrying the module’s manage scope skips that check — same endpoints, same request and response shapes, but it may act on records it did not author. Nothing new becomes reachable; calls that were refused stop being refused.
Manage sits above the lower verbs, so granting it grants them. concepts.manage also carries concepts.delete: granting “act on things you did not write” has already granted destruction. threads.manage is narrower still — it only lifts the author check on the talk of a view you own, never on a one-to-one thread. And graph.manage carries the same bypass into proposals: accepting one that touches a record you did not author is the one moment it is consulted.
What a key can carry
The chips under Scopes are everything you can grant when minting a key. These never go on one — admin.write and drive.manage — so what they reach stays session-only. The reason is reach: admin.write can mint keys (a key that holds it could escalate itself), and drive.manage destroys file bytes for good — a permanent file delete and emptying the file bin. Actions with that reach stay behind a person in a session, never a standing credential a script holds.
The MCP authorization flow is narrower still. These scopes can go on a key you mint for your own scripts, but the consent screen never offers them, so an AI you connect can never be granted one by a click:
concepts.managegraph.managehistory.writepeople.writeask.readask.writewebhooks.managethreads.managenotifications.writeadmin.readmcp.rawThe admin.read scope reaches space-administration endpoints (people, roles, branding, billing, API-key management) that are scope-gated but not part of this published v1 reference. They behave like every other endpoint — same auth, errors and rate limits — but their shapes are undocumented and change without notice, so do not build against them.
Test keys & the sandbox
Alongside live keys you can mint a test key — prefix mad_sk_test_. Every test key resolves to an isolated sandbox space (a sbx_… id) — a private copy seeded with a small demo mind. A test key can never read or write your production data, which is why the Playground runs them right here in your browser.
The sandbox is part of the API, so it is Pro’s: minting a test key needs the same api-keys entitlement as a live one, and so does resetting the sandbox from your space. A test key you already hold keeps reaching its sandbox if your space leaves Pro; a new one can’t be minted.
The sandbox runs no live AI. Endpoints that would call a model — keep understanding, transcription, Ask, re-embedding — return pre-baked or canned results at $0, so you can exercise the request and response shapes without spending credits. Each such endpoint says so in its console.
A sandbox is capped at 1 GB of storage and is disposable — an idle one may be reclaimed after roughly 30 days. Minting a fresh test key recreates it, re-seeded with the demo mind.
To wipe a sandbox back to that demo mind at any time, call POST /api/v1/sandbox/reset — it clears everything you created and re-seeds the original data. The Playground’s Reset sandbox button does the same.
Keep your keys secret
A live key (mad_sk_…) carries every scope it was given. Never expose one in client-side code, public repositories, or browser network requests. Test keys (mad_sk_test_…) are different — they only ever reach a private sandbox. If a key is compromised, revoke it in Settings → Developer → API Keys.