Errors¶
How the Mailyte API reports errors, what the status codes mean, and how to handle them.
Error Response Format¶
Error responses follow one envelope:
{
"type": "error",
"msg": "Human-readable error description",
"correlation_id": "a1b2c3d4e5f6"
}
- Every response carries an
X-Correlation-Idheader, and every JSON error body (status ≥ 400) has the samecorrelation_idfolded in. Quote it when reporting problems -- every server log line for the request carries it too. - Errors that need a stable machine-readable identifier additionally carry an
error_codefield (see the registry below). - Some errors include a
datafield with additional detail:
{
"type": "error",
"msg": "Validation failed",
"data": {
"errors": [
"Organization name is required",
"Invalid admin email format"
]
}
}
Two validation shapes
Endpoints that validate request data in their handlers return the envelope above with data.errors and status 400. Endpoints whose request bodies are Pydantic models (and bad query-parameter values on newer endpoints) return 422 -- Pydantic body failures use FastAPI's standard shape {"detail": [{"loc": [...], "msg": "...", "type": "..."}]}, while query-value failures (unknown sort key, bad enum) use the normal envelope.
HTTP Status Codes¶
| Code | Name | When it happens |
|---|---|---|
400 | Bad Request | Missing required fields, invalid data, handler-level validation failure |
401 | Unauthorized | No credential, invalid API key, expired/revoked session, bad login |
403 | Forbidden | Insufficient permission level, wrong scope, insufficient operator role, missing/invalid CSRF token, MFA not completed |
404 | Not Found | Resource does not exist -- or belongs to another organization (deliberately indistinguishable) |
409 | Conflict | Duplicate resource, bootstrap already done, quota override standing, idempotency replay in progress, unreplayable dead letter |
422 | Unprocessable Entity | Pydantic body validation, bad query parameter values, over-limit bulk requests, Idempotency-Key reuse with a different body |
429 | Too Many Requests | An authentication throttle tripped (login or invalid-API-key rate limiting) |
500 | Internal Server Error | Unexpected server failure ({"type": "error", "msg": "Internal server error"} from the global handler) |
503 | Service Unavailable | A downstream microservice (analytics, tracking, RAG, rate limiter, storage, TOTP) is not reachable |
404 is always preferred over 403 for cross-tenant access: a 403 would confirm the resource exists.
error_code Registry¶
error_code | Status | Where |
|---|---|---|
DOMAIN_ALREADY_CLAIMED | 409 | Creating a domain that already exists (yours or another tenant's). If your own org owns it, data.existing_id is included |
MAILBOX_ALREADY_EXISTS | 409 | Creating a mailbox address that already exists (data.existing_id included) |
IDEMPOTENCY_KEY_REUSED | 422 | An Idempotency-Key was reused with a different request body |
IDEMPOTENCY_IN_PROGRESS | 409 | The original request for this Idempotency-Key is still executing -- retry shortly |
CERT_RENEW_UNAVAILABLE | -- | SSL certificate renewal is not available on this deployment |
New codes are additive; the absence of an error_code on older errors is expected.
Common Error Messages¶
Authentication and authorization (401/403)¶
See Authentication -- Authentication Errors for the full table. Highlights:
| Status | Message | Fix |
|---|---|---|
401 | "API key required" / "Invalid API key" | Send a valid X-API-Key header |
401 | "Session expired or invalid" | Log in again |
403 | "Write access required" | Use a key without read_only |
403 | "Admin access required" | Use a key with admin_access |
403 | "Platform scope required" | Endpoint is platform-only; a tenant credential can never reach it |
403 | "Insufficient operator role" | Log in as an operator at or above the route's role floor |
403 | "CSRF token missing or invalid" | Echo the CSRF cookie in X-CSRF-Token on cookie-authenticated writes |
429 | "Too many failed attempts. Try again later." | Wait for the throttle window to pass |
Validation errors (400)¶
| Message | Cause |
|---|---|
"No data provided" | Request body is empty or not valid JSON |
"Validation failed" | One or more fields failed validation (check data.errors) |
"Organization ID is required" / "Organization name is required" | Missing fields when creating an organization |
"Organization ID must contain only alphanumeric characters, hyphens, and underscores" | Invalid characters in organization ID |
"Invalid domain name format" | Domain name failed format validation |
"Invalid email format" | Email address failed format validation |
"Password must be at least 12 characters long" | Password too short |
"Password must contain letters" / "Password must contain numbers" | Password composition |
"Password is too common; choose something less predictable" | Password on the common-password blocklist |
"Max quota must be non-negative" | Negative quota value |
"url is required" / "url must start with http:// or https://" | Webhook endpoint URL problems |
"event_types must be a list or null" | Wrong type for webhook event_types |
"Domain {name} not found or inactive" | The domain for a new mailbox/alias does not exist, is inactive, or is not yours |
Not found (404)¶
| Message | Cause |
|---|---|
"Organization not found" | No such org, or it belongs to another tenant |
"Domain not found" | No such domain, or it belongs to another tenant |
"Email account not found" / "Mailbox not found" | No such account, or cross-tenant |
"Webhook endpoint not found" | No such endpoint, or cross-tenant |
"Dead letter not found" | No such dead-letter row |
Conflicts (409)¶
| Message | Cause |
|---|---|
"Organization with this ID already exists" | Duplicate organization ID |
"Organization with this external ID already exists" | Duplicate org external_id |
"Domain {name} already exists" | Domain already claimed (error_code: DOMAIN_ALREADY_CLAIMED) |
"Domain with this external_id already exists" | Duplicate domain external_id |
"Email account {email} already exists" | Duplicate address |
"Mailbox {email} already exists" | Duplicate address, legacy endpoint (error_code: MAILBOX_ALREADY_EXISTS) |
"Alias {address} already exists" | Duplicate alias source address |
"Already bootstrapped -- an organization already exists" | POST /api/v1/bootstrap/ on a provisioned install |
"Quota override in place: set by {who} at {when}..." | Automated quota write while an operator override stands (pass ?force=true or clear the override) |
Dependency errors (400)¶
| Message | Cause |
|---|---|
"Cannot delete organization with {n} domains and {n} email accounts. Delete them first." | Organization has child resources |
"Cannot delete domain with {n} email accounts. Delete them first." | Domain has child resources |
"Domain has reached maximum user limit ({n})" | max_users quota exceeded |
Service unavailable (503)¶
| Message | Cause |
|---|---|
{"error": "Analytics service unavailable"} | Analytics worker unreachable |
{"error": "Rate limiter service unavailable"} | Rate limiter worker unreachable |
{"error": "Storage service unavailable"} | Storage worker unreachable |
{"error": "RAG service unavailable"} | RAG worker unreachable |
"MFA service unavailable" | TOTP service unreachable during operator login |
Note that proxied 503s use the downstream {"error": ...} shape rather than the type/msg envelope.
Error Handling Best Practices¶
-
Always check the
typefield. If it is"error", something went wrong. -
Check
data.errorsfor validation failures. When the message is"Validation failed", thedata.errorsarray tells you exactly which fields are wrong. -
Branch on
error_codewhere present. It is stable across releases;msgwording is not. -
Retry on 503. Downstream services may be temporarily unavailable. Wait a few seconds and retry -- with an
Idempotency-Keyon mutating requests so retries are safe. -
Do not retry on 4xx (except
409IDEMPOTENCY_IN_PROGRESS, which asks you to retry shortly). Client errors will not succeed without changing the request. -
Log the
correlation_id. It links your request to every server-side log line about it.
Example: Handling Errors in Code¶
import requests
resp = requests.post(
"http://your-server:8083/api/v1/domains/",
headers={"X-API-Key": "YOUR_KEY"},
json={"domain": "acme.com"},
)
body = resp.json()
if body.get("type") == "error":
code = body.get("error_code")
if code == "DOMAIN_ALREADY_CLAIMED":
print("Domain already exists:", body.get("data", {}).get("existing_id"))
elif resp.status_code == 400 and "errors" in body.get("data", {}):
for err in body["data"]["errors"]:
print(f"Validation error: {err}")
elif resp.status_code == 401:
print("Check your API key")
else:
print(f"Error [{body.get('correlation_id')}]: {body['msg']}")
else:
print(f"Success: {body['msg']}")
const resp = await fetch("http://your-server:8083/api/v1/domains/", {
method: "POST",
headers: {
"X-API-Key": "YOUR_KEY",
"Content-Type": "application/json"
},
body: JSON.stringify({ domain: "acme.com" })
});
const body = await resp.json();
if (body.type === "error") {
if (body.error_code === "DOMAIN_ALREADY_CLAIMED") {
console.error("Domain already exists:", body.data?.existing_id);
} else if (resp.status === 400 && body.data?.errors) {
body.data.errors.forEach(err => console.error("Validation:", err));
} else {
console.error(`Error [${body.correlation_id}]:`, body.msg);
}
} else {
console.log("Success:", body.msg);
}