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

Test modeOutbound sending runs in test mode while delivery is being connected. Send and reply calls run every check and save the message with send_status: "logged", but nothing is delivered yet. Everything else 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 is the inbox's email address. support-bot@bots.emlbot.com becomes support-bot%40bots.emlbot.com.
  • message_id is the RFC Message-ID, angle brackets included. <CAF7x9Lq2@mail.yourco.com> becomes %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": [ ... ]
}

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.

curl https://api.emlbot.com/health
{ "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.

curl https://api.emlbot.com/v0/me -H "Authorization: Bearer $EMLBOT_KEY"
{
  "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 /users: invite

Invites a Google account as owner, admin or viewer. Nobody can sign in until they are invited.

POST /v0/users
Content-Type: application/json

{"email": "sam@yourco.com", "role": "admin"}
{
  "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 /users: list

Every user, oldest first, as {count, users}.

GET /v0/users
{ "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).

PATCH /v0/users/usr_5d2e8f1a9c4b7036
Content-Type: application/json

{"role": "viewer"}
{ "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.

DELETE /v0/users/usr_5d2e8f1a9c4b7036
{ "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 /keys: create

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.

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"
  }'
{
  "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 /keys: list

Every key, newest first, revoked ones included. Only the prefix is shown, never the key.

curl https://api.emlbot.com/v0/keys -H "Authorization: Bearer $EMLBOT_ADMIN_KEY"
{
  "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.

curl -X DELETE https://api.emlbot.com/v0/keys/key_8c1f2a9d4b7e3605 \
  -H "Authorization: Bearer $EMLBOT_ADMIN_KEY"
{ "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 /inboxes: create

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.

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"}'
{
  "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 /inboxes: list

The inboxes this key can see, newest first, paged.

curl "https://api.emlbot.com/v0/inboxes?limit=20" -H "Authorization: Bearer $EMLBOT_KEY"
{
  "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.

curl https://api.emlbot.com/v0/inboxes/support-bot%40bots.emlbot.com -H "Authorization: Bearer $EMLBOT_KEY"
{ "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.

curl -X DELETE https://api.emlbot.com/v0/inboxes/support-bot%40bots.emlbot.com \
  -H "Authorization: Bearer $EMLBOT_ADMIN_KEY"
{ "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}/messages: list

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.

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

curl "https://api.emlbot.com/v0/inboxes/support-bot%40bots.emlbot.com/messages/$MID" \
  -H "Authorization: Bearer $EMLBOT_KEY"
{
  "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}/raw: raw source

The original message exactly as received (or as built, for mail you sent), as message/rfc822. Use it when you need every header.

curl "https://api.emlbot.com/v0/inboxes/support-bot%40bots.emlbot.com/messages/$MID/raw" \
  -H "Authorization: Bearer $EMLBOT_KEY" -o message.eml
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.

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

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"]}'
{ "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.

POST /inboxes/{inbox_id}/messages/send: send

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

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" \
  -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..."}
    ]
  }'
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":

HTTP/2 202

{ "status": "pending_approval", "approval_id": "apr_7a3f1e9c2b5d4a6f8e0c1b3d5f7a9c2e" }

POST /inboxes/{inbox_id}/messages/{message_id}/reply: reply

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.

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" \
  -d '{"text": "Thanks Dana, a fresh copy is on its way."}'
{
  "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" \
  -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}/threads: list

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.

curl "https://api.emlbot.com/v0/inboxes/support-bot%40bots.emlbot.com/threads?limit=20" \
  -H "Authorization: Bearer $EMLBOT_KEY"
{
  "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.

curl https://api.emlbot.com/v0/inboxes/support-bot%40bots.emlbot.com/threads/6f1d2c3b-8a4e-4f5a-9b7c-1d2e3f4a5b6c \
  -H "Authorization: Bearer $EMLBOT_KEY"
{
  "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 /webhooks: create

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.

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"]}'
{
  "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 /webhooks: list

Every webhook, newest first, without secrets. failures counts consecutive failed deliveries; enabled turns false after 50 in a row.

curl https://api.emlbot.com/v0/webhooks -H "Authorization: Bearer $EMLBOT_ADMIN_KEY"
{ "count": 1, "webhooks": [ { "webhook_id": "wh_2c7d9e1f3a5b4c6d8e0f1a2b3c4d5e6f", "enabled": true, "failures": 0, ... } ] }

GET /webhooks/{id}: get

One webhook, without its secret.

curl https://api.emlbot.com/v0/webhooks/wh_2c7d9e1f3a5b4c6d8e0f1a2b3c4d5e6f -H "Authorization: Bearer $EMLBOT_ADMIN_KEY"
{ "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.

curl -X DELETE https://api.emlbot.com/v0/webhooks/wh_2c7d9e1f3a5b4c6d8e0f1a2b3c4d5e6f -H "Authorization: Bearer $EMLBOT_ADMIN_KEY"
{ "webhook_id": "wh_2c7d9e1f3a5b4c6d8e0f1a2b3c4d5e6f", "deleted": true }

POST /webhooks/{id}/rotate-secret: rotate 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).

curl -X POST https://api.emlbot.com/v0/webhooks/wh_2c7d9e1f3a5b4c6d8e0f1a2b3c4d5e6f/rotate-secret \
  -H "Authorization: Bearer $EMLBOT_ADMIN_KEY"
{ "webhook_id": "wh_2c7d9e1f3a5b4c6d8e0f1a2b3c4d5e6f", "updated_at": "2026-10-22T17:30:00.120Z", "secret": "whsec_...", ... }

Events

GET /events: read 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.

curl "https://api.emlbot.com/v0/events?after=40&limit=100" -H "Authorization: Bearer $EMLBOT_KEY"
{
  "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
}

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. A key cannot approve a send it parked itself, even if it holds both send and approve.

GET /approvals: list

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.

curl "https://api.emlbot.com/v0/approvals?status=pending" -H "Authorization: Bearer $EMLBOT_ADMIN_KEY"
{
  "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}/approve: approve

Sends the parked message now and answers with its ids. 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.

curl -X POST https://api.emlbot.com/v0/approvals/apr_7a3f1e9c2b5d4a6f8e0c1b3d5f7a9c2e/approve \
  -H "Authorization: Bearer $EMLBOT_ADMIN_KEY"
{
  "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}/reject: reject

Drops the parked send. Nothing is sent.

curl -X POST https://api.emlbot.com/v0/approvals/apr_7a3f1e9c2b5d4a6f8e0c1b3d5f7a9c2e/reject \
  -H "Authorization: Bearer $EMLBOT_ADMIN_KEY"
{ "approval_id": "apr_7a3f1e9c2b5d4a6f8e0c1b3d5f7a9c2e", "status": "rejected" }

Deciding an approval that is no longer pending answers 409 conflict, so a double click cannot send twice.

Audit log

GET /audit: read 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.

curl "https://api.emlbot.com/v0/audit?action=key.create&limit=20" -H "Authorization: Bearer $EMLBOT_ADMIN_KEY"
{
  "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