Errors
Every error the API returns has a consistent structure and a meaningful HTTP status
code. Check the status code first, then read the error field.
Error shape
Most errors follow this envelope:
{
"error": "Human-readable message"
}
Many errors include additional fields alongside error — each status
section below shows the exact shape. Validation errors (400) use a different
structure from Zod — see below.
Returned for two distinct reasons: schema validation failures (wrong or missing fields) and domain-level validation (domain not verified, bad scheduledAt, etc.). The shapes are different.
Schema validation — Zod error
When a required field is missing or a field has the wrong type, the validator
returns the raw Zod result. Each item in issues names the field
via path and the problem via message:
// HTTP 400 — missing required field
{
"success": false,
"error": {
"issues": [
{
"code": "invalid_type",
"expected": "string",
"received": "undefined",
"path": ["from"],
"message": "Required"
}
],
"name": "ZodError"
}
}
Domain not verified
// HTTP 400
{
"error": "Sending domain not verified",
"domain": "mail.yourdomain.com"
}
Invalid from address
// HTTP 400
{
"error": "Invalid from address",
"domain": ""
}
Too many recipients
// HTTP 400
{
"error": "Too many recipients: 75. Maximum is 50.",
"count": 75,
"max": 50
}
Invalid or past scheduledAt
// HTTP 400 — not a valid datetime
{ "error": "Invalid scheduledAt datetime" }
// HTTP 400 — in the past
{ "error": "scheduledAt must be a future datetime" }
issues[].message
to the user (or log them for debugging). For domain errors — verify and activate the
sending domain in your dashboard. For scheduledAt — ensure the value is a valid
ISO 8601 string representing a future time.
Returned when the Authorization header is missing, malformed, or
contains an invalid or deleted API key.
// HTTP 401
{
"error": "Unauthorized"
}
qle_, is not deleted, and is being sent as
Authorization: Bearer qle_…. See
Authentication.
Monthly email limit reached. The response tells you exactly where you are against your plan's cap.
Single send
// HTTP 402
{
"error": "Monthly limit reached",
"limit": 3000,
"used": 3000,
"plan": "Starter"
}
Batch — all-or-nothing pre-flight check
// HTTP 402
{
"error": "This batch of 50 would exceed your monthly limit of 3000. You can send 12 more this month.",
"canSend": 12
}
canSend to split
and send a smaller batch within your remaining allowance.
The referenced resource does not exist or is not accessible from your account.
On the public API surface, this is returned when the template field
references a slug that does not match any saved template in your account.
// HTTP 404
{
"error": "Template not found"
}
Returned when a template is found but fails to render. This happens when a
{{variable}} in the template body has no matching key in the
variables object — Handlebars strict mode is enabled, so every
referenced variable must be provided.
// HTTP 422 — missing template variable
{
"error": "Template render failed: \"firstName\" not defined"
}
variables object. Use {{#if var}}…{{/if}} in the
template for optional values — references inside an #if block are
not required at render time.
Returned for two independent limits: the per-minute request rate limit and the daily sending cap. The shapes are different — check the body to distinguish them.
Rate limit exceeded (100 requests / 60 seconds)
// HTTP 429
{
"error": "Rate limit exceeded. Please slow down."
}
Rate limit headers are returned on every request — check
X-RateLimit-Remaining to throttle proactively before you hit this.
See Conventions → Rate limits.
Daily sending cap — single send
// HTTP 429
{
"error": "Daily sending limit reached",
"dailyLimit": 100,
"dailySent": 100,
"resetsAt": "midnight UTC",
"plan": "Starter"
}
Daily sending cap — batch pre-flight
// HTTP 429
{
"error": "This batch would exceed your daily limit of 100. You can send 20 more today.",
"canSend": 20
}
X-RateLimit-Reset. Daily cap — use canSend (batch) or
wait until midnight UTC. Daily cap is floor(monthlyLimit / 30);
upgrade your plan to increase it.
Account-level kill-switch. Quolle monitors bounce and complaint rates via CloudWatch. If either rate crosses a threshold, sending is automatically paused for the entire account to protect your domain reputation and inbox placement. This affects all sends regardless of template, recipient, or key used.
// HTTP 503 — bounce rate threshold exceeded
{
"error": "Sending temporarily paused — account under review",
"reason": "bounce"
}
// HTTP 503 — complaint rate threshold exceeded
{
"error": "Sending temporarily paused — account under review",
"reason": "complaint"
}
reason is "bounce" or "complaint" to
speed up the review.
An unexpected error occurred on our side. The body contains a message, but it may not always be actionable.
// HTTP 500
{
"error": "Internal server error"
}