Developersv1.0.0

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.

json
{
  "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 403 for someurl values on any route that takes one — GET /api/v1/links/preview, a link intent on POST /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 413 for 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.

TypeStatus
validation_error400
unauthorized401
ai_credits_exhausted402
ai_usd_budget_exhausted402
ai_platform_budget_exhausted402
ai_platform_monthly_budget_exhausted402
ai_daily_usd_cap_exhausted402
media_minutes_exhausted402
images_exhausted402
concept_cap_reached402
forbidden403
not_found404
gone410
method_not_allowed405
conflict409
too_large413
misdirected_request421
unprocessable_entity422
invalid_label_name400
client_upgrade_required426
rate_limited429
daily_cap_reached429
server_error500
service_unavailable503
ai_budget_check_failed503

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.

CodeStatus
already_delivered409
already_exists409
already_kept409
already_requeued409
answer_not_found404
block_cap_reached409
block_not_found404
block_refused422
blocked_address422
cadence_too_fine400
camel_case_field400
canceled403
checksum_mismatch422
concept_in_trash409
concept_not_found400 · 404
conditional_write_unsupported400
connection_not_found404
connection_not_pending409
content_mismatch400
content_order_required400
control_character400
cross_origin_not_supported403
csrf_error500
csrf_mismatch403
csrf_token_missing403
delivery_expired409
delivery_not_found404
delivery_retrying409
document_corrupt422
duplicate_block400
duplicate_header400
email_not_verified403
embedding_not_ready409
empty_submission422
endpoint_not_on_board409
entitlements_unavailable503
event_not_subscribed400
exactly_one_required400
expired_token401
file_already_attached409
file_not_found400 · 404
file_quarantined409
file_too_large422
file_type_blocked400
file_type_not_allowed400
handle_required400
handle_taken409
happened_at_out_of_range400
https_required400
human_review_required403
idempotency_body_mismatch422
idempotency_inflight409
idempotency_key_required400
idempotency_store_unavailable503
image_not_found404
image_unreadable422
incomplete_order400
insufficient_scope403
intake_expired422
intake_not_found404
intake_settled409
invalid_anchor_key400
invalid_color400
invalid_concept_id400
invalid_cursor400
invalid_element400
invalid_format400
invalid_json400
invalid_key400
invalid_limit400
invalid_request400
invalid_scope403
invalid_secret400
invalid_timezone400
invalid_token401
invalid_type400
invalid_union400
invalid_url_scheme400
invalid_value400
invalid_window400
invitation_cancelled409
invitation_expired409
invitation_not_found404
invitation_unknown409
invitation_used409
keep_upload_divergence400
label_name_taken409
label_not_found400 · 404
last_private_view409
last_world409
link_not_found404
link_unreadable422
malformed_credential401
merge_cap_exceeded409
merge_source_shared409
merge_survivor_in_sources400
merge_too_few422
message_not_found404
method_not_allowed405
missing_entitlement403
missing_scope403
mutually_exclusive400
name_required400
no_credential401
no_pipeline422
no_space400 · 409
no_speech_found422
no_text_layer422
no_update_fields400
node_locked409
not_a_permutation400
not_allowed_to_invite403
not_connected403
not_invited403
not_member403
not_multiple_of400
not_on_view404
not_owner403
not_yours403
note_cap_reached409
note_not_found404
notification_not_found404
object_already_registered409
owner_stays409
owner_switches_fixed409
page_not_found404
payment_ended403
person_not_found404
place_failed500
profile_not_found404
proposal_not_found404
proposal_not_pending409
proposal_not_rejected409
rbac_unavailable503
reaction_cap_reached409
read_only403
register_key_unknown404
relation_not_found404
repeated_parameter400
reply_target_not_found400
required400 · 422
reserved_header400
route_not_found404
rrule_no_future_occurrence400
sandbox_not_found404
self_connection400
self_invite400
self_keep400
self_move422
session_revoked403
shared_view_cap_reached403
sign_in_required409
size_mismatch400
sketch_not_found404
space_asleep403
space_full422
space_mismatch403
ssrf_blocked400
subscription_id_required400
subscription_limit_reached400
subscription_missing_secret400
subscription_missing_url400
subscription_not_found404
suspended403
target_no_space400
terms_not_accepted400
test_delivery409
text_not_found404
thread_not_found404
timestamp_out_of_range400
too_big400
too_long422
too_many_files400
too_many_ids400
too_small400
understanding_failed422
unknown_anchor_type400
unknown_block400
unknown_key401
unknown_label400
unknown_parameter400
unknown_subtype400
unrecognized_keys400
unsigned_object_path403
unsupported_filter_combination400
unsupported_format422
unsupported_image422
unsupported_markdown_table422
unsupported_scheme401
upload_not_found404
url_credentials400
user_not_found404
variant_not_found404
version_not_found404
view_full409
view_has_people409
view_nodes_limit_reached409
view_not_found404
window_too_wide400
world_not_found404

Common Status Codes

CodeMeaning
200OK
201Created
202Accepted
204No Content
400Bad Request
401Unauthorized
402Payment Required
403Forbidden
404Not Found
405Method Not Allowed
409Conflict
410Gone
413Payload Too Large
421Misdirected Request
422Unprocessable Entity
426Upgrade Required
429Rate Limited
500Server Error
503Service Unavailable

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:

HeaderDescription
Retry-AfterSeconds 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-LimitMaximum number of requests THIS endpoint allows in its window — a minute for most, an hour for a few (Ask).
X-RateLimit-RemainingRequests remaining in the current window.
X-RateLimit-ResetWhen 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 in Retry-After and go again. The three X-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-After here can be hours, and the X-RateLimit-* headers are absent — they publish the per-endpoint numbers, which would be the wrong bucket.
bash
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.

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