Appearance
Errors
Every Krafter API uses conventional HTTP status codes and a flat JSON body whose top-level error field is always a human-readable string. Some shapes add sibling fields (details, retry_after, code) depending on which layer rejected the request. Branch on the HTTP status first, then on the body's top-level fields.
json
// The baseline shape — every error response has at least this
{ "error": "Not found" }Audit API exception
The Audit service wraps responses in a richer { data, meta, error } envelope, so its errors look like { "error": { "code": ..., "message": ... } } instead of the flat shape below. Its :strict_rate_limit 429 is the one exception that falls back to the platform shape. See Authentication and the Audit service docs for details.
Status codes
| Status | Meaning | Body |
|---|---|---|
400 Bad Request | Malformed JSON, a body that is not a JSON object, or a missing/invalid required top-level field | { "error": "..." } |
401 Unauthorized | API key missing, malformed, expired, or revoked | { "error": "Invalid or missing API key" } |
403 Forbidden | Valid key without the route's required scope, or a cross-team resource access | { "error": "Insufficient scope" } |
404 Not Found | Resource does not exist, or belongs to another team (never confirmed) | { "error": "Not found" } |
422 Unprocessable Entity | Changeset validation failed, or a service policy rejected the request | { "error": "Validation failed", "details": { ... } } |
429 Too Many Requests | Rate-limit bucket exhausted, or a plan usage limit reached | { "error": "Rate limit exceeded", "retry_after": 23 } |
500 Internal Server Error | Unexpected server error | { "error": "..." } |
Error shapes
Authentication — flat string
Returned by the API-key plug. The body has exactly one key, error — no code, no details.
json
// 401 — missing, malformed, expired, or revoked key
{ "error": "Invalid or missing API key" }json
// 403 — valid key, but it does not hold the route's required scope
{ "error": "Insufficient scope" }See Authentication for the key format, scopes, and the per-request lifecycle.
Body not a JSON object — flat string
A POST, PUT or PATCH body sent as application/json must be a JSON object. A string, array, number, boolean or null is refused before any field is read, and the message names what arrived:
json
// 400 — the body was a JSON string, e.g. an object encoded twice
{ "error": "request body must be a JSON object, got a JSON string" }The usual cause is a client that serialises the body itself and then hands the resulting string to an HTTP library that serialises it again: hand the library the object, or send the string once as the raw body. The same rule holds on the public embed endpoints — POST /f/:slug (form submissions) and POST /c/:slug/react / POST /c/:slug/subscribe (announcements widget). POST /api/v1/logs/ingest/batch is the exception: it takes NDJSON, one object per line.
Not found — flat string
json
// 404
{ "error": "Not found" }A resource that belongs to another team returns 404, not 403 — the API never confirms the existence of resources outside your team.
Validation — string + top-level details
Returned when an Ecto changeset fails. details is a sibling of error at the top level (not nested inside it), mapping each field to a list of messages.
json
// 422
{
"error": "Validation failed",
"details": {
"name": ["can't be blank"],
"url": ["must be a valid http or https URL"]
}
}For batch endpoints, a failing item reports its position:
json
// 422
{
"error": "Validation failed at index 2",
"details": { "to": ["can't be blank"] }
}Text length
Unless an endpoint documents a longer one, a text field holds at most 255 characters, counted in Unicode code points — so an emoji built from several code points (a family, a flag, a skin tone) counts as all of them. Some fields hold less (a domain name 253, an email address 254 or fewer). A longer value is a 422 naming the field and its limit, e.g. "should be at most 255 character(s)"; for a list (tags, events, origins) the limit applies to each entry. Request metadata Krafter records on its own — a visitor's User-Agent, Referer or UTM parameters on a public form or waitlist — is shortened to fit instead of rejecting the request.
Atom-derived errors (e.g. :not_found, :limit_exceeded) carry only error — the details map appears only for changeset-derived validation failures.
Rate limit — string + retry_after
Returned when a rate-limit bucket is exhausted. The same value is also set as the retry-after HTTP header.
http
HTTP/1.1 429 Too Many Requests
content-type: application/json
retry-after: 23
x-ratelimit-limit: 100
x-ratelimit-remaining: 0
x-ratelimit-reset: 1714128360
{ "error": "Rate limit exceeded", "retry_after": 23 }Plan-level usage limits surface as a 429 with a different message and no retry_after:
json
// 429
{ "error": "Monthly usage limit exceeded. Upgrade your plan." }See Rate limits for the buckets, headers, and window semantics.
Policy — string + machine code
Some services reject a request because of a service-specific rule (stream, sandbox, override, …). These add a stable, machine-readable code alongside the human-readable error. Branch on code, not on error — the message wording may change.
json
{
"error": "API key is not allowed to send on stream 'broadcast'",
"code": "stream_not_allowed"
}The code enum is documented per service. See Mail → Error handling for the canonical example.
Service-specific errors
Each service documents the status codes and any extra fields unique to it:
Best practices
- Check the HTTP status before parsing the body — different statuses emit different shapes.
- Branch on
codefor policy errors, not on the human-readableerror. - Show field-level messages by walking the top-level
detailsobject on422responses. - Retry
429and5xxwith exponential backoff, honouring theretry-afterheader on429. - Do not retry
400,401,403, or422— fix the request first.