BETA
Skip to content

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 ​

StatusMeaningBody
400 Bad RequestMalformed JSON, a body that is not a JSON object, or a missing/invalid required top-level field{ "error": "..." }
401 UnauthorizedAPI key missing, malformed, expired, or revoked{ "error": "Invalid or missing API key" }
403 ForbiddenValid key without the route's required scope, or a cross-team resource access{ "error": "Insufficient scope" }
404 Not FoundResource does not exist, or belongs to another team (never confirmed){ "error": "Not found" }
422 Unprocessable EntityChangeset validation failed, or a service policy rejected the request{ "error": "Validation failed", "details": { ... } }
429 Too Many RequestsRate-limit bucket exhausted, or a plan usage limit reached{ "error": "Rate limit exceeded", "retry_after": 23 }
500 Internal Server ErrorUnexpected 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 code for policy errors, not on the human-readable error.
  • Show field-level messages by walking the top-level details object on 422 responses.
  • Retry 429 and 5xx with exponential backoff, honouring the retry-after header on 429.
  • Do not retry 400, 401, 403, or 422 — fix the request first.

Built by Krafter Studio