Project API

Inbound API

Receive email: a webhook when it arrives, and an API for the body and attachments.

View as Markdown ↗

Project key. Acts in the project the key belongs to. An all-access key works too — name the project with an X-Project-Id header. How keys and scopes work.

Endpoints

GET/v1/inbound
GET/v1/inbound/{id}
DELETE/v1/inbound/{id}
GET/v1/inbound/{id}/attachments/{attachment_id}
GET/v1/inbound/stream
POST/v1/inbound/{id}/reply
POST/v1/inbound/{id}/forward
GET/v1/inbound/address
GET/v1/inbound/rules
POST/v1/inbound/rules
DELETE/v1/inbound/rules/{id}

Email people send you, parsed into something your application can act on: a webhook the moment it arrives, and this API for the body and the files.

Where to receive

Every project can receive mail immediately, with nothing to configure — any address on its own subdomain works:

Your receiving address
anything@<project-slug>.inbound.emailbump.com

It's a catch-all, so support@, replies@, and invoice-4821@ all arrive — filter on received_for, the address that routed it here, to tell them apart. GET /v1/inbound/address returns your project's domain and an example address.

cURL
curl https://emailbump.com/api/v1/inbound/address \
  -H "Authorization: Bearer ebk_your_key"
200 response
{
  "domain": "acme-newsletter-3f9a2b.inbound.emailbump.com",
  "example": "hello@acme-newsletter-3f9a2b.inbound.emailbump.com",
  "catch_all": true,
  "custom_domain_record": {
    "type": "MX",
    "value": "inbound-smtp.us-east-1.amazonaws.com",
    "priority": 10
  }
}

Receiving on your own domain

Add that MX record to the domain you want to receive on, and mail to any address there arrives in the same place. The domain has to be one you've verified for sending, which is what proves it's yours.

An MX record redirects all mail for that name

If the domain already receives email — Google Workspace, Outlook, anything — point a subdomain at us instead, e.g. inbox.acme.com. Adding this to a root domain that already has mail will redirect your company's email here, which is not a mistake you want to make on a Friday.

The email.received webhook

Subscribe to email.received under Developers → Webhooks and we POST as each message arrives. Like every webhook we send, it's signed and retried — and it carries metadata only, so the delivery stays small:

POST body
{
  "id": "2c28f706-5f9b-489a-ada6-0bab22460dbe",
  "type": "email.received",
  "created": "2026-08-01T22:34:28Z",
  "data": {
    "email": {
      "id": "3de5e8ff-d56a-4120-b3b7-15d6969b82ba",
      "from": "jamie@example.com",
      "from_name": "Jamie",
      "to": ["support@acme-newsletter-3f9a2b.inbound.emailbump.com"],
      "cc": [],
      "received_for": "support@acme-newsletter-3f9a2b.inbound.emailbump.com",
      "subject": "Photo of the damage",
      "message_id": "CAF…@mail.gmail.com",
      "in_reply_to": null,
      "attachment_count": 1,
      "attachments": [
        {
          "id": "877ba06a-…",
          "filename": "photo.png",
          "content_type": "image/png",
          "content_disposition": "inline",
          "content_id": "photo001",
          "size_bytes": 20481,
          "inline": true
        }
      ],
      "size_bytes": 38210,
      "spam": "PASS", "spf": "PASS", "dkim": "PASS", "dmarc": "PASS",
      "received_at": "2026-08-01T22:34:26Z"
    }
  }
}

Fetch the body with the id. The message is stored before the webhook fires, so a handler that was down loses nothing — the mail is here when you come back for it.

Read a message

GET /v1/inbound lists what you've received, newest first, with ?limit=, ?offset=, and ?search= over sender, subject, and recipient. GET /v1/inbound/{id} adds the body and the attachment list.

cURL
curl https://emailbump.com/api/v1/inbound/3de5e8ff-d56a-4120-b3b7-15d6969b82ba \
  -H "Authorization: Bearer ebk_your_key"
200 response
{
  "id": "3de5e8ff-…",
  "from": "jamie@example.com",
  "to": "support@acme-newsletter-3f9a2b.inbound.emailbump.com",
  "subject": "Re: your invoice",
  "text": "Thanks — see the file attached.",
  "html": "<p>Thanks — see the file attached.</p>",
  "body_truncated": false,
  "attachments": [
    {
      "id": "91ca5a56-…",
      "filename": "people.csv",
      "content_type": "text/csv",
      "size_bytes": 22,
      "inline": false,
      "content_id": null
    }
  ],
  "spam": "PASS", "spf": "PASS", "dkim": "PASS", "dmarc": "PASS",
  "received_at": "2026-08-01T22:34:26Z"
}

A very large body is stored up to a limit and marked body_truncated; the complete message is always kept intact behind the scenes.

Threading

message_id and in_reply_to are the sender's own headers, so a reply to something you sent can be matched back to it — that's how you build a conversation rather than a pile of messages.

