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
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.
No Authorization header, a header not in the form Bearer <key>, a mistyped key, or a revoked key.
Check the key and the header. Do not retry in a loop: wrong keys count toward the lockout.
Console only: a signed-in browser write without the right Origin or X-Emlbot-CSRF header.
Reload the console. API keys never see this.
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.
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.
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.
Check the path and that ids are URL-encoded. For a limited key, check its inbox_ids with GET /me.
An OPTIONS request, usually a browser CORS preflight.
Call the API from your server, not from a web page. There is no CORS.
A request body over 30 MB. Base64 attachments are the usual reason: they are about a third larger than the files.
Send fewer or smaller attachments. Attachments are capped at 25 MB per message anyway.
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.
Fix the request. message names the field and the rule. Retrying unchanged gives the same answer.
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.
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.
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.
The message is "Your endpoint answered <status> (or timed out). Fix it, then call enable again."
Creating an inbox whose address is taken (including a deleted inbox's address); inviting a user who already exists.
Pick another username, or create inboxes with a client_id so a retry returns the existing inbox.
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.
Refresh the item. For approvals, check status, outcome and superseded_by with GET /approvals. There is no separate superseded error name.
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.
The message is "This approval expired after 72 hours. Ask the bot to send again."
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.
Ask the bot to send again so a new approval is bound to the current content and recipients.
Three different causes; see below.
Wait for Retry-After seconds before trying again. On a send or reply, keep unread until you get a 200.
Something failed on our side. The message is always Internal error; details go to our logs, never to the response.
Retry reads with backoff. For sends, check before retrying (see retries). Tell info@emlbot.com if it persists.
The outbound email provider refused the message. Nothing was sent and the send was not counted against the daily cap.
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:
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.
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.
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.
Limits
The numbers you are most likely to meet:
Requests
| What | Limit | Over the limit |
|---|---|---|
| JSON request body | 30 MB | 413 payload_too_large |
| Failed authentications per IP address | 20 per 10 minutes | 429 rate_limited with Retry-After |
| Requests per key or bot | 600 per minute | 429 rate_limited. Message: "This key passed its request budget (600/min, 120 writes/min). Retry after the Retry-After header." |
| Writes per key or bot | 120 per minute, any method other than GET | The same 429 rate_limited request-budget message, with Retry-After |
Page size (limit) for inboxes, messages, threads, approvals, audit | Default 50, maximum 500 | Larger values are lowered to 500 |
| Page size for events | Default 100, maximum 1000 | Larger 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
| What | Limit |
|---|---|
| Recipients per send | 50 in total across to, cc and bcc |
reply_to addresses | 10 |
| Attachments | 20 per message, 25 MB in total (decoded); file names up to 255 characters |
| Subject | 500 characters |
Custom headers | 20 per message; names up to 64 characters from A-Z a-z 0-9 -; values up to 998 characters |
| Daily sends per key | daily_send_cap: 1 to 100,000, default 200, per UTC day |
| Addresses | A 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:
Receiving
Including attachments. Larger mail is refused during SMTP delivery, so the sending server reports the failure to the sender.
Mail to an address with no inbox is refused with "Unknown mailbox".
Names and labels
| What | Rule |
|---|---|
| Inbox username | 1 to 64 characters from a-z 0-9 . _ -, starting with a letter or digit, no .. |
Inbox display_name | Up to 100 characters |
Inbox client_id | Up to 128 characters |
| Labels | 1 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 name | Up to 100 characters |
Key inbox_ids | Up to 100 inboxes |
Key allow_recipients | Up to 200 entries, each up to 254 characters |
Webhooks
2xx, or the attempt failsWebhook 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:
It uses https: on the default port, 443.
It contains no username or password.
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.
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.
- https://hooks.yourco.com/emlbot
- 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
- Every
GET. PATCHon labels: adding a label twice or removing one that is gone changes nothing.POST /inboxeswith aclient_id.DELETEon keys (revoking twice keeps the first time).- Approve and reject: a second attempt answers
409 conflictinstead of acting twice.
- Send and reply. Send an
Idempotency-Keyheader (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) withIdempotent-Replayed: true, and the same key with a different request is422 idempotency_key_reused. Without a key, a send that times out on your side may have gone through. Before sending again, list the inbox withlabels=sent(or read the event log formessage.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.
429: wait at leastRetry-Afterseconds.500and502, and network errors: retry with exponential backoff and jitter, then give up and alert.- Other
4xxerrors: do not retry unchanged. The same request gets the same answer. Fix the key, the path or the body first.
- 1 sfirst retry
- 2 ssecond
- 4 sthird
- 8 sfourth
- Give upand alert