Error Handling
Conventional HTTP status codes, and every failure body in the RFC 9457 Problem Details format: a machine-readable type to branch on, a title, the HTTP status, and prose for a human.
Error Shape
Every error response uses application/problem+json as the content type. The instance field echoes the request path, and request_id carries the identifier to quote when you write to us — a bare 26-character ULID with no prefix, so log it verbatim rather than parsing it.
{ "type": "https://developers.madless.com/errors#not_found", "title": "not found", "status": 404, "detail": "Concept con_8f3a2b1cAHTd92kLm0Rt not found.", "instance": "/api/v1/concepts/con_8f3a2b1cAHTd92kLm0Rt", "request_id": "01KZ92SE71CC9KHCVKX8H4TEVK" }
Validation errors (400) include an additional errors array with one entry per failed field (detail, pointer, code) from Zod schema validation. pointer is an RFC 6901 JSON Pointer in its fragment form — it always begins with # (#/title, #/files/0/file_id), so a client matching on a bare path will miss every one.
Two answers that are not problem+json
Two refusals come from the network in front of the API, before it runs, so they carry notype, no code and norequest_id. Branch on the status only, and never parse their bodies.
- An HTML
403for someurlvalues on any route that takes one —GET /api/v1/links/preview, alinkintent onPOST /api/v1/keep, a link added to a concept — ajavascript:address, a cloud metadata address, an IP written as one decimal number. The firewall answers before the API can; the API would have refused the same address with a400. - A plain-text
413for a request body over about 4.5 MB. Bytes never ride a body here: upload a file to a signed URL and send its id.
Error Types
The type field is a stable URL that names the error class and deep-links here, e.g. https://developers.madless.com/errors#validation_error. Branch on it — never on detail, which is human-readable prose and may change.
| Type | Status | When |
|---|---|---|
| validation_error | 400 | The body or the query string failed validation. Carries an errors[] array with one entry per failed field (detail, pointer, code), and each detail says what the field accepts. A query parameter the route does not read is `unknown_parameter`; one sent twice is `repeated_parameter`. |
| unauthorized | 401 | No credentials, an Authorization header in a scheme other than Bearer, or a key or token this environment does not recognise. An unknown `mad_sk_` key says which environment you reached, because keys are bound to the one that minted them. |
| ai_credits_exhausted | 402 | Your space has used its AI credit allowance — monthly on a paid plan, where it resets at the start of each billing cycle; a lifetime grant on Starter. |
| ai_usd_budget_exhausted | 402 | This space's monthly AI budget is spent. Contact the space owner to raise it, or wait for the next cycle. |
| ai_platform_budget_exhausted | 402 | AI is temporarily unavailable platform-wide. Transient — retry later. |
| ai_platform_monthly_budget_exhausted | 402 | AI is temporarily unavailable platform-wide for the rest of the month. Transient — retry next cycle. |
| ai_daily_usd_cap_exhausted | 402 | This space's daily AI cap has been reached. Resets daily. |
| media_minutes_exhausted | 402 | This space has used its minutes — the allowance for audio and video entering it. Every clip is transcribed, so nothing is kept that cannot be read. The response carries used_sec, limit_sec, and reset_date. On a paid plan minutes refresh at the start of each calendar month (UTC); on Starter they are a lifetime grant and reset_date is null. |
| images_exhausted | 402 | This space has let in its allowance of images — every image counts once, whichever door it came through (a captured photo, an upload, a bulk import). Every image is described, so nothing is kept that cannot be read. The response carries used, limit, and reset_date. On a paid plan the count resets at the start of the next calendar month (UTC); on Starter it is a lifetime grant and reset_date is null. |
| concept_cap_reached | 402 | This space is holding as many concepts as its plan allows. Unlike the meters beside it, this one is not spent — it is occupied: the response carries cap and held, and trashing anything frees a slot immediately. Every door refuses alike (keep, POST /concepts, an import, a keep from a view with people), and restoring from the trash is checked the same way. Only the free plan carries a cap at all. |
| forbidden | 403 | Authenticated, but not allowed: the key lacks a scope (`missing_scope`) or the space an entitlement (`missing_entitlement`), the record is somebody else’s (`not_yours`), or the space is locked. Branch on `code`. |
| not_found | 404 | The addressed resource does not exist or is not visible to you — or a verified credential belongs to no space. |
| gone | 410 | The bytes behind a byte read are gone for good — `GET /drive/files/{file_id}/content`, `/download`, `/preview` and `GET /media/image` answer it when the file they name has been deleted. Only those four: a trashed concept or file still reads `200` with `lifecycle.kind: trashed` (and a write to it is `409 concept_in_trash`), and a purged one is a `404`. Distinct from 404 — it once existed. Do not retry the same id. |
| method_not_allowed | 405 | The HTTP method is not supported for this path. The Allow header lists the methods that are. |
| conflict | 409 | The request conflicts with current state — e.g. a duplicate resource, an already-taken handle, or a second request arriving with an Idempotency-Key that is still in flight from the first call. |
| too_large | 413 | The request payload exceeds the allowed size for this endpoint. |
| misdirected_request | 421 | A key sent to a space subdomain that is not its own space’s. A key always names its own space, so the host must not name a different one. The body carries `base_url`, where the key belongs; the apex, `api.` and `mcp.` accept every key. |
| unprocessable_entity | 422 | The body parsed and validated, but what it asks for cannot be done or read: a keep Madless could not read (the body names `code` and `modality`, and no concept was created), a Markdown table in a note, uploaded bytes that do not match their declared checksum, or a proposal whose target has moved on. Malformed JSON is a 400 (validation_error), not a 422. |
| invalid_label_name | 400 | A label name on a keep failed validation (too long, empty after trimming, or contains a control character). The body carries `failure` (`empty`, `too_long` or `control_char`), the offending `name`, and `max_length`. |
| client_upgrade_required | 426 | A native Madless app older than the minimum version the platform allows for it. Only iOS, Android, macOS and Windows builds can receive it; web and API keys never do. The body names `min_version`, `current_version` and `update_url`. |
| rate_limited | 429 | Too many requests in ONE endpoint’s own bucket. Every endpoint publishes its number, its window and the smaller number a key gets on its reference page — most windows are a minute, a few are an hour (`POST /ask` is 30 per hour per person and 15 per hour through an API key). Respect the Retry-After header — it is seconds, and it is as long as the window needs: seconds on a minute bucket, minutes on an hourly one. |
| daily_cap_reached | 429 | This space has spent its rolling day of API requests — one ceiling across every key and every connected AI, counted per space, never per key. The same status as `rate_limited` and a different thing: that one is a per-endpoint bucket you wait seconds or minutes on, this one is 24 hours of traffic and you may be waiting hours. Switch on `type`, not on the status. The body carries `limit`, `used` and `retry_after`; `Retry-After` carries the same seconds. The window SLIDES rather than resetting at midnight, so headroom returns gradually. Nothing a person does in the app counts toward it. `GET /usage` reports the same numbers as `api_requests` without spending one. |
| server_error | 500 | An unexpected internal failure. Safe to retry with backoff. |
| service_unavailable | 503 | A dependency is temporarily unavailable. Safe to retry with backoff. |
| ai_budget_check_failed | 503 | AI is temporarily unavailable, so the request was refused rather than billed. Safe to retry. |
Error Codes
Branch on type, then on code. Never on detail. The type names the class of refusal; the code names which refusal inside it. The same 400 validation_error is invalid_cursor on one route and too_many_files on another, and only the code tells them apart without matching prose.
A code arrives in one of two places: on each entry of the errors array of a validation problem, one per failed field, or as code on the problem body itself. The list below is the whole vocabulary — the house codes and the schema validator’s own — and the spec publishes it as the ErrorCode enum.
It is closed in the document and open in practice. Adding a code is an additive change to v1, so treat one you have not seen before as opaque and fall back to the type. Every code is lower snake_case — the same spelling on a problem body, on each validation entry, and on a batch’s skipped rows.
| Code | Status | When |
|---|---|---|
| already_delivered | 409 | A webhook delivery retry named a delivery that landed — there is nothing to re-send. |
| already_exists | 409 | The thing this call would create is already there — the same connection, or the same card on that view. |
| already_kept | 409 | The card on that view has already been kept into your own Canvas. |
| already_requeued | 409 | A webhook delivery retry named a delivery that is already re-queued; poll its deliveries for the outcome. |
| answer_not_found | 404 | No Ask answer with that id is in your record — it was never kept there, or it was deleted. |
| block_cap_reached | 409 | A concept already holds as many links, files or sketches as one can; `detail` names the cap. |
| block_not_found | 404 | The block a move named is not on the source concept any more. |
| block_refused | 422 | One block of a composed keep could not be read, so nothing was kept; `block_ref` names the block, `block_code` carries that block’s own code and `detail` its own sentence. |
| blocked_address | 422 | A kept link points at a private, loopback or link-local address, or redirected to one, so nothing was fetched and nothing was kept — a synchronous `422` when the address itself points inside, the receipt’s `refusal` when a redirect hop does. |
| cadence_too_fine | 400 | A revisit cadence repeats more often than once a day — a sub-daily `FREQ`, or a list of times in one day (`BYHOUR=9,17`); `FREQ=DAILY` is the finest rule, because a revisit brings something back on a day rather than ringing within one. |
| camel_case_field | 400 | A body field was sent in camelCase; the wire is snake_case with no exceptions. |
| canceled | 403 | The subscription was ended, so the space is locked until it starts again. |
| checksum_mismatch | 422 | The uploaded bytes hash to something other than the checksum that was declared. |
| concept_in_trash | 409 | A concept the request names is in the bin; restore it first. |
| concept_not_found | 400 · 404 | A request named a concept id that does not exist in this space. |
| conditional_write_unsupported | 400 | `If-Match` or `If-Unmodified-Since` rode on a write; the API is last-write-wins. |
| connection_not_found | 404 | No connection with that id exists between you and another person. |
| connection_not_pending | 409 | The connection request was declined or withdrawn, so there is nothing left to accept. |
| content_mismatch | 400 | The stored bytes do not begin like the declared `mime` says they should — a PNG sent as `image/jpeg`, an HTML page sent as a PDF. The object was removed; upload the right bytes, or declare the right type. |
| content_order_required | 400 | A create sends notes, links or files but no `content_order` — the order says which block the concept leads with. |
| control_character | 400 | A name or a title carries a control character; tab and newline are the only ones allowed. |
| cross_origin_not_supported | 403 | The request came from a browser origin; this API is not called cross-origin. |
| csrf_error | 500 | CSRF verification could not be completed, so the write was refused rather than allowed. |
| csrf_mismatch | 403 | The CSRF token does not match the session, or it has expired. |
| csrf_token_missing | 403 | A cookie-authenticated write arrived with no CSRF token. |
| delivery_expired | 409 | A webhook delivery retry named a delivery too old to re-send — its row predates `message_id`, or its message has aged out of the queue. |
| delivery_not_found | 404 | No webhook delivery with that id is on this subscription. |
| delivery_retrying | 409 | A webhook delivery retry named a delivery that still has automatic retries to come; only a `dlq` row can be re-queued. |
| document_corrupt | 422 | A kept document did not extract end to end, so nothing was kept — arrives on the keep receipt’s `refusal`, never as an HTTP error. |
| duplicate_block | 400 | `content_order` names the same block more than once, or a create sends the same link twice. |
| duplicate_header | 400 | A webhook subscription named the same custom header twice (names compare case-insensitively). |
| email_not_verified | 403 | The account’s email address has not been confirmed yet. |
| embedding_not_ready | 409 | The concept has no embedding yet, so it has no neighbours to rank; retry once `embedding_status` reads `ready`. |
| empty_submission | 422 | A keep held nothing to read — only whitespace or invisible characters, markdown that converts to no words, or a document with no words in it. |
| endpoint_not_on_board | 409 | A card the line names is not on that view. |
| entitlements_unavailable | 503 | Entitlements could not be read, so nothing was assumed about the plan. Retry. |
| event_not_subscribed | 400 | A webhook test named an event the subscription does not subscribe to; a test sends only events it asked for. |
| exactly_one_required | 400 | Exactly one of a set of fields must be sent, and this request sent none of them or more than one. |
| expired_token | 401 | The token is past its expiry; sign in again, or send a current key. |
| file_already_attached | 409 | That file already backs another concept; one file belongs to one concept. |
| file_not_found | 400 · 404 | A request named a file id that does not exist in this space — a `400` on a write that names it, a `404` when an accepted proposal finds it gone. |
| file_quarantined | 409 | The file’s stored bytes did not match the checksum it was uploaded with, so no route serves them. |
| file_too_large | 422 | A kept file, or the document behind a kept link, is past the byte ceiling `GET /keep/formats` publishes for it. |
| file_type_blocked | 400 | The file’s extension is refused outright for safety. |
| file_type_not_allowed | 400 | The file’s media type is not one this space can store. |
| handle_required | 400 | Connecting to someone needs a handle on your own card first. |
| handle_taken | 409 | The handle is held by someone else, or reserved. |
| happened_at_out_of_range | 400 | A `happened_at` falls before the year 1 (`0001-01-01T00:00:00Z`), read as the instant it names in UTC. |
| https_required | 400 | A webhook URL is not `https://`; deliveries only go to an HTTPS endpoint. |
| human_review_required | 403 | A machine caller tried a merge, emptying the trash or deleting a View or World, directly or by accepting the proposal. |
| idempotency_body_mismatch | 422 | An `Idempotency-Key` was reused with a different request body; a new request needs a new key. |
| idempotency_inflight | 409 | The same `Idempotency-Key` is still being processed; retry once it settles. |
| idempotency_key_required | 400 | The operation must never run twice — a keep, an invitation’s letter — so it needs an `Idempotency-Key` header: one stable key per intended call. |
| idempotency_store_unavailable | 503 | Single execution could not be guaranteed, so the write was refused rather than risked. |
| image_not_found | 404 | The link card or favicon a media read named is not stored — it was never fetched, or its bytes are gone. |
| image_unreadable | 422 | A kept image could not be read, so nothing was kept — arrives on the keep receipt’s `refusal`, never as an HTTP error. |
| incomplete_order | 400 | `content_order` does not name every block the concept holds, or every block a create sends. |
| insufficient_scope | 403 | Accepting a proposal costs what the write costs, and the caller is short of it. |
| intake_expired | 422 | Reading a keep outlived the intake lifetime, so nothing was kept — arrives on the keep receipt’s `refusal`, never as an HTTP error. |
| intake_not_found | 404 | No keep in progress has that id; it settled and its staging row expired, or it never existed. |
| intake_settled | 409 | The keep has already admitted or refused; place the concept directly. |
| invalid_anchor_key | 400 | A thread’s anchor key is not shaped the way its anchor type requires. |
| invalid_color | 400 | A colour is neither `#RRGGBB` nor `oklch(…)`. |
| invalid_concept_id | 400 | A value that must be a `con_` id is not one. |
| invalid_cursor | 400 | The pagination cursor was tampered with, or another list minted it (the detail names that list). |
| invalid_element | 400 | An element inside an array or a set is not what that collection accepts. |
| invalid_format | 400 | A string is the right type but the wrong shape — an email, a URL, a pattern. |
| invalid_json | 400 | The request body is not valid JSON. |
| invalid_key | 400 | A key of an object or a map is not one that object accepts. |
| invalid_limit | 400 | `limit` is not an integer, or sits outside the range the route allows. |
| invalid_request | 400 | The body carried none of the fields this route can act on. |
| invalid_scope | 403 | The request asked for a scope that is not in the catalogue. |
| invalid_secret | 400 | A webhook secret is not a Standard Webhooks secret — `whsec_` plus base64 of a 24–64 byte key, the prefix optional. |
| invalid_timezone | 400 | A `tz` is not an IANA time zone name the zone database knows, such as `Europe/London` or `UTC`; it is never read as UTC. |
| invalid_token | 401 | The token did not verify — revoked, issued somewhere else, or an OAuth access token that is no longer live. |
| invalid_type | 400 | A field is the wrong type, or a required field is missing. |
| invalid_union | 400 | A value matched none of the shapes the field allows. |
| invalid_url_scheme | 400 | A link address is not `http://` or `https://` with a host — `javascript:`, `data:`, `vbscript:` and every other scheme are refused. |
| invalid_value | 400 | A value is outside the set the field allows. |
| invalid_window | 400 | `since` is later than `until`. |
| invitation_cancelled | 409 | The invitation link was withdrawn by the person who sent it. |
| invitation_expired | 409 | The invitation link is more than 30 days old. |
| invitation_not_found | 404 | No invitation onto that view is waiting for the address named. |
| invitation_unknown | 409 | The invitation link was already spent, withdrawn, or never existed. |
| invitation_used | 409 | The invitation link has already been used to join. |
| keep_upload_divergence | 400 | A plan was saved with two different answers to how long a thing may be. |
| label_name_taken | 409 | Another label in this space already has that name, in any case; `label_id` names it. |
| label_not_found | 400 · 404 | A request named a label id that does not exist in this space — a `400` on a write that names it, a `404` when an accepted proposal finds it gone. |
| last_private_view | 409 | Your last private View cannot be deleted; there would be nowhere to land. |
| last_world | 409 | Your last World cannot be deleted; there would be nowhere to land. |
| link_not_found | 404 | An accepted proposal named a link the concept no longer carries. |
| link_unreadable | 422 | Nothing readable came back from a kept link, so nothing was kept — arrives on the keep receipt’s `refusal`, never as an HTTP error. |
| malformed_credential | 401 | The bearer value is neither a Madless key (`mad_sk_…`) nor a token. |
| merge_cap_exceeded | 409 | The concepts being merged hold more of one kind of block than one concept can; `what`, `total` and `cap` say which. |
| merge_source_shared | 409 | A concept being merged away is on a view with people; take it off that view first, or make it the one you keep. |
| merge_survivor_in_sources | 400 | A merge names its survivor among the concepts folded into it. |
| merge_too_few | 422 | A merge needs at least two different concepts. |
| message_not_found | 404 | No thread message with that id is in the thread named. |
| method_not_allowed | 405 | The path exists and the method does not; `Allow` lists what does. |
| missing_entitlement | 403 | The plan does not carry a feature this operation needs. |
| missing_scope | 403 | The credential does not hold a scope this operation needs. |
| mutually_exclusive | 400 | Two parameters that cannot be sent together were both sent. |
| name_required | 400 | An invitation arrives with your name on it, and you have not set one. |
| no_credential | 401 | The request carried no credential — no `Authorization` header and no session cookie. A key in the URL or the body is never read. |
| no_pipeline | 422 | Madless has no reader for that kind of material right now, so the keep was refused before anything was staged. |
| no_space | 400 · 409 | The account has no space yet, so there is nothing to connect or seat. |
| no_speech_found | 422 | A kept recording held no words to transcribe, so nothing was kept — arrives on the keep receipt’s `refusal`, never as an HTTP error. |
| no_text_layer | 422 | A kept document is a scan — pictures of pages with no text underneath — so nothing was kept — arrives on the keep receipt’s `refusal`, never as an HTTP error. |
| no_update_fields | 400 | A patch carried no field this route can update. |
| node_locked | 409 | The concept a proposal targets is locked against writes. |
| not_a_permutation | 400 | A reorder must name every current id exactly once; this one did not. |
| not_allowed_to_invite | 403 | Only the Owner, or someone the Owner let invite, may invite people onto a view or withdraw an invitation. |
| not_connected | 403 | You are not connected to that person — talking with them in a pair thread needs a connection first, and inviting them needs a connection or their being reachable by handle. |
| not_invited | 403 | The caller has no pending invitation to this view. |
| not_member | 403 | The caller is not a member of the space the request addressed. |
| not_multiple_of | 400 | A number is not a multiple of the step the field requires. |
| not_on_view | 404 | An accepted proposal restyles a card that is not placed on that View. |
| not_owner | 403 | Only the view's Owner may do that — rename it, change what someone may do on it, take a person off it, or remove everyone. |
| not_yours | 403 | The thing named belongs to someone else; you can only act on your own. |
| note_cap_reached | 409 | The concept already holds 10 notes, the most one concept can — adding one, or restoring a deleted one, has no room. |
| note_not_found | 404 | An accepted proposal named a note the concept no longer holds. |
| notification_not_found | 404 | No notification with that id is yours. |
| object_already_registered | 409 | The uploaded object is already a file, registered with a different checksum. |
| owner_stays | 409 | The Owner can be neither taken off their view nor leave it; they remove everyone instead. |
| owner_switches_fixed | 409 | The Owner always invites and always builds; their own switches cannot change. |
| page_not_found | 404 | The link has no saved page to read — none was kept for it, or the stored copy is gone. |
| payment_ended | 403 | The subscription ended because payment could not be taken. |
| person_not_found | 404 | The person named is not on that view. |
| place_failed | 500 | The invitation link made your Madless but could not put you on the view. The link is spent; `activate_url` signs you in to your own board, and the person who invited you can invite you again. |
| profile_not_found | 404 | No profile answers to that handle for you. |
| proposal_not_found | 404 | No proposal with that id in this space. |
| proposal_not_pending | 409 | The proposal has already been accepted or rejected. |
| proposal_not_rejected | 409 | Only a rejected proposal can be deleted; an accepted one is the audit record of a write that happened, and a pending one is still a question. |
| rbac_unavailable | 503 | Membership could not be verified, so nothing was assumed about it. Retry. |
| reaction_cap_reached | 409 | That reaction on that message already holds as many people as one reaction can. |
| read_only | 403 | The Owner set you to Read only on that view: you can see it, talk on it and keep from it, and you cannot change what is on it. |
| register_key_unknown | 404 | The upload register key was never issued, or has expired. |
| relation_not_found | 404 | An accepted proposal named a connection that no longer exists. |
| repeated_parameter | 400 | A query parameter was sent more than once; a list is one value, comma-separated. |
| reply_target_not_found | 400 | `reply_to.message_id` does not name a message on this anchor. |
| required | 400 · 422 | A required field or query parameter is missing — a `422` when an accepted proposal turns out to lack one. |
| reserved_header | 400 | A webhook custom header named one of the headers Madless sets on every delivery (`webhook-id`, `webhook-signature`, `content-type`, …). |
| route_not_found | 404 | No API route matches the path, under any method; check the path against the reference. |
| rrule_no_future_occurrence | 400 | A revisit cadence never fires again — its rule has no occurrence after now in the zone `tz` names. |
| sandbox_not_found | 404 | This space has no sandbox to reset. |
| self_connection | 400 | The handle given is your own, or a connection names the same concept at both ends. |
| self_invite | 400 | The address given is your own. |
| self_keep | 400 | The card on that view is your own; there is nothing to keep. |
| self_move | 422 | A block move named the concept the block is already in as its target. |
| session_revoked | 403 | The session was signed out elsewhere; sign in again. |
| shared_view_cap_reached | 403 | You already have as many views with people on them as your plan allows; inviting someone onto a private view would give it people. |
| sign_in_required | 409 | The invitation link was sent to an address that already has a Madless. Sign in; the invitation waits under People. Nothing was spent. |
| size_mismatch | 400 | The stored object is not the `size_bytes` sent with its register; `sent` and `actual` say both. The object was removed; upload it again. |
| sketch_not_found | 404 | No sketch with that id is on the concept named. |
| space_asleep | 403 | The space is asleep; ask to wake it before calling anything else. |
| space_full | 422 | What would be stored passes what is left of the space’s storage, so nothing was kept. On `POST /keep` (the document behind a kept link) it arrives on the keep receipt’s `refusal`, never as an HTTP error; keeping a card off a view with people answers it as a `422`, with `needed` and `available` in bytes. |
| space_mismatch | 403 | The API key belongs to a different space than the one addressed. |
| ssrf_blocked | 400 | The URL resolves somewhere the server will not be pointed at. |
| subscription_id_required | 400 | The webhook subscription id is missing from the path or body. |
| subscription_limit_reached | 400 | The space already holds as many active webhook subscriptions as it may. |
| subscription_missing_secret | 400 | The webhook subscription has no signing secret to sign a test delivery with. |
| subscription_missing_url | 400 | The webhook subscription has no URL and none was given to override it. |
| subscription_not_found | 404 | No webhook subscription with that id exists in this space. |
| suspended | 403 | The space has been suspended; support can say why. |
| target_no_space | 400 | The person addressed has no space yet, so there is nothing to connect to. |
| terms_not_accepted | 400 | Joining was asked for without accepting the Terms of Service and Privacy Policy. |
| test_delivery | 409 | A webhook delivery retry named a send from `POST /webhooks/test`, which has no message behind it to re-send. |
| text_not_found | 404 | The file has no extracted text to read — it holds none, or its reading has not run. |
| thread_not_found | 404 | Nothing has been said on that anchor yet, so there is no thread to summarise. |
| timestamp_out_of_range | 400 | A timestamp falls outside the window its field accepts — `source.kept_at` from 2000-01-01 to an hour ahead, a revisit or an expiry in the past, a moment before 1970; `detail` names the window. |
| too_big | 400 | A value is above the maximum — too large, too long, or too many. |
| too_long | 422 | A keep was longer than one keep can hold — a recording past the plan’s length cap, or words whose note, as stored, passes `max_note_chars`. |
| too_many_files | 400 | A keep or a concept named more files than one concept may hold. |
| too_many_ids | 400 | More ids were sent in one request than the route accepts. |
| too_small | 400 | A value is below the minimum — too small, too short, or too few. |
| understanding_failed | 422 | Madless could not finish reading a keep, so nothing was kept; the same material, kept again, may be read. |
| unknown_anchor_type | 400 | A thread was addressed with an anchor type that does not exist. |
| unknown_block | 400 | `content_order` names a block the concept does not hold, or one a create does not send. |
| unknown_key | 401 | A well-formed `mad_sk_` key this environment has never seen — revoked, mistyped, or minted on another environment; `environment` names this one. |
| unknown_label | 400 | A label id was sent that names no label in this space. |
| unknown_parameter | 400 | A query parameter this route does not read was sent. |
| unknown_subtype | 400 | An act subtype filter named something that is not an act subtype. |
| unrecognized_keys | 400 | The body carried a field this operation does not accept; `detail` names it. |
| unsigned_object_path | 403 | An `object_path` is not a key `POST /drive/uploads/sign` minted for this space. |
| unsupported_filter_combination | 400 | Two filters that cannot narrow the same request were both sent. |
| unsupported_format | 422 | A keep was in a format Madless does not read — refused at the door, or on the receipt for a link whose address is a picture or a recording. |
| unsupported_image | 422 | A note carried an image — in markdown or in HTML; a note cannot hold one, so it is refused rather than dropped. |
| unsupported_markdown_table | 422 | A note carried a table — in markdown or in HTML; tables are refused rather than flattened. |
| unsupported_scheme | 401 | The `Authorization` header is not `Bearer <credential>` — another scheme such as `Basic`, or a key sent without `Bearer `. |
| upload_not_found | 404 | Nothing is stored at the upload’s object path — the signed PUT has not landed. |
| url_credentials | 400 | A webhook URL carried credentials (`user:pass@`); receivers verify the signature instead. |
| user_not_found | 404 | No account answers to the person named, or they cannot be reached from yours. |
| variant_not_found | 404 | The file exists, but the derived version asked for — a size tier, a video thumbnail, a PDF page preview — is not made yet or cannot be made; fall back to the original. |
| version_not_found | 404 | No saved version with that id exists for the note named. |
| view_full | 409 | Every place on that view is taken — the people on it plus everyone still invited — by the number the Owner’s plan seats. |
| view_has_people | 409 | People are on that view. Placing, moving, styling, removing, renaming or deleting there is done in the app, through the view itself — never through a proposal or a connector. |
| view_nodes_limit_reached | 409 | That view already holds as many cards as a view can; take one off before putting another down. |
| view_not_found | 404 | No view with that id is yours or one you are on — the same answer whether or not it exists. |
| window_too_wide | 400 | The window asked for covers more days than the route will answer for. |
| world_not_found | 404 | The `world_id` given names no World of yours. |
Common Status Codes
| Code | Meaning | When |
|---|---|---|
| 200 | OK | Request succeeded |
| 201 | Created | Resource successfully created |
| 202 | Accepted | A keep was staged and is being read — poll the intake receipt it returns for the outcome |
| 204 | No Content | Successful delete, no body returned |
| 400 | Bad Request | The body or query string failed validation (validation_error) — including a query parameter the route does not read (unknown_parameter) or one sent twice (repeated_parameter) |
| 401 | Unauthorized | No credentials, a non-Bearer Authorization header, or a key or token this environment does not know |
| 402 | Payment Required | The space's AI credits, budget, minutes or image allowance are spent, or it holds as many concepts as its plan allows — branch on the type |
| 403 | Forbidden | A missing scope or entitlement, or a record that is not yours (branch on the code field) |
| 404 | Not Found | Resource does not exist. On a DELETE, read the operation: each one either declares 404 and says when, or omits it and is idempotent |
| 405 | Method Not Allowed | The path exists but not with this method — the Allow header lists the ones it takes |
| 409 | Conflict | Duplicate or conflicting state (branch on the code field) |
| 410 | Gone | Resource once existed but was deleted (e.g. a trashed concept or file) |
| 413 | Payload Too Large | Request body exceeds the size limit (too_large, carries max_bytes) |
| 421 | Misdirected Request | A key sent to another space's subdomain — the body's base_url names where it belongs |
| 422 | Unprocessable Entity | Well-formed body that cannot be acted on or read. Malformed JSON is a 400, not a 422 |
| 426 | Upgrade Required | A native Madless app older than the floor its platform allows — never sent to an API key |
| 429 | Rate Limited | One endpoint's minute bucket (rate_limited) or the space's day of machine traffic (daily_cap_reached) |
| 500 | Server Error | Unexpected internal failure |
| 503 | Service Unavailable | A dependency is briefly unavailable — safe to retry with backoff |
Rate Limiting
When you exceed the rate limit for an endpoint, the API returns 429 Too Many Requests. Rate-limited endpoints carry headers to help you manage your request budget — on every response for the three budget headers, and only on the 429 itself for Retry-After. An endpoint that declares no limit sends none of them, so read them defensively:
| Header | Description |
|---|---|
| Retry-After | Seconds to wait before retrying. Sent on the 429 only, not on successful calls. Short on rate_limited; can be hours on daily_cap_reached. |
| X-RateLimit-Limit | Maximum number of requests THIS endpoint allows in its window — a minute for most, an hour for a few (Ask). |
| X-RateLimit-Remaining | Requests remaining in the current window. |
| X-RateLimit-Reset | When the current window resets, as a Unix timestamp in seconds — a moment, not a duration. |
The Daily Cap
There is a second 429, and it is not the first one wearing a bigger number. Switch on the problem’s type, never on the status.
rate_limited— this endpoint’s own bucket (a minute wide for most, an hour for Ask). Wait the seconds inRetry-Afterand go again. The threeX-RateLimit-*headers describe that bucket; every endpoint publishes its own number on its reference page.daily_cap_reached— the whole space’s machine traffic for a day: every request made through an API key or a connected AI, counted per space and never per key.Retry-Afterhere can be hours, and theX-RateLimit-*headers are absent — they publish the per-endpoint numbers, which would be the wrong bucket.
HTTP/1.1 429 Too Many Requests Retry-After: 14400 Content-Type: application/problem+json { "type": "https://developers.madless.com/errors#daily_cap_reached", "title": "daily cap reached", "status": 429, "detail": "This space has made all the API requests a day allows. The day slides over UTC days — the previous day’s count fades to nothing by the next 00:00 UTC — so headroom returns gradually, with no midnight reset.", "limit": 100000, "used": 100000, "retry_after": 14400 }
The window slides. It is a sliding day measured in UTC days, not a counter that empties at midnight: yesterday’s traffic still weighs against you at 00:00 UTC and decays to nothing by the next midnight. So headroom comes back gradually — a loop that burned the whole cap yesterday gets almost nothing at 00:01 and about half of it back by noon. There is no cliff to schedule against.
It is deliberately far above anything an ordinary integration does. A key running flat out against one endpoint’s own ceiling, all day without pausing, does not reach it. It exists so a runaway loop — a one-second poll somebody forgot about — has a ceiling, and nothing a person does inside the app counts toward it at all. If you have a genuine one-day job that needs more (a large migration through the SDK, say), ask us and we will raise it for your space rather than leaving you to fight it.
Read where you stand any time from GET /api/v1/usage — the api_requests row carries used, limit, remaining and its own resets_at. Reading it does not spend one.
Over MCP the same refusal arrives as the JSON-RPC -32004 envelope with data.op set to api.machine.daily — the transport’s equivalent of the type switch above, with the same Retry-After.
Idempotency
Write endpoints support idempotent requests. Include an Idempotency-Key header (max 255 ASCII printable characters — a UUID works well). If a request with the same key already succeeded, the server replays that stored response — flagged with an X-Idempotency-Replay: true header — instead of running the operation again. Only successful responses are cached: a request that errored (4xx or 5xx) is not replayed, so retrying with the same key runs it afresh.
curl -X POST 'https://acme.madless.com/api/v1/concepts' \ -H 'Authorization: Bearer mad_sk_abc123...' \ -H 'Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000' \ -H 'Content-Type: application/json' \ -d '{ "title": "Distributed consensus", "notes": [{ "content": "<p>Raft trades speed for a story you can follow.</p>" }], "content_order": ["note:0"] }'
Keys expire after 24 hours. The entry is keyed by your space, your user, the method, and the path, and it remembers the body it was first used with. The same body replays the first response; a different body is refused with 422 idempotency_body_mismatch and nothing is written — mint a fresh key for every distinct write. With the same body, you only get a 409 Conflict when a second request arrives with the same key while the first is still in flight — retry it once the original completes.
DELETE is not uniformly idempotent — each operation says which it is. Some treat “already gone” as the end state you asked for, and answer 204 forever and declare no 404 at all. The rest 404 on a second call and say so. Read the operation rather than assuming; where one does 404, an Idempotency-Key makes a dropped response safe to retry.
Retry with backoff
For 429 and 5xx errors, use exponential backoff with jitter. Always respect the Retry-After header when present. Do not retry 4xx errors other than 429 — they indicate a problem with the request itself that must be fixed before retrying.