Docs
Quickstart: every endpoint.
One curl call, one line of explanation and a trimmed example response for each endpoint in the API, grouped by resource. Copy, swap in your key, run.
OverviewQuickstartConceptsErrors and limits
send_status: "sent". Outside sends still follow the key's approval rules. Everything on this page is live.Before you start
Base URL and auth
Every endpoint below lives under https://api.emlbot.com/v0 and speaks JSON. Authenticate with your key as a bearer token. The examples use two environment variables: $EMLBOT_ADMIN_KEY for a key with the admin scope (setup work) and $EMLBOT_KEY for a bot's own key. Each section says which scope it needs.
export EMLBOT_ADMIN_KEY="emb_live_..." # admin scope, no inbox list
export EMLBOT_KEY="emb_live_..." # the bot's keyThe API is called server to server. It sends no CORS headers, so a browser page on another site cannot call it.
URL-encoding ids in paths
Two ids go into paths and both contain characters that must be URL-encoded:
support-bot@bots.emlbot.com
support-bot%40bots.emlbot.com
<CAF7x9Lq2@mail.yourco.com>
%3CCAF7x9Lq2%40mail.yourco.com%3E
The API also finds the message if you leave the brackets off, but always encode the rest.
In code, use your language's component encoder (encodeURIComponent in JavaScript, urllib.parse.quote(s, safe="") in Python). In a shell, jq does it:
export MID=$(jq -rn --arg v "<CAF7x9Lq2@mail.yourco.com>" '$v|@uri')
echo "$MID" # %3CCAF7x9Lq2%40mail.yourco.com%3EThe examples below use $MID wherever a message id goes in the path.
Pagination
The inbox, message, thread, approval and audit lists all answer in the same shape, newest first:
{
"count": 50,
"limit": 50,
"next_page_token": "WyIyMDI2LTEwLTIyVDE1OjU4OjAzLjIwMVoiLCI5YjJjIl0",
"messages": [ ... ]
}- First pageNewest first, up to
limititems.next_page_token: "WyIy…" - Next pagePass the token back as it is.?page_token=WyIy…
- RepeatEach answer carries the next token.
- DoneYou have everything.next_page_token: null
limit defaults to 50 and is capped at 500. To get the next page, pass the token back as page_token. When next_page_token is null you have everything. Tokens are opaque: store and send them as they are. The item array is named after the resource (inboxes, messages, threads, approvals, entries). Keys, webhooks and users come back in full as {count, <items>} without paging, and events use their own cursor (see Events).
Health
GET/health
Tells you the API is up. It sits at the root of the host, not under /v0, and needs no key.
Request
curl https://api.emlbot.com/healthResponse
{ "ok": true, "service": "emlbot-api", "time": "2026-10-22T16:00:00.000Z" }Me
GET/me
Says who the key belongs to and what it can do. Any valid key may call it, so it is the quickest way to check a key works. admin is true only for a key with the admin scope and no inbox list.
Request
curl https://api.emlbot.com/v0/me -H "Authorization: Bearer $EMLBOT_KEY"Response
{
"type": "key",
"email": null,
"name": "support-bot",
"role": null,
"scopes": ["read", "label", "send"],
"key_id": "key_8c1f2a9d4b7e3605",
"inbox_ids": ["support-bot@bots.emlbot.com"],
"admin": false
}Users
Users are the people who sign in to the console with Google. Managing them is reserved for the account owner: these calls are made from the owner's signed-in console session (the Users screen makes them for you), and an API key, even an admin key, gets 403 forbidden. They are listed here so you know exactly what the console does. Writes from the console carry the X-Emlbot-CSRF token that GET /me returns to a signed-in person.
POST/usersinvite
Invites a Google account as owner admin, member or viewer. Nobody can sign in until they are invited.
Request
POST /v0/users
Content-Type: application/json
{"email": "sam@yourco.com", "role": "admin"}Response
{
"id": "usr_5d2e8f1a9c4b7036",
"email": "sam@yourco.com",
"name": null,
"role": "admin",
"created_at": "2026-10-22T16:01:12.044Z",
"last_login_at": null,
"disabled_at": null
}GET/userslist
Every user, oldest first, as {count, users}.
Request
GET /v0/usersResponse
{ "count": 2, "users": [ { "id": "usr_5d2e8f1a9c4b7036", "email": "sam@yourco.com", "role": "admin", ... } ] }PATCH/users/{id}update
Changes role, disabled or both. Disabling signs the person out everywhere. The last enabled owner cannot be demoted or disabled (409 conflict).
Request
PATCH /v0/users/usr_5d2e8f1a9c4b7036
Content-Type: application/json
{"role": "viewer"}Response
{ "id": "usr_5d2e8f1a9c4b7036", "email": "sam@yourco.com", "role": "viewer", "disabled_at": null, ... }DELETE/users/{id}disable
Disables the user and ends their sessions. The record stays so the audit log still points at someone; PATCH with {"disabled": false} brings them back.
Request
DELETE /v0/users/usr_5d2e8f1a9c4b7036Response
{ "id": "usr_5d2e8f1a9c4b7036", "email": "sam@yourco.com", "disabled_at": "2026-10-22T16:20:45.310Z", ... }API keys
All key calls need the admin scope on a key with no inbox list.
POST/keyscreate
Creates a key with its scopes and send policy. name and scopes are required. inbox_ids limits the key to those inboxes; leave it out for all inboxes. daily_send_cap defaults to 200 and approval to "none". The full key is in api_key and is never shown again.
Request
curl -X POST https://api.emlbot.com/v0/keys \
-H "Authorization: Bearer $EMLBOT_ADMIN_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "support-bot",
"scopes": ["read", "label", "send"],
"inbox_ids": ["support-bot@bots.emlbot.com"],
"allow_recipients": ["team", "@yourco.com"],
"daily_send_cap": 100,
"approval": "outside"
}'Response
{
"key_id": "key_8c1f2a9d4b7e3605",
"name": "support-bot",
"prefix": "emb_live_7Qx2",
"scopes": ["read", "label", "send"],
"inbox_ids": ["support-bot@bots.emlbot.com"],
"allow_recipients": ["team", "@yourco.com"],
"daily_send_cap": 100,
"approval": "outside",
"created_at": "2026-10-22T16:02:41.118Z",
"last_used_at": null,
"revoked_at": null,
"api_key": "emb_live_7Qx2..."
}GET/keyslist
Every key, newest first, revoked ones included. Only the prefix is shown, never the key.
Request
curl https://api.emlbot.com/v0/keys -H "Authorization: Bearer $EMLBOT_ADMIN_KEY"Response
{
"count": 2,
"keys": [
{ "key_id": "key_8c1f2a9d4b7e3605", "name": "support-bot", "prefix": "emb_live_7Qx2", "last_used_at": "2026-10-22T16:40:02.551Z", "revoked_at": null, ... }
]
}DELETE/keys/{id}revoke
Revokes the key. It stops working on the next request. The answer is the key record with revoked_at set. To rotate a bot's key, create the new one, switch the bot over, then revoke the old one.
Request
curl -X DELETE https://api.emlbot.com/v0/keys/key_8c1f2a9d4b7e3605 \
-H "Authorization: Bearer $EMLBOT_ADMIN_KEY"Response
{ "key_id": "key_8c1f2a9d4b7e3605", "name": "support-bot", "revoked_at": "2026-10-22T17:05:19.902Z", ... }Inboxes
Creating and deleting need admin; reading needs read. A key with an inbox list only ever sees those inboxes.
POST/inboxescreate
Creates username@bots.emlbot.com. Every field is optional: leave out username and you get a random bot-... name. A client_id makes the call idempotent: if a live inbox already has that client_id, you get it back instead of a new one, so retries are safe.
Request
curl -X POST https://api.emlbot.com/v0/inboxes \
-H "Authorization: Bearer $EMLBOT_ADMIN_KEY" \
-H "Content-Type: application/json" \
-d '{"username": "support-bot", "display_name": "Support Bot", "client_id": "support-bot-v1"}'Response
{
"inbox_id": "support-bot@bots.emlbot.com",
"email": "support-bot@bots.emlbot.com",
"display_name": "Support Bot",
"client_id": "support-bot-v1",
"updated_at": "2026-10-22T16:03:10.517Z",
"created_at": "2026-10-22T16:03:10.517Z"
}GET/inboxeslist
The inboxes this key can see, newest first, paged.
Request
curl "https://api.emlbot.com/v0/inboxes?limit=20" -H "Authorization: Bearer $EMLBOT_KEY"Response
{
"count": 1,
"limit": 20,
"next_page_token": null,
"inboxes": [ { "inbox_id": "support-bot@bots.emlbot.com", "display_name": "Support Bot", ... } ]
}GET/inboxes/{inbox_id}get
One inbox by address. An inbox outside the key's list answers 404, exactly like one that does not exist.
Request
curl https://api.emlbot.com/v0/inboxes/support-bot%40bots.emlbot.com -H "Authorization: Bearer $EMLBOT_KEY"Response
{ "inbox_id": "support-bot@bots.emlbot.com", "email": "support-bot@bots.emlbot.com", "display_name": "Support Bot", ... }DELETE/inboxes/{inbox_id}delete
Deletes the inbox. New mail to the address is refused and the inbox disappears from the API. The address is not freed for reuse: creating it again answers 409 already_exists.
Request
curl -X DELETE https://api.emlbot.com/v0/inboxes/support-bot%40bots.emlbot.com \
-H "Authorization: Bearer $EMLBOT_ADMIN_KEY"Response
{ "inbox_id": "support-bot@bots.emlbot.com", "deleted": true }Messages
Reading needs read; changing labels needs label. Inbound mail arrives labelled received and unread; mail you send is labelled sent.
GET/inboxes/{inbox_id}/messageslist
Messages in the inbox, newest first, paged. Filter with labels: repeat it or comma-separate, and only messages carrying every label you name come back. List items carry a preview (first 200 characters) rather than the full body.
Request
curl "https://api.emlbot.com/v0/inboxes/support-bot%40bots.emlbot.com/messages?labels=unread&limit=20" \
-H "Authorization: Bearer $EMLBOT_KEY"
# next page
curl "https://api.emlbot.com/v0/inboxes/support-bot%40bots.emlbot.com/messages?labels=unread&limit=20&page_token=$NEXT_PAGE_TOKEN" \
-H "Authorization: Bearer $EMLBOT_KEY"Response
{
"count": 1,
"limit": 20,
"next_page_token": null,
"messages": [
{
"inbox_id": "support-bot@bots.emlbot.com",
"thread_id": "6f1d2c3b-8a4e-4f5a-9b7c-1d2e3f4a5b6c",
"message_id": "<CAF7x9Lq2@mail.yourco.com>",
"labels": ["received", "unread"],
"timestamp": "2026-10-22T15:58:03.201Z",
"from": "Dana Lee <dana@yourco.com>",
"to": ["support-bot@bots.emlbot.com"],
"cc": [],
"subject": "Invoice question",
"preview": "Hi, could you resend the October invoice? The PDF in my copy is blank.",
"attachments": [
{ "attachment_id": "att_4b9e0c7d2a1f48e6b3c5d7f9a1b2c3d4", "filename": "screenshot.png", "size": 48213, "content_type": "image/png" }
],
"size": 71840
}
]
}GET/inboxes/{inbox_id}/messages/{message_id}get
The full message: everything in the list item plus text, html, bcc, reply_to, in_reply_to, references, send_status (for mail you sent) and extracted_text, the new part of a reply with the quoted history cut off (best effort). extracted_text is usually what a bot wants to read.
Request
curl "https://api.emlbot.com/v0/inboxes/support-bot%40bots.emlbot.com/messages/$MID" \
-H "Authorization: Bearer $EMLBOT_KEY"Response
{
"message_id": "<CAF7x9Lq2@mail.yourco.com>",
"thread_id": "6f1d2c3b-8a4e-4f5a-9b7c-1d2e3f4a5b6c",
"from": "Dana Lee <dana@yourco.com>",
"subject": "Invoice question",
"text": "Hi, could you resend the October invoice? The PDF in my copy is blank.\n\nThanks,\nDana",
"html": null,
"extracted_text": "Hi, could you resend the October invoice? The PDF in my copy is blank.\n\nThanks,\nDana",
"reply_to": null,
"in_reply_to": null,
"references": [],
"send_status": null,
...
}GET/inboxes/{inbox_id}/messages/{message_id}/rawraw source
The original message exactly as received (or as built, for mail you sent), as message/rfc822. Use it when you need every header.
Request
curl "https://api.emlbot.com/v0/inboxes/support-bot%40bots.emlbot.com/messages/$MID/raw" \
-H "Authorization: Bearer $EMLBOT_KEY" -o message.emlResponse
Content-Type: message/rfc822
Received: from mail.yourco.com ...
From: Dana Lee <dana@yourco.com>
Subject: Invoice question
...GET/inboxes/{inbox_id}/messages/{message_id}/attachments/{attachment_id}attachment
Downloads one attachment by the attachment_id from the message. It always comes back as a download (Content-Disposition: attachment) with a cleaned-up file name and type.
Request
curl "https://api.emlbot.com/v0/inboxes/support-bot%40bots.emlbot.com/messages/$MID/attachments/att_4b9e0c7d2a1f48e6b3c5d7f9a1b2c3d4" \
-H "Authorization: Bearer $EMLBOT_KEY" -o screenshot.pngResponse
Content-Type: image/png
Content-Disposition: attachment; filename="screenshot.png"
(file bytes)PATCH/inboxes/{inbox_id}/messages/{message_id}labels
Adds and removes labels in one call, up to 20 of each. Labels are 1 to 64 characters from a-z 0-9 . _ : -. The answer is the full message with its new labels. The thread's labels update too. Add handled and remove unread only after a send or reply answered 200. The other answers are under Sending.
Request
curl -X PATCH "https://api.emlbot.com/v0/inboxes/support-bot%40bots.emlbot.com/messages/$MID" \
-H "Authorization: Bearer $EMLBOT_KEY" \
-H "Content-Type: application/json" \
-d '{"add_labels": ["handled", "billing"], "remove_labels": ["unread"]}'Response
{ "message_id": "<CAF7x9Lq2@mail.yourco.com>", "labels": ["received", "handled", "billing"], ... }Sending
Both calls need the send scope and run through the key's send policy. A successful send answers 200 with the new message's id and thread, plus X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset (Unix seconds of the next midnight UTC) for the key's daily cap. If the policy parks the send for approval, the answer is 202 instead. New workspaces start supervised: the first outside email waits for the owner's approval.
message_id, thread_id and the rate-limit headers. On a reply, add handled and remove unread on the message you answered.
A recipient is outside the allowlist and the key uses approval: "outside". On a reply, add waiting-approval and keep unread.
A recipient is outside the allowlist and the key has no approval gate. On a reply, keep unread. Nothing was sent.
On a reply, keep unread. Retry after the Retry-After header. A daily send cap stays spent until midnight UTC; a request budget clears when that header says so.
If the call times out, resend it with the same Idempotency-Key. Keys are kept 24 hours. A repeat with that key returns the first response, so you can label from the real status instead of guessing.
# 200 sent: the reply went out
curl -X PATCH "https://api.emlbot.com/v0/inboxes/support-bot%40bots.emlbot.com/messages/$MID" \
-H "Authorization: Bearer $EMLBOT_KEY" \
-H "Content-Type: application/json" \
-d '{"add_labels": ["handled"], "remove_labels": ["unread"]}'
# 202 pending_approval: parked, still unread
curl -X PATCH "https://api.emlbot.com/v0/inboxes/support-bot%40bots.emlbot.com/messages/$MID" \
-H "Authorization: Bearer $EMLBOT_KEY" \
-H "Content-Type: application/json" \
-d '{"add_labels": ["waiting-approval"]}'
# 403 or 429: do not PATCH. unread stays.
# On 429, wait for the Retry-After header, then retry the send.
# On timeout, repeat the send with the same Idempotency-Key.POST/inboxes/{inbox_id}/messages/sendsend
Sends a new message from the inbox and starts a new thread. to and one of text or html are required. Optional: cc, bcc, reply_to, subject, labels, headers (custom headers as an object) and attachments, each {filename, content} with base64 content and an optional content_type. Addresses may be plain or Name <address>.
Request
curl -i -X POST https://api.emlbot.com/v0/inboxes/support-bot%40bots.emlbot.com/messages/send \
-H "Authorization: Bearer $EMLBOT_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: invoice-oct-dana" \
-d '{
"to": ["Dana Lee <dana@yourco.com>"],
"subject": "Your October invoice",
"text": "Hi Dana, here it is again. Let me know if it opens.",
"labels": ["billing"],
"attachments": [
{"filename": "invoice-oct.pdf", "content_type": "application/pdf", "content": "JVBERi0xLjcK..."}
]
}'Response 200
HTTP/2 200
x-ratelimit-limit: 100
x-ratelimit-remaining: 99
x-ratelimit-reset: 1792713600
{
"message_id": "<0b6e2f1c-5d7a-4c3e-9f1b-8a2d4e6c7b90@bots.emlbot.com>",
"thread_id": "2a9c4e1f-7b3d-4f8a-b6c2-5e1d9a7f3c40"
}If a recipient is outside the key's allowlist and the key uses approval: "outside":
Response 202 pending_approval
HTTP/2 202
{ "status": "pending_approval", "approval_id": "apr_7a3f1e9c2b5d4a6f8e0c1b3d5f7a9c2e" }POST/inboxes/{inbox_id}/messages/{message_id}/replyreply
Replies in the same thread with In-Reply-To and References set. You only need text or html: when you leave out to, a reply to received mail goes to the sender's Reply-To address if there is one, otherwise to the sender. The subject is always the original's with Re: in front; a subject you pass is ignored.
Request
curl -X POST "https://api.emlbot.com/v0/inboxes/support-bot%40bots.emlbot.com/messages/$MID/reply" \
-H "Authorization: Bearer $EMLBOT_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: reply-CAF7x9Lq2" \
-d '{"text": "Thanks Dana, a fresh copy is on its way."}'Response
{
"message_id": "<5c8d1a2e-3f4b-4e6a-8c9d-0b1a2c3d4e5f@bots.emlbot.com>",
"thread_id": "6f1d2c3b-8a4e-4f5a-9b7c-1d2e3f4a5b6c"
}Reply all. Add "reply_all": true to copy everyone on the original To and Cc lines, minus this inbox and whoever is already in to. It applies to replies to received mail when you do not pass your own cc. Every added address still goes through the send policy.
curl -X POST "https://api.emlbot.com/v0/inboxes/support-bot%40bots.emlbot.com/messages/$MID/reply" \
-H "Authorization: Bearer $EMLBOT_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: reply-all-CAF7x9Lq2" \
-d '{"text": "Looping everyone in: the invoice is fixed.", "reply_all": true}'Threads
Both calls need read. A thread groups a message with its replies inside one inbox.
GET/inboxes/{inbox_id}/threadslist
Threads in the inbox, most recently active first, paged. Accepts the same labels filter as messages; a thread's labels are all the labels on its messages.
Request
curl "https://api.emlbot.com/v0/inboxes/support-bot%40bots.emlbot.com/threads?limit=20" \
-H "Authorization: Bearer $EMLBOT_KEY"Response
{
"count": 1,
"limit": 20,
"next_page_token": null,
"threads": [
{
"inbox_id": "support-bot@bots.emlbot.com",
"thread_id": "6f1d2c3b-8a4e-4f5a-9b7c-1d2e3f4a5b6c",
"labels": ["received", "handled", "billing", "sent"],
"timestamp": "2026-10-22T16:12:30.004Z",
"senders": ["Dana Lee <dana@yourco.com>", "Support Bot <support-bot@bots.emlbot.com>"],
"subject": "Invoice question",
"preview": "Thanks Dana, a fresh copy is on its way.",
"last_message_id": "<5c8d1a2e-3f4b-4e6a-8c9d-0b1a2c3d4e5f@bots.emlbot.com>",
"message_count": 2,
...
}
]
}GET/inboxes/{inbox_id}/threads/{thread_id}get
One thread with all its messages in full, oldest first. The thread id has no special characters, so it needs no encoding.
Request
curl https://api.emlbot.com/v0/inboxes/support-bot%40bots.emlbot.com/threads/6f1d2c3b-8a4e-4f5a-9b7c-1d2e3f4a5b6c \
-H "Authorization: Bearer $EMLBOT_KEY"Response
{
"thread_id": "6f1d2c3b-8a4e-4f5a-9b7c-1d2e3f4a5b6c",
"subject": "Invoice question",
"message_count": 2,
"messages": [
{ "message_id": "<CAF7x9Lq2@mail.yourco.com>", "text": "Hi, could you resend...", ... },
{ "message_id": "<5c8d1a2e-3f4b-4e6a-8c9d-0b1a2c3d4e5f@bots.emlbot.com>", "text": "Thanks Dana, ...", ... }
],
...
}Webhooks
All webhook calls need admin. How deliveries are signed, verified and retried is on the Concepts page.
POST/webhookscreate
Registers an HTTPS endpoint. event_types defaults to all three types; inbox_ids defaults to every inbox. The URL must pass the destination rules. The secret appears in this response and in rotate-secret, nowhere else.
Request
curl -X POST https://api.emlbot.com/v0/webhooks \
-H "Authorization: Bearer $EMLBOT_ADMIN_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://hooks.yourco.com/emlbot", "event_types": ["message.received"], "inbox_ids": ["support-bot@bots.emlbot.com"]}'Response
{
"webhook_id": "wh_2c7d9e1f3a5b4c6d8e0f1a2b3c4d5e6f",
"url": "https://hooks.yourco.com/emlbot",
"event_types": ["message.received"],
"inbox_ids": ["support-bot@bots.emlbot.com"],
"enabled": true,
"failures": 0,
"created_at": "2026-10-22T16:05:52.660Z",
"updated_at": "2026-10-22T16:05:52.660Z",
"secret": "whsec_..."
}GET/webhookslist
Every webhook, newest first, without secrets. failures counts consecutive failed deliveries; enabled turns false after 50 in a row. POST /webhooks/{id}/enable turns it back on after a health check. The concepts page has the request and the replay preview.
Request
curl https://api.emlbot.com/v0/webhooks -H "Authorization: Bearer $EMLBOT_ADMIN_KEY"Response
{ "count": 1, "webhooks": [ { "webhook_id": "wh_2c7d9e1f3a5b4c6d8e0f1a2b3c4d5e6f", "enabled": true, "failures": 0, ... } ] }GET/webhooks/{id}get
One webhook, without its secret.
Request
curl https://api.emlbot.com/v0/webhooks/wh_2c7d9e1f3a5b4c6d8e0f1a2b3c4d5e6f -H "Authorization: Bearer $EMLBOT_ADMIN_KEY"Response
{ "webhook_id": "wh_2c7d9e1f3a5b4c6d8e0f1a2b3c4d5e6f", "url": "https://hooks.yourco.com/emlbot", "enabled": true, ... }DELETE/webhooks/{id}delete
Removes the webhook. Deliveries stop; the events stay in the event log.
Request
curl -X DELETE https://api.emlbot.com/v0/webhooks/wh_2c7d9e1f3a5b4c6d8e0f1a2b3c4d5e6f -H "Authorization: Bearer $EMLBOT_ADMIN_KEY"Response
{ "webhook_id": "wh_2c7d9e1f3a5b4c6d8e0f1a2b3c4d5e6f", "deleted": true }POST/webhooks/{id}/rotate-secretrotate the secret
Issues a new signing secret. Deliveries are signed with it from now on and the old one stops verifying, so update your receiver promptly (or have it accept both secrets for a short while).
Request
curl -X POST https://api.emlbot.com/v0/webhooks/wh_2c7d9e1f3a5b4c6d8e0f1a2b3c4d5e6f/rotate-secret \
-H "Authorization: Bearer $EMLBOT_ADMIN_KEY"Response
{ "webhook_id": "wh_2c7d9e1f3a5b4c6d8e0f1a2b3c4d5e6f", "updated_at": "2026-10-22T17:30:00.120Z", "secret": "whsec_...", ... }POST/webhooks/{id}/enableturn back on
Same auth as create. 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 message is "Your endpoint answered <status> (or timed out). Fix it, then call enable again." The body also includes status.
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.
Request
curl -X POST https://api.emlbot.com/v0/webhooks/wh_2c7d9e1f3a5b4c6d8e0f1a2b3c4d5e6f/enable \
-H "Authorization: Bearer $EMLBOT_ADMIN_KEY"Response
{
"webhook": {
"webhook_id": "wh_2c7d9e1f3a5b4c6d8e0f1a2b3c4d5e6f",
"enabled": true,
"failures": 0
},
"replay": {
"failed_events": 3,
"hint": "GET /v0/events?after=40 to catch up"
}
}Events
GET/eventsread the event log
Needs read. Returns events oldest first after the sequence number you pass in after (start at 0). Save next_after once you have handled the batch and send it next time: nothing is skipped and nothing repeats. limit defaults to 100 and is capped at 1000. A key with an inbox list only sees events for its inboxes. Each event's payload is the same JSON a webhook receives.
Request
curl "https://api.emlbot.com/v0/events?after=40&limit=100" -H "Authorization: Bearer $EMLBOT_KEY"Response
{
"events": [
{
"seq": 41,
"event_id": "evt_9b2c4d6e8f0a1b3c5d7e9f1a2b4c6d8e",
"event_type": "message.received",
"inbox_id": "support-bot@bots.emlbot.com",
"created_at": "2026-10-22T15:58:03.377Z",
"payload": {
"type": "event",
"event_type": "message.received",
"event_id": "evt_9b2c4d6e8f0a1b3c5d7e9f1a2b4c6d8e",
"message": { "message_id": "<CAF7x9Lq2@mail.yourco.com>", ... },
"thread": { "thread_id": "6f1d2c3b-8a4e-4f5a-9b7c-1d2e3f4a5b6c", ... }
}
}
],
"next_after": 41
}- Askafter=40
- HandleEvents 41 onward, oldest first.
- Savenext_after=41
- Ask againWith what you saved. Nothing is skipped, nothing repeats.
When there is nothing new, events is empty and next_after equals the after you sent.
Approvals
All approval calls need the approve scope (an admin key has it). Owners and admins can also decide in the console. The bot that raised a request cannot approve it, and a key cannot approve a send it parked itself, even if it holds both send and approve.
GET/approvalslist
Parked sends, newest first, paged. status is pending (the default), approved or rejected. Each item shows who the send is to, the subject, a preview and attachment names, so a person can decide without opening anything else.
The default filter. A mail approval expires 72 hours after it is created.
On the asking key's daily cap. See the lifecycle.
A person rejected it, it expired (outcome expired), or a newer mail approval for the same inbox and the same draft or thread superseded it (outcome superseded, superseded_by set).
Request
curl "https://api.emlbot.com/v0/approvals?status=pending" -H "Authorization: Bearer $EMLBOT_ADMIN_KEY"Response
{
"count": 1,
"limit": 50,
"next_page_token": null,
"approvals": [
{
"approval_id": "apr_7a3f1e9c2b5d4a6f8e0c1b3d5f7a9c2e",
"inbox_id": "support-bot@bots.emlbot.com",
"key_id": "key_8c1f2a9d4b7e3605",
"status": "pending",
"to": ["jordan@partnerco.com"],
"cc": [],
"bcc": [],
"subject": "Re: Contract renewal",
"preview": "Hi Jordan, we can confirm the renewal terms you sent over.",
"in_reply_to": "<D41b7Qz@partnerco.com>",
"attachments": [],
"created_at": "2026-10-22T16:31:07.812Z",
"decided_at": null,
"decided_by": null
}
]
}POST/approvals/{id}/approveapprove
Sends the stored request and answers with its ids. Policy is checked again first. The send counts against the daily cap of the key that asked for it, so this can answer 429 if that cap is spent; the approval then stays pending. If the content no longer matches the sha256 taken at creation, over to, cc, bcc, subject, text, html and the attachment ids, the call answers 409 approval_stale.
Request
curl -X POST https://api.emlbot.com/v0/approvals/apr_7a3f1e9c2b5d4a6f8e0c1b3d5f7a9c2e/approve \
-H "Authorization: Bearer $EMLBOT_ADMIN_KEY"Response
{
"approval_id": "apr_7a3f1e9c2b5d4a6f8e0c1b3d5f7a9c2e",
"status": "approved",
"message_id": "<9d0e1f2a-3b4c-4d5e-8f6a-7b8c9d0e1f2a@bots.emlbot.com>",
"thread_id": "3e5f7a9b-1c2d-4e3f-a5b6-c7d8e9f0a1b2"
}POST/approvals/{id}/rejectreject
Drops the parked send. Nothing is sent.
Request
curl -X POST https://api.emlbot.com/v0/approvals/apr_7a3f1e9c2b5d4a6f8e0c1b3d5f7a9c2e/reject \
-H "Authorization: Bearer $EMLBOT_ADMIN_KEY"Response
{ "approval_id": "apr_7a3f1e9c2b5d4a6f8e0c1b3d5f7a9c2e", "status": "rejected" }Deciding an approval that is no longer pending answers 409 conflict, so a double click cannot send twice. A mail approval past 72 hours answers 409 approval_expired: "This approval expired after 72 hours. Ask the bot to send again." Its draft, if still pending_approval, is marked failed with send_error The approval expired. How long a mail approval lasts.
Audit log
GET/auditread the audit log
Needs admin. Security-relevant changes, newest first, paged. Filter by action, actor_id or target. The list of actions is on the Concepts page.
Request
curl "https://api.emlbot.com/v0/audit?action=key.create&limit=20" -H "Authorization: Bearer $EMLBOT_ADMIN_KEY"Response
{
"count": 1,
"limit": 20,
"next_page_token": null,
"entries": [
{
"id": "aud_1f3e5d7c9b0a2c4e6f8a0b1c3d5e7f9a",
"at": "2026-10-22T16:02:41.130Z",
"actor_type": "user",
"actor_id": "usr_0a1b2c3d4e5f6071",
"ip": "203.0.113.7",
"action": "key.create",
"target": "key_8c1f2a9d4b7e3605",
"detail": { "name": "support-bot", "scopes": ["read", "label", "send"], "daily_send_cap": 100, "approval": "outside" }
}
]
}