Docs

Errors and limits.

What each error means, what usually causes it and what to do next. Then every limit the API enforces, the rules for webhook URLs, and when a retry is safe.

OverviewQuickstartConceptsErrors and limits

Error shape

Every error from /v0 is JSON with two fields: name, a short machine-readable code to branch on, and message, a sentence for people and logs.

Response 403 forbidden

HTTP/2 403
Content-Type: application/json

{
  "name": "forbidden",
  "message": "Recipient jordan@partnerco.com is not in this key's allow_recipients"
}

How to read it

status + nameYour bot branches on these
messageFor people and logs

A bot should decide what to do from the status and name, and log message.

Status codes

Grouped by what usually causes them. Each one says what to do next, and whether to retry.

Fix first do not retry unchangedWait retry after Retry-AfterBackoff retry with backoff
Your credentialsThe key, the header or the console session
401 unauthorizedFix first
Common causes

No Authorization header, a header not in the form Bearer <key>, a mistyped key, or a revoked key.

What to do

Check the key and the header. Do not retry in a loop: wrong keys count toward the lockout.

403 csrf_failedFix first
Common causes

Console only: a signed-in browser write without the right Origin or X-Emlbot-CSRF header.

What to do

Reload the console. API keys never see this.

Permissions and send policyThe key may not do this, or not to this address
403 forbiddenFix first
Common causes

The key lacks the scope for this call; an admin call made with a non-admin or inbox-limited key; a users call made without the owner's console session; a recipient outside allow_recipients on a key without an approval gate; a key approving its own parked send.

What to do

Read message: it names the missing scope or the refused address. Use a key with the right scope, add the recipient to the allowlist, set approval: "outside", or have a different key or a person approve.

The request itselfPath, method, size or body
404 not_foundFix first
Common causes

A wrong path or method (No such endpoint); an inbox, message, thread, attachment, key, webhook, user or approval that does not exist; an inbox outside an inbox-limited key's list; an id that was not URL-encoded.

What to do

Check the path and that ids are URL-encoded. For a limited key, check its inbox_ids with GET /me.

405 method_not_allowedFix first
Common causes

An OPTIONS request, usually a browser CORS preflight.

What to do

Call the API from your server, not from a web page. There is no CORS.

413 payload_too_largeFix first
Common causes

A request body over 30 MB. Base64 attachments are the usual reason: they are about a third larger than the files.

What to do

Send fewer or smaller attachments. Attachments are capped at 25 MB per message anyway.

422 validation_errorFix first
Common causes

A body that is not a JSON object; a missing or malformed field; a value over a limit; a bad limit, after or page_token.

What to do

Fix the request. message names the field and the rule. Retrying unchanged gives the same answer.

422 digest_loopFix first
Common causes

PUT /v0/bots/{id}/digest-address was given an address that resolves to an inbox in any EMLbot workspace. The same check runs when a wake is updated and email_digest.to is set.

What to do

The message is "That address is delivered into EMLbot itself; a wake email to it would wake the bot again. Use the bot's outside address." Point the digest at an address outside EMLbot.

422 webhook_unhealthyFix first
Common causes

POST /v0/webhooks/{id}/enable sent one signed webhook.test event and the endpoint answered with an error, or timed out. Redirects are not followed and the check times out after 10 seconds. The webhook stays disabled. The body includes status.

What to do

The message is "Your endpoint answered <status> (or timed out). Fix it, then call enable again."

State conflictsIt already exists, or was already decided
409 already_existsFix first
Common causes

