BETA
Skip to content

Error Handling

Krafter Mail uses conventional HTTP status codes. Unlike a single nested envelope, the API returns errors in four real shapes, each emitted by a different layer. Branch on HTTP status first, then on the response body's top-level fields.

The Four Error Shapes

1. Auth errors — flat string

Returned by the API-key plug for 401 Unauthorized and 403 Forbidden (insufficient scope):

json
{
  "error": "Invalid or missing API key"
}
json
{
  "error": "Insufficient scope"
}

No code, no details, no nesting. The body has exactly one key: error.

2. Policy errors — string + machine code

Returned by the mail controller when a request violates a stream, sandbox, or override rule. Use code (a stable machine-readable string) for branching; error is human-readable and may change wording:

json
{
  "error": "stream is required for this API key",
  "code": "stream_required"
}

The full code enum is documented in Policy error codes below.

3. Validation errors — string + top-level details

Returned by the action fallback when an Ecto changeset fails. details is a sibling of error at the top level — not nested inside error:

json
{
  "error": "Validation failed",
  "details": {
    "from": ["can't be blank"],
    "to": ["can't be blank"],
    "subject": ["can't be blank"]
  }
}

For batch sends, when an item fails the upfront from/to/subject check the response is a plain validation error with the 0-indexed position of the bad item:

json
// 422
{
  "error": "Email at index 2 is missing required fields (from, to, subject)"
}

When an item fails policy resolution (stream, sandbox, recipient rules) the response is a policy error with the 1-indexed position prefixed and the code: field set:

json
// 403
{
  "error": "Email #3: API key is not allowed to send on stream 'broadcast'",
  "code": "stream_not_allowed"
}

If only some items in the batch fail at insert time, the wrapper status is 207 Multi-Status and each failing item appears in the data array as {"status": 422, "errors": {...}}.

4. Rate-limit errors — string + retry_after

Returned by the rate-limit plug when the per-team window is exhausted. The same value is also set as the Retry-After HTTP response header:

http
HTTP/1.1 429 Too Many Requests
Retry-After: 42
json
{
  "error": "Rate limit exceeded",
  "retry_after": 42
}

See Rate Limiting for the full headers list.

INFO

One exception to the four shapes: POST /api/v1/webhooks/:id/test returns a nested {"error": {"message": ..., "detail": ...}} body on 502 Bad Gateway when the test delivery fails to reach your endpoint. This shape is unique to that one endpoint — every other Mail endpoint follows one of the four shapes above.

HTTP Status Codes

CodeMeaning
200OK
201Created
204No Content
400Bad Request — malformed JSON or missing required top-level field
401Unauthorized — missing or invalid API key
403Forbidden — insufficient scope or stream/sandbox policy denial
404Not Found
422Unprocessable Entity — validation, policy, or forbidden header
429Too Many Requests — rate limit hit or sandbox cap reached
500Internal Server Error

Policy error codes

When POST /emails rejects a request because of a stream, sandbox, or header rule, the response includes both error (a human message) and code (a stable string). Build your client logic on code:

code valueHTTPMeaning
stream_required422API key allows >1 stream and request omitted stream
invalid_stream422stream value is not transactional or broadcast
stream_not_allowed403stream is not in the key's allowed_streams
sandbox_disabled403Sandbox key has been suspended (complaint or bounce threshold)
sandbox_domain_forbidden403Live (non-sandbox) key tried to send from @test.krafter.dev
sandbox_extra_recipients422Sandbox send included cc, bcc, or reply_to
sandbox_stream_forbidden403Sandbox key tried to use the broadcast stream
recipient_not_allowed422Sandbox send to an address outside the key's allow-list
hourly_cap_exceeded429Sandbox hourly cap reached
daily_cap_exceeded429Sandbox daily cap reached
override_forbidden403Body included a per-message key that is not user-overridable
forbidden_header422headers body field used a reserved name or non-string value

A policy error response looks like this:

json
{
  "error": "API key is not allowed to send on stream 'broadcast'",
  "code": "stream_not_allowed"
}

Examples by status code

400 Bad Request

Malformed batch payload:

bash
curl -X POST https://app.krafter.dev/api/v1/emails/batch \
  -H "Authorization: Bearer kr_live_abc123def456" \
  -H "Content-Type: application/json" \
  -d '{}'
json
{
  "error": "Missing or invalid 'emails' array"
}

401 Unauthorized

Missing or invalid API key:

bash
curl https://app.krafter.dev/api/v1/emails \
  -H "Authorization: Bearer not_a_real_key"
json
{
  "error": "Invalid or missing API key"
}

403 Forbidden — insufficient scope

json
{
  "error": "Insufficient scope"
}

403 Forbidden — policy denial

json
{
  "error": "API key is not allowed to send on stream 'broadcast'",
  "code": "stream_not_allowed"
}

422 Unprocessable Entity — validation

json
{
  "error": "Validation failed",
  "details": {
    "from": ["can't be blank"],
    "to": ["can't be blank"]
  }
}

422 Unprocessable Entity — forbidden header

json
{
  "error": "header 'Content-Type' is reserved and cannot be set",
  "code": "forbidden_header"
}

429 Too Many Requests — rate limit

http
HTTP/1.1 429 Too Many Requests
Retry-After: 42
json
{
  "error": "Rate limit exceeded",
  "retry_after": 42
}

429 Too Many Requests — sandbox cap

json
{
  "error": "Sandbox hourly limit of 10 would be exceeded",
  "code": "hourly_cap_exceeded"
}

Handling Errors in Code

TypeScript

typescript
type AuthError = { error: string };
type PolicyError = { error: string; code: string };
type ValidationError = { error: string; details: Record<string, string[]> };
type RateLimitError = { error: "Rate limit exceeded"; retry_after: number };
type KrafterError = AuthError | PolicyError | ValidationError | RateLimitError;

async function sendEmail(payload: object): Promise<void> {
  const response = await fetch("https://app.krafter.dev/api/v1/emails", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.KRAFTER_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify(payload),
  });

  if (response.ok) return;

  const body = (await response.json()) as KrafterError;

  if (response.status === 401 || response.status === 403) {
    throw new Error(body.error);
  }
  if (response.status === 422 && "details" in body) {
    const fields = Object.entries(body.details)
      .map(([k, v]) => `${k}: ${v.join(", ")}`)
      .join("; ");
    throw new Error(`Validation failed: ${fields}`);
  }
  if (response.status === 429) {
    const retryAfter =
      Number(response.headers.get("Retry-After")) ||
      ("retry_after" in body ? body.retry_after : 1);
    // sleep and retry...
    return;
  }
  if ("code" in body) {
    throw new Error(`Policy error (${body.code}): ${body.error}`);
  }
  throw new Error(body.error);
}

Elixir

elixir
case Req.post("https://app.krafter.dev/api/v1/emails",
       headers: [{"authorization", "Bearer " <> api_key}],
       json: payload
     ) do
  {:ok, %{status: status, body: body}} when status in 200..299 ->
    {:ok, body}

  {:ok, %{status: 401, body: %{"error" => msg}}} ->
    {:error, {:unauthorized, msg}}

  {:ok, %{status: 422, body: %{"details" => details}}} ->
    {:error, {:validation, details}}

  {:ok, %{status: 422, body: %{"code" => code, "error" => msg}}} ->
    {:error, {:policy, code, msg}}

  {:ok, %{status: 429, headers: headers, body: %{"retry_after" => retry_after}}} ->
    header_retry =
      headers
      |> Enum.find_value(fn {k, v} -> String.downcase(k) == "retry-after" && v end)

    {:error, {:rate_limited, header_retry || retry_after}}

  {:ok, %{status: status, body: body}} ->
    {:error, {status, body}}

  {:error, reason} ->
    {:error, reason}
end

Best Practices

  • Always check the HTTP status code before parsing the response body — different statuses emit different shapes.
  • Branch on code, not error, for policy errors. The error text is human-readable and may change.
  • Show field-level validation errors to end users by walking the top-level details object on 422 changeset responses.
  • Implement retry logic for 429 and 5xx responses with exponential backoff; honour Retry-After on 429.
  • Do not retry 400, 401, 403, or 422 errors — fix the request first.

Built by Krafter Studio