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.

Branch on 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.

CodeStatusWhat to do
validation_error400, 422Fix the request. The message names the field.
unsupported_media_type415Send the file type the endpoint accepts. Retrying the same file will not help.
domain_not_verified400Publish the DNS records and wait for verification. The response lists which of your domains are verified.
missing_api_key, invalid_api_key401Check the Authorization header. We do not say which of the two it is, deliberately.
forbidden403The key or session is valid but not allowed to do this — a permission problem, not a billing one.
account_suspended403Contact support. Nothing about the request will change the answer.
account_paused403Marketing sending is paused — contact support. Transactional mail is unaffected and still sends.
not_found404Check the id, or the URL.
conflict409The resource is in a state that does not allow this.
concurrent_idempotent_request409An identical request is still running. Wait and retry unchanged.
invalid_idempotent_request409This key was already used with a different body. Use a new key, or send the original request.
rate_limit_exceeded429Back off and retry. This clears in seconds.
daily_quota_exceeded429Do 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_exceeded402Wait for the period to roll over, or upgrade. Retrying tomorrow will not help.
plan_limit_exceeded402, 403A 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_due403A 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_expired403The subscription ended and grace is over. Resubscribe or upgrade.
marketing_subscription_required402There is no marketing plan on this account, or it has expired. Not a usage limit.
payment_declined402The card was refused. This is between the account holder and their bank.
payment_method_required400There 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_error500Ours. Retry; if it persists, contact support.
service_unavailable502, 503Ours, and usually brief. Retry.
400 Bad Request

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" }
How to handle: For Zod errors, surface 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.
401 Unauthorized

Returned when the Authorization header is missing, malformed, or contains an invalid or deleted API key.

// HTTP 401
{
  "error": "Unauthorized"
}
How to handle: No detail is provided beyond the status code — this is intentional to avoid leaking information about key validity. Check that your key starts with qle_, is not deleted, and is being sent as Authorization: Bearer qle_…. See Authentication.
402 Payment Required

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
}
How to handle: For single sends — upgrade your plan or wait for the monthly reset (1st of the month, UTC). For batch — use canSend to split and send a smaller batch within your remaining allowance.
404 Not Found

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"
}
How to handle: Verify the template slug in your dashboard under Templates. Template slugs are case-sensitive. Templates belong to your account — you cannot reference another account's templates.
422 Unprocessable Entity

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"
}
How to handle: Pass all variables the template references in the 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" }
  ]
}
How to handle: Fix (or remove) the items named in errors[] and resend the whole batch. Emails not listed were valid — they were still not sent, because the batch is atomic.
429 Too Many Requests

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
}
How to handle: Rate limit — back off and retry after 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.
503 Service Unavailable

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"
}
The pause does not lift automatically. A falling rate caused by sending stopping is not evidence that the underlying list problem is fixed. An operator must review and clear the pause manually. Contact support — do not retry in a loop.
How to handle: Stop retrying immediately (retries will all 503). Check your bounce/complaint rates in the dashboard. Contact support with your account details — include whether reason is "bounce" or "complaint" to speed up the review.
500 Server Error

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"
}
How to handle: Safe to retry with exponential backoff. If 500s persist, check the dashboard for any service notices, or contact support.