Creating an inbox whose address is taken (including a deleted inbox's address); inviting a user who already exists.

What to do

Pick another username, or create inboxes with a client_id so a retry returns the existing inbox.

409 conflictFix first
Common causes

Approving or rejecting an approval that was already decided; approving after the inbox was deleted or the requesting key revoked; demoting or disabling the last owner. A new mail approval for the same inbox and the same draft or thread rejects older pending ones. Those are stored as rejected with outcome superseded and superseded_by set to the new approval id.

What to do

Refresh the item. For approvals, check status, outcome and superseded_by with GET /approvals. There is no separate superseded error name.

409 approval_expiredFix first
Common causes

Deciding a mail approval more than 72 hours after it was created. It is stored as rejected with outcome expired, and the expiry is audited as approval.expire. Its draft, if still pending_approval, is marked failed with send_error The approval expired.

What to do

The message is "This approval expired after 72 hours. Ask the bot to send again."

409 approval_staleFix first
Common causes

Carrying out a mail approval whose stored request no longer matches. The approval is bound to a sha256 over to, cc, bcc, subject, text, html and the attachment ids. The send uses that stored request, and policy is checked again first.

What to do

Ask the bot to send again so a new approval is bound to the current content and recipients.

Slow downA request budget, a daily cap, or too many wrong keys
429 rate_limitedWait
Common causes

Three different causes; see below.

What to do

Wait for Retry-After seconds before trying again. On a send or reply, keep unread until you get a 200.

Our sideSomething failed at EMLbot or the email provider
500 internal_errorBackoff
Common causes

Something failed on our side. The message is always Internal error; details go to our logs, never to the response.

What to do

Retry reads with backoff. For sends, check before retrying (see retries). Tell info@emlbot.com if it persists.

502 send_failedBackoff
Common causes

The outbound email provider refused the message. Nothing was sent and the send was not counted against the daily cap.

What to do

Retry with backoff. If it keeps failing for the same message, check the recipients and attachments.

The three kinds of 429

Each uses name: "rate_limited" and a Retry-After header in seconds. Tell them apart by the call, the message and the headers:

Key or bot authRequest budget spent

600 requests a minute, and 120 writes a minute. A write is any request that is not a GET. The message is "This key passed its request budget (600/min, 120 writes/min). Retry after the Retry-After header." The admin token and signed-in people are not counted. Long-poll GET /v0/me/wait counts once per request, not again while the wait stays open. Retry after Retry-After.

Only on send, reply and approveDaily send cap spent

The message says Daily send cap of 100 reached for this key (with your cap), and the response also carries X-RateLimit-Limit, X-RateLimit-Remaining: 0 and X-RateLimit-Reset. The cap resets at midnight UTC. Queue the send until then, or raise the cap with a new key if the bot genuinely needs more. Keep unread on the message you were answering.

Any callToo many failed sign-ins from your IP address

After 20 requests with a wrong key from one IP address within 10 minutes, that address is locked out, even for valid keys, until the oldest failure is more than 10 minutes old. Retry-After says how long. Find the process using a bad or revoked key and stop it. Successful calls and calls with no key at all do not count.

Shared IP addressesThe lockout is per IP address, so machines behind the same NAT or office connection share it. One misconfigured job can lock out its neighbours for up to 10 minutes.

Limits

The numbers you are most likely to meet:

30MBJSON request body
25MBAttachments per message, and inbound message size
50Recipients per send, across to, cc and bcc
200/ dayDefault daily sends per key (1 to 100,000)

Requests

WhatLimitOver the limit
JSON request body30 MB413 payload_too_large
Failed authentications per IP address20 per 10 minutes429 rate_limited with Retry-After
Requests per key or bot600 per minute429 rate_limited. Message: "This key passed its request budget (600/min, 120 writes/min). Retry after the Retry-After header."
Writes per key or bot120 per minute, any method other than GETThe same 429 rate_limited request-budget message, with Retry-After
Page size (limit) for inboxes, messages, threads, approvals, auditDefault 50, maximum 500Larger values are lowered to 500
Page size for eventsDefault 100, maximum 1000Larger values are lowered to 1000

The admin token and signed-in people are not counted against the 600-a-minute or 120-writes-a-minute budgets. Long-poll GET /v0/me/wait counts once per request, not again while the wait stays open. A webhook still removes the need to poll.

Sending

WhatLimit
Recipients per send50 in total across to, cc and bcc
reply_to addresses10
Attachments20 per message, 25 MB in total (decoded); file names up to 255 characters
Subject500 characters
Custom headers20 per message; names up to 64 characters from A-Z a-z 0-9 -; values up to 998 characters
Daily sends per keydaily_send_cap: 1 to 100,000, default 200, per UTC day
AddressesA plain address or Name <address>. Names containing @ , ; " must be fully quoted. No control characters.

Custom headers cannot override how a message is routed or authenticated. These names are reserved, and a custom header using one is left out of the message:

FromToCcBccSubjectMessage-IDDateMIME-VersionReply-ToIn-Reply-ToReferencesSenderReturn-PathReceivedDKIM-SignatureAuthentication-ResultsList-Unsubscribe-Postanything starting with Content-anything starting with X-SES-

Receiving

25 MBInbound message size

Including attachments. Larger mail is refused during SMTP delivery, so the sending server reports the failure to the sender.

Unknown mailboxUnknown address

Mail to an address with no inbox is refused with "Unknown mailbox".

Names and labels

WhatRule
Inbox username1 to 64 characters from a-z 0-9 . _ -, starting with a letter or digit, no ..
Inbox display_nameUp to 100 characters
Inbox client_idUp to 128 characters
Labels1 to 64 characters from a-z 0-9 . _ : -; up to 20 in add_labels, 20 in remove_labels and 20 in labels on a send
Key nameUp to 100 characters
Key inbox_idsUp to 100 inboxes
Key allow_recipientsUp to 200 entries, each up to 254 characters

Webhooks

10sResponse time for a 2xx, or the attempt fails
8retriesOver about 28 hours (5 s, 30 s, 5 min, 30 min, 2 h, 5 h, 10 h, 10 h). See the timeline
50Consecutive failures before the webhook is disabled
2048Characters in the URL

Webhook destination rules

Webhook URLs are checked when you create the webhook and again before every delivery, so EMLbot can never be pointed at a private network. A URL is refused with 422 validation_error unless all of these hold:

HTTPS on 443

It uses https: on the default port, 443.

No credentials

It contains no username or password.

A name, not an IP

Its host is a name, not an IP address in any form (dotted, decimal, hex, octal, short or IPv6). This rules out loopback, private ranges, link-local and cloud metadata addresses.

A public name

Its host has at least one dot, is not localhost, metadata or instance-data, and does not end in .local, .localhost, .internal, .localdomain, .lan, .home.arpa, .intranet or .corp.

Examples
Accepted
  • https://hooks.yourco.com/emlbot
422 refused
  • http://hooks.yourco.com/emlbot not https
  • https://hooks.yourco.com:8443/emlbot not port 443
  • https://user:pass@hooks.yourco.com/ credentials
  • https://10.0.0.5/hook an IP address
  • https://localhost/hook localhost
  • https://billing.internal/hook .internal

At delivery time, redirects are not followed (a 3xx counts as a failure), the request times out after 10 seconds, and your response body is discarded unread. Point the webhook at the final URL, and answer with a plain 2xx.

Retry guidance

Safe to retry as they areThe second call changes nothing
  • Every GET.
  • PATCH on labels: adding a label twice or removing one that is gone changes nothing.
  • POST /inboxes with a client_id.
  • DELETE on keys (revoking twice keeps the first time).
  • Approve and reject: a second attempt answers 409 conflict instead of acting twice.
Retry with careA retry can act twice
  • Send and reply. Send an Idempotency-Key header (1 to 255 printable characters) with send, reply, reply-all, forward and draft sends: a repeat with the same key returns the first response (stored for 24 hours) with Idempotent-Replayed: true, and the same key with a different request is 422 idempotency_key_reused. Without a key, a send that times out on your side may have gone through. Before sending again, list the inbox with labels=sent (or read the event log for message.sent) and check whether it is there.
  • Creating keys and webhooks. A retry after a lost response creates a second one. List them, keep one, and revoke or delete the extra.
When to back off, when to stopDecide by status
  • 429: wait at least Retry-After seconds.
  • 500 and 502, and network errors: retry with exponential backoff and jitter, then give up and alert.
  • Other 4xx errors: do not retry unchanged. The same request gets the same answer. Fix the key, the path or the body first.
For example: backoff for 500, 502 and network errorsexponential, with jitter
  1. 1 sfirst retry
  2. 2 ssecond
  3. 4 sthird
  4. 8 sfourth
  5. Give upand alert

Read next