Appearance
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: 42json
{
"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
| Code | Meaning |
|---|---|
200 | OK |
201 | Created |
204 | No Content |
400 | Bad Request — malformed JSON or missing required top-level field |
401 | Unauthorized — missing or invalid API key |
403 | Forbidden — insufficient scope or stream/sandbox policy denial |
404 | Not Found |
422 | Unprocessable Entity — validation, policy, or forbidden header |
429 | Too Many Requests — rate limit hit or sandbox cap reached |
500 | Internal 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 value | HTTP | Meaning |
|---|---|---|
stream_required | 422 | API key allows >1 stream and request omitted stream |
invalid_stream | 422 | stream value is not transactional or broadcast |
stream_not_allowed | 403 | stream is not in the key's allowed_streams |
sandbox_disabled | 403 | Sandbox key has been suspended (complaint or bounce threshold) |
sandbox_domain_forbidden | 403 | Live (non-sandbox) key tried to send from @test.krafter.dev |
sandbox_extra_recipients | 422 | Sandbox send included cc, bcc, or reply_to |
sandbox_stream_forbidden | 403 | Sandbox key tried to use the broadcast stream |
recipient_not_allowed | 422 | Sandbox send to an address outside the key's allow-list |
hourly_cap_exceeded | 429 | Sandbox hourly cap reached |
daily_cap_exceeded | 429 | Sandbox daily cap reached |
override_forbidden | 403 | Body included a per-message key that is not user-overridable |
forbidden_header | 422 | headers 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: 42json
{
"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}
endBest Practices
- Always check the HTTP status code before parsing the response body — different statuses emit different shapes.
- Branch on
code, noterror, for policy errors. Theerrortext is human-readable and may change. - Show field-level validation errors to end users by walking the top-level
detailsobject on 422 changeset responses. - Implement retry logic for
429and5xxresponses with exponential backoff; honourRetry-Afteron429. - Do not retry
400,401,403, or422errors — fix the request first.