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:
{
"code": "monthly_quota_exceeded",
"error": "Monthly limit reached"
}code is a stable identifier your program can branch on. error is a sentence for a person to read, and it may be reworded — never match on it. Many errors include additional fields alongside these; each status section below shows the exact shape. Validation errors (400) use a different structure from Zod — see below.
code, not on the status and not on the message. The status alone cannot tell you enough: a daily cap and a rate limit are both 429 but need opposite reactions, and an exhausted monthly quota and a declined card are both 402 while only one of them is fixed by paying more.Error codes
Every code the API returns, and what a client should do about it.
| Code | Status | What to do |
|---|---|---|
validation_error | 400, 422 | Fix the request. The message names the field. |
unsupported_media_type | 415 | Send the file type the endpoint accepts. Retrying the same file will not help. |
domain_not_verified | 400 | Publish the DNS records and wait for verification. The response lists which of your domains are verified. |
missing_api_key, invalid_api_key | 401 | Check the Authorization header. We do not say which of the two it is, deliberately. |
forbidden | 403 | The key or session is valid but not allowed to do this — a permission problem, not a billing one. |
account_suspended | 403 | Contact support. Nothing about the request will change the answer. |
account_paused | 403 | Marketing sending is paused — contact support. Transactional mail is unaffected and still sends. |
not_found | 404 | Check the id, or the URL. |
conflict | 409 | The resource is in a state that does not allow this. |
concurrent_idempotent_request | 409 | An identical request is still running. Wait and retry unchanged. |
invalid_idempotent_request | 409 | This key was already used with a different body. Use a new key, or send the original request. |
rate_limit_exceeded | 429 | Back off and retry. This clears in seconds. |
daily_quota_exceeded | 429 | Do not back off and retry — it clears at midnight UTC, and resetsAt says so. Only plans that carry a daily cap — the free tiers — can return this. |
monthly_quota_exceeded | 402 | Wait for the period to roll over, or upgrade. Retrying tomorrow will not help. |
plan_limit_exceeded | 402, 403 | A ceiling only a larger plan raises — monthly volume (402), or a cap on contacts, domains, API keys, team members or a gated feature (403). |
subscription_past_due | 403 | A renewal failed. Sending continues during grace, but new contacts, domains, keys, team members and webhooks are blocked. Fix the card — upgrading will not help. |
subscription_expired | 403 | The subscription ended and grace is over. Resubscribe or upgrade. |
marketing_subscription_required | 402 | There is no marketing plan on this account, or it has expired. Not a usage limit. |
payment_declined | 402 | The card was refused. This is between the account holder and their bank. |
payment_method_required | 400 | There is no card on the account, and this operation needs one to charge later. Distinct from payment_declined, where a card was tried and refused. Start a subscription to add one — checkout is the only thing that captures a card. |
application_error | 500 | Ours. Retry; if it persists, contact support. |
service_unavailable | 502, 503 | Ours, and usually brief. Retry. |
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 in two situations: a template fails to render, or a batch fails validation. The shapes are different — check for the errors array to distinguish them.
Template render failure — 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.Batch validation failure — one or more emails in a POST /v1/emails/batch request are invalid. The batch is all-or-nothing: nothing is queued, and every invalid item is reported with its zero-based index in emails[].
// HTTP 422 — batch rejected, nothing queued
{
"error": "Batch validation failed — no emails were queued",
"errors": [
{ "index": 1, "error": "Sending domain not verified: unverified.com" },
{ "index": 4, "error": "scheduledAt must be a future datetime" }
]
}errors[] and resend the whole batch. Emails not listed were valid — they were still not sent, because the batch is atomic.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"
}