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

Delivery is liveSend and reply calls run every check, then deliver the message and save it with 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 key

The 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:

inbox_id: the inbox's email address

support-bot@bots.emlbot.com

In the path

support-bot%40bots.emlbot.com

message_id: the RFC Message-ID, angle brackets included

<CAF7x9Lq2@mail.yourco.com>

In the path

%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%3E

The 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": [ ... ]
}
  1. First pageNewest first, up to limit items.
    next_page_token: "WyIy…"
  2. Next pagePass the token back as it is.
    ?page_token=WyIy…
  3. RepeatEach answer carries the next token.
  4. 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/health

Response

{ "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/users

Response

{ "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_5d2e8f1a9c4b7036

Response

{ "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.eml

Response

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.png

Response

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.

200 sentSent

message_id, thread_id and the rate-limit headers. On a reply, add handled and remove unread on the message you answered.

202 pending_approvalParked

A recipient is outside the allowlist and the key uses approval: "outside". On a reply, add waiting-approval and keep unread.

403 forbiddenRefused

A recipient is outside the allowlist and the key has no approval gate. On a reply, keep unread. Nothing was sent.

429 rate_limitedWait

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
}
  1. Ask
    after=40
  2. HandleEvents 41 onward, oldest first.
  3. Save
    next_after=41
  4. 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.

pendingWaiting

The default filter. A mail approval expires 72 hours after it is created.

approvedSent

On the asking key's daily cap. See the lifecycle.

rejectedClosed

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" }
    }
  ]
}

Read next