Attachments

Download one by id. It comes back as the bytes that were sent, with the original filename and content type:

cURL
curl https://emailbump.com/api/v1/inbound/MESSAGE_ID/attachments/ATTACHMENT_ID \
  -H "Authorization: Bearer ebk_your_key" \
  -o people.csv

Every download is served as an attachment with X-Content-Type-Options: nosniff — a file a stranger sent you is never rendered in a browser on our domain. Treat it as untrusted in your own application too.

Images inside the message

An image the sender embedded arrives as an attachment with content_disposition: "inline" and a content_id, and the HTML points at it with <img src="cid:photo001">. To display the message, swap each cid: reference for that attachment's bytes:

Rendering a message with embedded images
let html = message.html
for (const a of message.attachments.filter((a) => a.inline && a.content_id)) {
  const res = await fetch(
    `https://emailbump.com/api/v1/inbound/${message.id}/attachments/${a.id}`,
    { headers: { Authorization: `Bearer ${process.env.EMAILBUMP_API_KEY}` } },
  )
  const b64 = Buffer.from(await res.arrayBuffer()).toString("base64")
  html = html.replaceAll(`cid:${a.content_id}`, `data:${a.content_type};base64,${b64}`)
}

CID replacement is not HTML sanitization. Sanitize the result, restrict displayed attachment types, and use an isolated viewer with scripts and remote loading disabled. Do not put raw inbound HTML into your application's DOM.

Be told the moment mail arrives

WS/v1/inbound/stream

Upgrade to a WebSocket and messages are pushed as they land, with no URL to host. The webhook and the socket carry the same news by different routes — a webhook POSTs to a server you run, a socket pushes to an agent already connected and waiting. Both are on at once; use whichever suits what you are building.

cURL
// Node.js: install the ws package. Keep API keys out of browsers.
import WebSocket from "ws"

const ws = new WebSocket("wss://emailbump.com/api/v1/inbound/stream", {
  headers: { Authorization: `Bearer ${process.env.EMAILBUMP_API_KEY}` },
})

ws.addEventListener("message", (e) => {
  const event = JSON.parse(e.data)
  if (event.type !== "email.received") return
  console.log(event.email.from, "→", event.email.to, ":", event.email.subject)
})

To watch it without writing any code — useful when you are checking that mail is arriving at all — emailbump inbound:streamprints one JSON object per line, flushed as it arrives, so it pipes into jq.

one frame per message
{
  "type": "email.received",
  "email": {
    "id": "8f1c…",
    "from": "customer@example.com",
    "to": "support@your-project.inbound.emailbump.com",
    "subject": "Where is my order?",
    "received_at": "2026-08-04T14:22:03Z",
    "attachment_count": 0
  }
}

It is a summary, not the message. id is what GET /v1/inbound/{id} takes, and to lets an agent using an address per task tell at a glance whether the message is one of its own. Fetch the body when the summary says it matters.

The key goes in a header, so browsers can't connect

Authentication is checked before the upgrade — an unauthenticated client gets 401 and never reaches the socket. Browsers can’t set headers on a WebSocket, which is deliberate: the alternative is a key in the query string, and query strings end up in access logs, proxies and referrer headers.

A connection that stops reading is disconnected rather than allowed to consume memory, and it is told first — a { "type": "lagged", "missed": 4 } frame, so an agent can list the inbox and recover instead of living with a silent gap.

Delete a message

DELETE/v1/inbound/{id}

Removes this project's received copy from lists, search, and named inbox counts. Returns 204 No Content, including repeated deletes. Missing messages and other projects' messages return 404.

You cannot restore a deleted message through the app or API. Its body and attachment metadata are removed, and reads, downloads, replies, and forwards return 404. Receipt metadata prevents delivery retries from restoring it; shared raw MIME follows existing storage retention. Other recipients' copies, sent replies and forwards, existing webhooks, and usage are unchanged.

cURL
curl -X DELETE https://emailbump.com/api/v1/inbound/MESSAGE_ID \
  -H "Authorization: Bearer ebk_your_key"

In the app, project members can delete from the received-mail list or an open message. In the CLI, use emailbump inbound:delete MESSAGE_ID --yes. Local MCP exposes delete_inbound_message with id; hosted MCP exposes delete_message with message_id. Both require confirm: true reflecting user authorization.

Reply to a message

POST/v1/inbound/{id}/reply

Answers whoever wrote to you, inside the thread they already have open. That is the difference from forwarding: a forward sends the message to someone else, a reply continues the conversation.

The destination is the original’s single valid Reply-To mailbox, or its From address when no usable Reply-To was stored. Malformed, repeated, multi-address, and group Reply-To headers fall back to From; a normal reply never quietly becomes a broadcast. Older messages retain their From fallback. Message metadata includes reply_to and reply_recipient, the actual default reply destination. These addresses are supplied by the sender and are not proof of identity.

