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
On this page: Inboxes, Keys and scopes, Users and roles, Send policy, Approvals, Events, Webhooks, Threads and labels, Domains, Audit log.
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).
Usernames are 1 to 64 characters from a-z 0-9 . _ -, start with a letter or digit, and cannot contain ... Names are lowercased. An inbox can also have a display_name, which appears in the From line of mail it sends (Support Bot <support-bot@bots.emlbot.com>).
Creating inboxes safely
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
Mail is routed by the address it was delivered to, never by the To header, so a forged header cannot push mail into another inbox. Mail for an address with no inbox is refused at delivery, and so is any message over 25 MB. Each accepted message is stored with its raw source and attachments, joined to a thread, labelled received and unread, and announced as a message.received event. If the sending server delivers the same Message-ID twice, the second copy is dropped.
From, Reply-To and Message-ID are whatever the sender claims. 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
Deleting an inbox hides it from the API and refuses new mail to the address. The address stays reserved and cannot be created again.
Keys and scopes
Every API call is made with a key, sent as Authorization: Bearer emb_live_.... A key is emb_live_ followed by 32 random characters. It is shown in full once, in the response that creates it. EMLbot keeps only a SHA-256 hash and the first few characters (the prefix), so 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.
| Scope | Allows |
|---|---|
read | List and read inboxes, messages, raw source, attachments and threads; read the event log. |
label | Add and remove labels on messages. |
send | Send new messages and reply, always subject to the key's send policy. |
approve | List parked sends and approve or reject them. |
admin | Everything above, 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.
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 replacement, deploy it to the bot, then revoke the old key with DELETE /keys/{id}. Revocation takes effect on the next request. last_used_at on each key (updated at most once a minute) shows whether the old one is still in use.
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.
| Role | Can do |
|---|---|
owner | Every scope on every inbox, plus inviting, changing and disabling users. |
admin | Every scope on every inbox: inboxes, keys, webhooks, approvals. Cannot manage users. |
viewer | Read only. |
A console session lasts 12 hours from sign-in and is not extended by activity. Disabling a user ends all of their sessions at once. The last enabled owner cannot be demoted or disabled, so the account always has someone in charge. Console sign-in is only as strong as the Google account behind it, so turn on 2-step verification or a passkey there.
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:
allow_recipients: who the key may write to freely. Empty (the default) means no allowlist: anyone.daily_send_cap: how many sends per UTC day. Default 200, from 1 to 100,000.approval:"none"(the default) or"outside", which parks sends to anyone outside the allowlist for a person to decide.
Allowlist entries
| Entry | Matches |
|---|---|
dana@yourco.com | Exactly that address. |
@yourco.com | Any address at exactly that domain. Subdomains such as eu.yourco.com need their own entry. |
team | Any live inbox hosted on EMLbot, so your bots can always write to each other. |
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:
- Collect recipients. Everyone in
to,ccandbcc, including addresses added byreply_all. - Check the allowlist. If
allow_recipientsis not empty and any recipient matches no entry, then:- with
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. - otherwise the call answers
403 forbiddennaming the first address that was not allowed, and nothing is sent.
- with
- Reserve one send from the daily cap. The cap counts sends (one call), not recipients, per UTC day. If it is already spent, the call answers
429 rate_limitedwith aRetry-Afterheader. - Send and store. The 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. |
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.
- Approve performs the send as it was requested and 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.
- Reject drops it. Nothing is sent.
Rules that keep the gate honest:
- No self-approval. A key can never approve a send it parked, even if it holds both
sendandapprove. Use a different key or a person. - One decision only. An approval is claimed before it is sent, so two clicks cannot send twice. Deciding one that is no longer pending answers
409 conflict. - Still valid at send time. If the inbox has been deleted or the requesting key revoked since, approving answers
409 conflictand the item stays pending.
Pending approvals do not expire. 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:
| Type | When | Payload carries |
|---|---|---|
message.received | Mail arrived in an inbox. | message (full) and thread |
message.sent | A send or reply went out, including approved ones. | message (full) and send (status and recipients) |
message.approval_requested | A send was parked for approval. | approval |
Every payload also has type: "event", event_type and event_id.
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:
- Start with
after=0, or with the lastnext_afteryou saved. - Handle the events in the batch, oldest first.
- Save
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
- Take your secret, drop the
whsec_prefix and base64-decode the rest. Those bytes are the HMAC key. - Build the signed content:
webhook-id, a dot,webhook-timestamp, a dot, then the raw request body exactly as received:${id}.${timestamp}.${body}. - Compute HMAC-SHA256 over it and base64-encode the result.
- The header is
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, answers quickly, then dedupes:
import express from "express";
import { verifyEmlbotWebhook } from "./verify.js";
const app = express();
const seen = new Set(); // use your database in production
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();
}
res.status(204).end(); // any 2xx within 10 seconds counts as delivered
const event = JSON.parse(raw);
if (seen.has(event.event_id)) return; // a retry of something already handled
seen.add(event.event_id);
// handle event.event_type here
});
app.listen(3000);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:
| Retry | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
|---|---|---|---|---|---|---|---|---|
| Wait | 5 s | 30 s | 5 min | 30 min | 2 h | 5 h | 10 h | 10 h |
After the eighth retry the event is no longer pushed, but it stays in the event log for you to read.
Dedupe on event_id. webhook-id is the event's event_id and never changes between attempts. When one endpoint subscribed to an event fails, the retry goes to every webhook subscribed to that event, so even an endpoint that answered 2xx can see the same event again. Store the ids you have handled and skip repeats.
Disabled webhooks
Each webhook counts consecutive failed deliveries in failures, which resets to 0 on the next success. After 50 in a row it is disabled (enabled: false) and receives nothing more. There is no call to re-enable one: fix the endpoint, create a new webhook, delete the old one, then read the event log to catch up on what you missed.
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:
receivedandunreadon every inbound message.senton every message sent from an inbox.
Nothing removes unread automatically, so it works as a to-do flag: list with labels=unread, handle each message, then PATCH it with remove_labels: ["unread"]. 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.
Domains
Every inbox today lives on bots.emlbot.com. Leave domain out when creating an inbox and that is what you get. Sending from and receiving at your own domain is coming soon, with a domains API to manage it. Until then, bots.emlbot.com is the only domain that receives mail.
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:
| Area | Actions |
|---|---|
| Keys | key.create, key.revoke |
| Inboxes | inbox.create, inbox.delete |
| Webhooks | webhook.create, webhook.delete, webhook.rotate |
| Approvals | approval.approve, approval.reject |
| Console sign-in | auth.login, auth.logout |
| Users | user.invite, user.update, user.disable |
Each entry has the time, the actor (actor_type is key or user, with the key or user id), the client IP address, the action, its target, and a small detail object. 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.