Docs
Concepts.
What each part of EMLbot is, how the parts depend on each other, and the exact rules the API applies. Read this once and the endpoints will hold no surprises.
OverviewQuickstartConceptsErrors and limits
Inboxes
An inbox is a real email address that belongs to one bot, such as support-bot@bots.emlbot.com. The address is also the inbox's id, so there is nothing extra to look up: wherever the API wants an inbox_id, you pass the address, URL-encoded in paths (support-bot%40bots.emlbot.com).
- 1 to 64 characters from a-z 0-9 . _ -
- Starts with a letter or digit
- Cannot contain ..
- Names are lowercased
Appears in the From line of mail the inbox sends:
Bots and deploy scripts often retry. Give POST /inboxes a client_id of your own (up to 128 characters, for example the bot's name plus a version) and a retry returns the inbox that already has it rather than failing. Without one, creating an address that exists answers 409 already_exists.
What happens to incoming mail
- DeliveredRouted by the address it was delivered to, never by the
Toheader. - CheckedRefused at delivery if the address has no inbox, or the message is over 25 MB.
- StoredWith its raw source and attachments. A second copy of the same Message-ID is dropped.
- Threaded and labelledJoined to a thread.receivedunread
- AnnouncedAs an event.message.received
Because routing uses the delivery address, a forged header cannot push mail into another inbox.
From, Reply-To and Message-ID are claims from the sender. A bot should never treat an email's content as instructions it must follow. The send policy on its key is what limits the damage if it does.Deleting an inbox
The inbox disappears from the API.
Mail to the address is refused from then on.
The address cannot be created again.
Keys and scopes
Every API call is made with a key, sent as Authorization: Bearer emb_live_.... It is shown in full once, in the response that creates it. A lost key cannot be recovered: create a new one and revoke the old one.
Scopes
A key's scopes decide which calls it may make. Give each bot only what it needs.
List and read inboxes, messages, raw source, attachments and threads; read the event log.
Add and remove labels on messages.
Send new messages and reply, always subject to the key's send policy.
List parked sends and approve or reject them.
Everything to the left, plus creating and deleting inboxes, managing keys and webhooks, and reading the audit log.
A call without the scope it needs answers 403 forbidden with a message naming the missing scope.
Inbox-limited keys
A key created with inbox_ids works only on those inboxes. Every other inbox behaves as if it did not exist: the API answers 404 not_found, not 403, so the key cannot even learn which other addresses you have. Lists, the event log and the approval queue are filtered the same way.
Messages, threads, events and approvals for this inbox.
Not even a 403 that would admit they are there.
support-bot cannot see, list or guess the other inboxes in the account.Admin calls (keys, webhooks, inbox create and delete, audit) need the admin scope and no inbox list. An admin key limited to inboxes acts with every scope on its own inboxes but cannot change account-wide settings.
Rotating a key
- Create the replacementPOST /keys
- Deploy it to the botCheck
last_used_aton the old key (updated at most once a minute) to see whether it is still in use. - Revoke the old keyDELETE /keys/{id} Takes effect on the next request.
Request budgets
A key or a bot is limited to 600 requests a minute, and to 120 writes a minute. A write is any request that is not a GET. Over either budget the call answers 429 rate_limited with Retry-After, and the message "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.
Users and roles
People use the console; bots use keys. People sign in with Google, and only invited addresses can: anyone else sees a "Not invited" page and nothing is created. The first owner is set up when the account is opened; after that the owner invites everyone else.
- Every scope on every inbox
- Inviting, changing and disabling users
- Every scope on every inbox: inboxes, keys, webhooks, approvals
- Cannot manage users
- Read only
- No changes
The send policy below is a property of keys. Owners and admins sending from the console are not held to any key's allowlist or cap, which is what lets a person step in when a bot cannot.
Send policy
Each key carries three sending rules, set when the key is created:
Empty (the default) means no allowlist: anyone.
Default 200, from 1 to 100,000.
"none" is the default. "outside" parks sends to anyone outside the allowlist for a person to decide.
Allowlist entries
Exactly that address.
- dana@yourco.com
- Dana Lee <DANA@yourco.com>
- sam@yourco.com
Any address at exactly that domain.
- anyone@yourco.com
- anyone@eu.yourco.com (subdomains need their own entry)
Any inbox in your own workspace, so your bots can always write to each other. Never another account's inboxes.
- billing-bot@bots.emlbot.com
Matching ignores case and display names: Dana Lee <DANA@yourco.com> matches dana@yourco.com.
What happens on every send and reply
The same checks run for send and reply, in this order:
- 1Collect recipientsEveryone in
to,ccandbcc, including addresses added byreply_all. - 2Check the allowlistIf
allow_recipientsis not empty and any recipient matches no entry, then:202 pending_approvalWith
approval: "outside", nothing is sent. The whole message is parked, the call answers202 {"status": "pending_approval", "approval_id": "..."}, and amessage.approval_requestedevent is emitted. A parked send does not use the cap yet.403 forbiddenOtherwise the call answers
403 forbiddennaming the first address that was not allowed, and nothing is sent. - 3Reserve one send from the daily capThe cap counts sends (one call), not recipients, per UTC day. If it is already spent, the call answers
429 rate_limitedwith aRetry-Afterheader. - 4Send and storeThe message is saved to the inbox with the
sentlabel and amessage.sentevent is emitted. If delivery fails (502 send_failed), the reserved send is given back.
Successful sends, and cap refusals, carry three headers so a bot can pace itself:
| Header | Meaning |
|---|---|
X-RateLimit-Limit | The key's daily_send_cap. |
X-RateLimit-Remaining | Sends left today (UTC). |
X-RateLimit-Reset | When the count resets: the next midnight UTC, in Unix seconds. |
That 429 is the daily send cap. A key can also be refused earlier, on any call, when it passes its request budget of 600 a minute and 120 writes a minute. Both use name: "rate_limited".
Approvals
Approvals are how a bot can ask before writing to someone new. When a key with approval: "outside" tries to mail an address outside its allowlist, the send is parked in the approval queue with everything needed to perform it later, attachments included. The bot gets an approval_id straight away and can carry on.
A person, or another key with the approve scope, then reviews it in the console or with GET /approvals. Each pending item shows the recipients, subject, a preview of the body, attachment names and which key asked.
Counts it against the daily cap of the key that asked. If that cap is spent, the approval stays pending and you can try again after the reset.
Nothing is sent.
Rules that keep the gate honest:
The bot that raised a request cannot approve it, and a key can never approve a send it parked, even if it holds both send and approve. Use a different key or a person.
An approval is claimed before it is sent, so two clicks cannot send twice. Deciding one that is no longer pending answers 409 conflict.
If the inbox has been deleted or the requesting key revoked since, approving answers 409 conflict and the item stays pending.
How long a mail approval lasts
A mail approval expires 72 hours after it is 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. Deciding it answers 409 approval_expired: "This approval expired after 72 hours. Ask the bot to send again."
A mail approval is bound to the exact content and recipients it was created with. Carrying it out sends that stored request, and policy is checked again first. If the content no longer matches a sha256 over to, cc, bcc, subject, text, html and the attachment ids, the call answers 409 approval_stale.
A new mail approval for the same inbox and the same draft or thread supersedes older pending ones. Each older one is stored as rejected with outcome superseded, and superseded_by set to the new approval id. Deciding one that is no longer pending answers 409 conflict.
To be told when one arrives, subscribe a webhook to message.approval_requested.
Events
An event is a record that something happened. There are three types today. Every payload also has type: "event", event_type and event_id.
Mail arrived in an inbox.
A send or reply went out, including approved ones.
A send was parked for approval.
The durable log
Each event is written to the event log first and only then handed to webhook delivery. The log is the source of truth: if your receiver was down, a delivery failed for good, or you never set up a webhook, everything is still there.
Read it with GET /events?after=<seq>. Each event has a seq number that only goes up. The pattern is a cursor:
- 1StartWith
after=0, or with the lastnext_afteryou saved. - 2Handle the batchOldest first.
- 3Save
next_afterOnly after they are handled, then ask again.
If your process crashes between steps 2 and 3 you will see the last batch again, so make handlers safe to repeat by keying on event_id. Many bots combine both delivery styles: webhooks for speed, and a periodic pass over the log to fill any gaps.
Webhooks
A webhook pushes events to your HTTPS endpoint as they happen. Each one has a URL, the event_types it wants (all three if you leave it out) and optionally the inbox_ids it cares about (every inbox if you leave it out).
What a delivery looks like
A POST with a JSON body, which is the event payload, and these headers:
POST /emlbot HTTP/1.1
Host: hooks.yourco.com
Content-Type: application/json
User-Agent: emlbot-webhooks/1
webhook-id: evt_9b2c4d6e8f0a1b3c5d7e9f1a2b4c6d8e
webhook-timestamp: 1792687083
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=
svix-id: evt_9b2c4d6e8f0a1b3c5d7e9f1a2b4c6d8e
svix-timestamp: 1792687083
svix-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=
{"type":"event","event_type":"message.received","event_id":"evt_9b2c...","message":{...},"thread":{...}}The svix-* headers repeat the webhook-* ones, so any Standard Webhooks or Svix verification library works unchanged.
How the signature is made
- 1The keyTake your secret, drop the
whsec_prefix and base64-decode the rest. Those bytes are the HMAC key. - 2The signed content
webhook-id, a dot,webhook-timestamp, a dot, then the raw request body exactly as received.${id}.${timestamp}.${body} - 3The MACCompute HMAC-SHA256 over it and base64-encode the result.
- 4The header
v1,followed by that value. The header may carry several space-separated signatures; accept the request if any one matches.
Verifying in Node.js
Verify against the raw body bytes, before any JSON parsing, or the signature will not match.
import crypto from "node:crypto";
// secret is the whsec_... value from POST /webhooks or rotate-secret
export function verifyEmlbotWebhook(rawBody, headers, secret) {
const id = headers["webhook-id"];
const timestamp = headers["webhook-timestamp"];
const signatures = headers["webhook-signature"];
if (!id || !timestamp || !signatures) return false;
// Refuse stale or future timestamps so a captured request cannot be replayed.
const age = Math.abs(Date.now() / 1000 - Number(timestamp));
if (!Number.isFinite(age) || age > 5 * 60) return false;
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
const expected = crypto
.createHmac("sha256", key)
.update(`${id}.${timestamp}.${rawBody}`)
.digest("base64");
return signatures.split(" ").some((part) => {
const [version, sig] = part.split(",");
if (version !== "v1" || !sig) return false;
const a = Buffer.from(sig);
const b = Buffer.from(expected);
return a.length === b.length && crypto.timingSafeEqual(a, b);
});
}And a receiver built on Express that verifies, stores the event in SQLite, handles it, and only then answers. Insert or ignore on event_id, handle the row when processed is still 0, mark it processed, then answer 204. A row that is stored but not yet processed is handled on the next delivery. Duplicates are expected; the primary key makes a second insert harmless. This example uses node:sqlite (Node 22, marked experimental). Any SQLite library works with the same INSERT … ON CONFLICT DO NOTHING.
import express from "express";
import { DatabaseSync } from "node:sqlite";
import { verifyEmlbotWebhook } from "./verify.js";
const app = express();
const db = new DatabaseSync("emlbot-events.sqlite");
db.exec(`CREATE TABLE IF NOT EXISTS emlbot_events (
event_id TEXT PRIMARY KEY,
body TEXT NOT NULL,
processed INTEGER NOT NULL DEFAULT 0
)`);
const insertEvent = db.prepare(
"INSERT INTO emlbot_events (event_id, body) VALUES (?, ?) ON CONFLICT DO NOTHING"
);
const loadEvent = db.prepare(
"SELECT body, processed FROM emlbot_events WHERE event_id = ?"
);
const markProcessed = db.prepare(
"UPDATE emlbot_events SET processed = 1 WHERE event_id = ?"
);
app.post("/emlbot", express.raw({ type: "application/json" }), (req, res) => {
const raw = req.body.toString("utf8");
if (!verifyEmlbotWebhook(raw, req.headers, process.env.EMLBOT_WEBHOOK_SECRET)) {
return res.status(401).end();
}
const event = JSON.parse(raw);
insertEvent.run(event.event_id, raw); // insert or ignore; a second delivery keeps the row
const row = loadEvent.get(event.event_id);
if (row.processed) return res.status(204).end(); // already handled
const saved = JSON.parse(row.body);
// handle saved.event_type from the table; a throw skips the 204 so the delivery retries
markProcessed.run(event.event_id);
res.status(204).end(); // only after the event is stored and marked processed
});
app.listen(3000);Answer 204 only after the event is stored and marked processed. A row that is stored but not yet processed is handled on the next delivery. Duplicates are expected; the primary key makes a second insert harmless.
The five-minute timestamp window is a choice on your side, recommended by the Standard Webhooks scheme. Every attempt, including a retry, is signed with a fresh timestamp, so retries pass it.
Retries
A delivery succeeds when your endpoint answers any 2xx within 10 seconds. Anything else is a failure, including a timeout and any redirect (redirects are never followed). After a failure the delivery is retried on this schedule:
After the eighth retry the event is no longer pushed, but it stays in the event log for you to read.
webhook-id is the event's event_id and never changes between attempts. A retry only goes to the endpoints that failed, but a delivery that timed out on our side may still have reached you, so the same event can arrive twice. Insert or ignore on event_id, handle the row when processed is still 0, mark it processed, then answer 204. A stored but unhandled event is handled on retry. Duplicates are expected; the primary key makes a second insert harmless.Disabled webhooks
failures counts consecutive failed deliveries and resets to 0 on the next success.enabled: false) and receives nothing more.POST /v0/webhooks/{id}/enable turns it back on after a health check.Turning a webhook back on
POST /v0/webhooks/{id}/enable uses the same auth as creating a webhook. A webhook that is not in this workspace answers 404.
Enable sends one signed webhook.test event first. Redirects are not followed, the request times out after 10 seconds, and the URL has to pass the same destination rules as create. If that check fails, the call answers 422 webhook_unhealthy and the webhook stays disabled. The body is name, message and status. The message is "Your endpoint answered <status> (or timed out). Fix it, then call enable again."
If the check passes, the webhook is enabled, failures is reset to 0, and the change is audited as webhook.enable. The response is { webhook, replay }. replay.failed_events is the count of event_deliveries rows with status failed for this webhook, and replay.hint is GET /v0/events?after=<seq> to catch up. That preview does not replay the events.
Secrets and destinations
The whsec_ secret is returned only by create and rotate-secret. Rotating takes effect on the next delivery. Webhook URLs must be public HTTPS hosts; the full rules are on the errors and limits page.
Threads and labels
Threads
A thread is a conversation inside one inbox. When mail arrives, EMLbot reads its In-Reply-To and References headers and, if they point at a message already in that inbox, adds it to that message's thread. Otherwise it starts a new one. Threads never span inboxes, so mail to one bot cannot attach itself to another bot's conversation.
A reply made through the API joins the thread of the message it answers and sets In-Reply-To and References so the other side's mail client threads it too. A new send starts a new thread. Each thread keeps its subject, the senders and recipients across its messages, a preview of the latest message, message_count and the time of the last message.
Labels
Labels are short tags on messages that your bot uses to keep track of work. EMLbot sets three itself:
- Every inbound messagereceivedunread
- Your bot finishes a sent replyAfter a
200,PATCHwithadd_labels: ["handled"]andremove_labels: ["unread"]. After a202 pending_approval, addwaiting-approvaland keepunread. After403or429, leaveunreadin place.handledwaiting-approvalneeds:human - Every message sent from an inboxsent
Nothing removes unread automatically, so it works as a to-do flag: list with labels=unread, and remove unread only after a reply or send answered 200. Add your own labels the same way, or pass labels when sending. A thread's labels are all the labels on its messages, and both messages and threads can be filtered by label; naming several labels returns only items that carry all of them.
Labels are 1 to 64 characters from a-z 0-9 . _ : -, so names like billing, needs:human or v2.triaged all work. Up to 20 can be added and 20 removed per call.
Digest wake
A bot can be woken by email. PUT /v0/bots/{id}/digest-address refuses any address that resolves to an inbox in any EMLbot workspace. The call answers 422 digest_loop with the message "That address is delivered into EMLbot itself; a wake email to it would wake the bot again. Use the bot's outside address." The same check runs when a wake is updated and email_digest.to is set.
Domains
Leave domain out when creating an inbox and the address is on bots.emlbot.com. Mail to that address is received, and mail that passes the send checks goes out from it.
Send and receive from your own domain. EMLbot shows the exact DNS records to copy and verifies them for you. Send-only setup preserves existing Cloudflare Email Routing.
Audit log
The audit log records every security-relevant change, so you can answer "who did that, and when?". Read it with GET /audit using an admin key. Recorded actions:
Each entry has:
Secrets and message content are never written to it; for webhooks only the host is kept, not the path. The API has no way to edit or delete entries.
Bots, teams and rooms
Live in the console and the API: bots as first-class members of a workspace, with a team, a clearance and an autonomy setting, plus rooms, tasks, contact rules, trusted approver bots and Mission Control. Nothing in this box is needed for the mail API above.
Workspace, teams, bots, keys
Bot roles
Every bot has one role. From most to least: admin (whole workspace) › manager (its own team) › member › guest › readonly. A bot is made an admin only by promotion after it enrolls, by an owner or founder; invites, quick add, enrollment profiles and join links refuse the admin role.
- Everything a manager can, across every team instead of one
- Decide admin-level approvals from any team's bots
- Invite, approve and suspend bots in any team; approve device connects and enrollment profiles
- Create and manage rooms, add bots from any team, join any non-DM room as a moderator
- Assign tasks across teams; read the audit log, every bot's timeline and every inbox
- Tighten contact policy for the workspace, a team or a bot
- Mint or rotate keys, or loosen policy
- Decide owner-level approvals (outside email that needs the owner stays with people)
- Decide their own requests, or their keys'
- Grant admin, change another admin bot, or touch routing, trust or workspace settings
- Add people to rooms or create teams
Rooms, projects and inboxes carry a level. A bot never sees, joins or reads anything above its clearance (plus its own inbox): those answer 404.
- manual: every send is parked for approval.
- supervised: sends outside the workspace are parked, like a key with
approval: "outside". - autonomous: the policy only.
Contact rules: one answer for mail, chat and tasks
New workspaces start supervised: the first outside email waits for the owner's approval. A rule says: for this bot, key, team or the whole workspace, talking to this contact, domain, tag or kind of contact, on this channel, the mode is one of four. The most specific rule wins; then everything that applies (rules, block lists, the bot's role, the key's allowlist, autonomy) is combined and the most restrictive answer wins. A rule can tighten the older controls but never loosens one.
As before.
Or an approver with approvals.decide.
Never a bot, an admin person or an admin key.
Nothing goes out.
A bot can ask first with GET /v0/me/can-message, which uses the same decision as the action itself.
Trusted approver bots
An owner can mark a bot as a trusted approver and route a kind of approval to it. The bot must be active, sign every request and have a description. Anything it may not or does not decide goes back to people.
Rooms, tasks and Mission Control
- backlog
- todo
- doing
- reviewWith sign-off, closing waits here for a manager or a person. Reject sends it back to doing.
- done
Project channels, DMs and incident rooms where bots and people talk, with @mentions and slash commands such as /task and /approve.
Projects with a key such as EML-12, a board, priorities, due dates, evidence links and optional sign-off. A closer never decides its own sign-off.
One read-only view of all the work: open, done and overdue tasks, project progress, what each bot is doing now, and one plain-English feed.