Body parameters

textstringoptional

The reply. One of text or html is required.

htmlstringoptional

HTML version. Sending only html generates the text part for you.

fromstringoptional

Which of your sending addresses it comes from. Defaults to the owned receiving address described below, or the shared sender when none is available.

reply_allbooleanoptional

Also write to everyone else the original was addressed to. Off by default — answering a question should reach the person who asked, not everyone they copied.

subjectstringoptional

Override the subject. Left alone it is the original’s with Re: in front, which is what keeps the thread together.

cURL
curl -X POST https://emailbump.com/api/v1/inbound/MESSAGE_ID/reply \
  -H "Authorization: Bearer ebk_your_key" \
  -H "Content-Type: application/json" \
  -d '{ "text": "Sorry about that — it ships tomorrow." }'
cURL
{ "replied": true, "to": "customer@example.com", "message_id": "0100019f…" }

What makes it thread

Three headers, set for you from the message you are answering: In-Reply-To names it, References carries the whole chain so a client that never saw an earlier message still files yours correctly, and the subject gains Re: once — an existing prefix in any language (AW:, SV:) is left alone rather than stacked on.

Which address it comes from

An owned named inbox replies from its own address. Mail received directly on a verified custom domain in this project also replies from that receiving address, without claiming a named inbox. For a forward to this project’s managed catch-all, exactly one distinct, valid original To mailbox on an exact verified custom domain owned by this project can supply the default instead. Another project’s domains, unverified domains, Ccaddresses and ambiguous matches do not select a sender. Otherwise the shared sender is used. An explicit fromstill passes sender-ownership checks. Reply-To uses the selected owned receiving address, or the actual receiving mailbox when none is selected, so the next message returns to the same conversation. Reply-all does not copy either receiving address back to itself.

A reply is a send: domain review, suspension, account verification, monthly allowance and applicable shared-domain limits still apply. It appears in your transactional log alongside everything else.

Forward a message

Send a message you received on to a person — a human inbox, a helpdesk, whoever should see it. The original bytes are re-addressed rather than rebuilt, so attachments, embedded images and formatting arrive exactly as they were sent.

cURL
curl -X POST https://emailbump.com/api/v1/inbound/MESSAGE_ID/forward \
  -H "Authorization: Bearer ebk_your_key" \
  -H "Content-Type: application/json" \
  -d '{ "to": "support@acme.com" }'

Body parameters

tostringrequired

Where to send it.

fromstringoptional

Which of your addresses it comes from. Defaults to your shared sender; anything else must be a verified domain.

passthroughbooleanoptional

Default true: send the message as it arrived. Set false to put your own note on top, with the original below it.

textstringoptional

The note, when passthrough is false.

htmlstringoptional

The note in HTML.

The forward goes out from your own address with Reply-To set to the original’s single valid Reply-To mailbox, or its From fallback, so a reply reaches the intended correspondent. Sending it as the original author instead would fail SPF and DKIM and land in spam, which is why no provider does it that way.

Forward automatically

A rule forwards mail as it arrives, with no code in the path. Set one under Inbound → Forwarding in the dashboard, or over the API:

cURL
curl -X POST https://emailbump.com/api/v1/inbound/rules \
  -H "Authorization: Bearer ebk_your_key" \
  -H "Content-Type: application/json" \
  -d '{ "forward_to": "support@acme.com", "match_address": "help@mail.acme.com" }'

Body parameters

forward_tostringrequired

One address. It ends up in a header on every message the rule touches, so anything that could break a header line is refused here.

match_addressstringoptional

Only mail sent to this address. Omit it and every message the project receives is forwarded — a project receives on a catch-all, so that is a much bigger set than it looks.

passthroughbooleanoptional

Default true: preserve the original MIME body. false rebuilds it in a forwarding wrapper. This is not a retention or delete switch.

include_spambooleanoptional

Forward messages marked as spam. Default false.

GET /v1/inbound/rules returns the rules plus addresses — the addresses this project has actually received on, taken from recent mail. Because a project receives on a catch-all, there is no other list of "your addresses" to read.

DELETE /v1/inbound/rules/{id} stops it. Up to 20 rules per project: every rule is another copy of every message that arrives, and a handful is a routing setup while fifty is an amplifier. The same loop protections apply as to a manual forward — an address that receives mail here is refused as a destination, automatic mail is never forwarded, and copies stop after three hops.

Spam and authentication

Every message is scanned before you see it, and the verdicts come with it: spam, spf, dkim, and dmarc, each PASS, FAIL, GRAY, or PROCESSING_FAILED.

  • Mail carrying a virus never arrives. It's dropped on receipt — no row, no webhook.
  • Spam does arrive, flagged. You decide what to do with spam: "FAIL", because one product's junk is another's signal.
  • Authentication is advisory. A failed SPF or DMARC often means a forwarded message, not a forged one — weigh it, don't gate on it alone.