Can you confirm Thursday?
", // optional }); ``` A new message starts a new thread. Up to 50 recipients across `to`, `cc` and `bcc`. If you send only `html`, a plain-text version is generated for you. ## Reply [#reply] ```ts await send0.messages.reply(msg.id, { text: "Thursday works.", reply_all: false }); ``` * Goes to the original sender (their `Reply-To` if set). With `reply_all: true`, the original `To` and `Cc` are included too. Your own address is always left out. * Keeps the thread: `In-Reply-To`, `References` and a `Re:` subject. ## Forward [#forward] ```ts await send0.messages.forward(msg.id, { to: "finance@acme.com", text: "FYI, see below." }); ``` The original is quoted under a standard `---------- Forwarded message ---------` header in a new thread. Attachments aren't forwarded yet. ## Responses [#responses] | Status | Body | When | | ------ | ------------------------------- | ---------------------------------------------------------------------- | | `201` | `Message` with `status: "sent"` | Sent through our mail servers | | `202` | `Draft` | The inbox's send policy is `approval`. See [Drafts](/concepts/drafts). | Delivery happens after that: `status` moves to `delivered`, `bounced` or `complained`, and you get [`message.delivered` / `message.bounced` / `message.complained`](/realtime/webhooks) events. ## Sending rules [#sending-rules] These protect everyone's deliverability on the shared domain. Breaking one returns an error with a clear `code`: | Rule | Error | | ------------------------------------------------------------------------------------------------------------------- | --------------------------- | | Free accounts and `reply_only` inboxes may only send to addresses that have emailed that inbox | `403 recipient_not_allowed` | | Addresses that hard-bounced or complained are never mailed again from your account | `422 recipient_suppressed` | | New accounts can send 50 messages per day (UTC). The limit rises as your record builds. | `429 daily_limit_reached` | | Sending pauses automatically if complaints pass 0.3% or hard bounces pass 5% over 30 days. Receiving keeps working. | `403 sending_paused` | Cold outreach, bulk mail and marketing are not allowed. See the [acceptable use policy](https://send0.dev/acceptable-use). ## Test keys [#test-keys] With an `s0_test_` key, sends go through every rule above and are recorded as `sent`, but nothing leaves. Use them in CI. ## How messages are built [#how-messages-are-built] Every message has `Message-ID:: `, for example `send0 error recipient_not_allowed: …`, so it can pick another recipient or tell you, instead of the run failing.
* **Email is untrusted.** Tools that return mail wrap each body in `` tags with a note to treat it as data, and messages flagged for [prompt injection](/guides/prompt-injection) carry a warning. Tags inside an email are neutralized, so a sender can't close the block early.
* **Sending can wait for a person.** Each integration can put `send_email` and `reply` behind its framework's approval flow, or leave them out entirely. An inbox with the `approval` send policy turns every send into a [draft](/concepts/drafts) on the server side too.
Give an agent an API key with `read` and `send` scopes, limited to its inbox. See [API keys](/api-keys).
# LangChain.js (https://send0.dev/docs/integrations/langchain-js)
`@send0/langchain` turns send0's [tools](/integrations#tools) into LangChain structured tools, for `createAgent`, `model.bindTools()` and LangGraph's `ToolNode`. It needs `@langchain/core` 1.x and `zod` 4.
## Install [#install]
```sh
npm install @send0/langchain @langchain/core zod
```
## Example [#example]
```ts
import { ChatAnthropic } from "@langchain/anthropic";
import { send0Tools } from "@send0/langchain";
import { createAgent } from "langchain";
const agent = createAgent({
model: new ChatAnthropic({ model: "claude-opus-5-5" }),
tools: send0Tools({ exclude: ["send_email", "reply"] }), // reads SEND0_API_KEY
});
const result = await agent.invoke({
messages: [{ role: "user", content: "Summarize the latest conversations in my inbox." }],
});
```
`new Send0Toolkit(options).getTools()` returns the same tools as a LangChain toolkit. A runnable version is in [examples/langchain-js-agent](https://github.com/send0-dev/send0-v2/tree/main/examples/langchain-js-agent).
## Options [#options]
| Option | |
| --------- | ------------------------------------------------------------------- |
| `apiKey` | Defaults to `SEND0_API_KEY` |
| `baseUrl` | Your [self-hosted](/self-hosting) send0 |
| `client` | A configured `Send0` client from `@send0/sdk`, instead of the above |
| `inboxId` | Default inbox, so the model doesn't need `inbox_id` |
| `include` | Only these tools |
| `exclude` | All tools but these |
## Approval [#approval]
`send_email` and `reply` carry `metadata.sends = true`. To have a person approve them before they run, add LangChain's human-in-the-loop middleware. It pauses the agent with an interrupt, so it needs a checkpointer:
```ts
import { MemorySaver } from "@langchain/langgraph";
import { createAgent, humanInTheLoopMiddleware } from "langchain";
const agent = createAgent({
model,
tools: send0Tools(),
checkpointer: new MemorySaver(),
middleware: [humanInTheLoopMiddleware({ interruptOn: { send_email: true, reply: true } })],
});
```
## Untrusted content [#untrusted-content]
Every body the tools return sits inside `` tags with a note to treat it as data. Keep the tool set to what the task needs, use a key limited to one inbox, and put sending behind approval when the agent reads mail from strangers. See [Prompt injection](/guides/prompt-injection).
# LangChain for Python (https://send0.dev/docs/integrations/langchain-python)
`langchain-send0` gives LangChain agents send0's [tools](/integrations#tools) as `BaseTool` classes with pydantic argument schemas. Each tool runs sync (`invoke`) and async (`ainvoke`, on send0's async client). It needs Python 3.10+ and `langchain-core` 1.x.
## Install [#install]
```sh
pip install langchain-send0
```
## Example [#example]
```python
from langchain.agents import create_agent
from langchain_send0 import Send0Toolkit
tools = Send0Toolkit(exclude=["send_email", "reply"]).get_tools() # reads SEND0_API_KEY
agent = create_agent("anthropic:claude-opus-5-5", tools=tools)
result = agent.invoke({"messages": [{"role": "user", "content": "Summarize the latest conversations in my inbox."}]})
```
A runnable version, with approval for every send, is in [examples/langchain-python-agent](https://github.com/send0-dev/send0-v2/tree/main/examples/langchain-python-agent).
## Options [#options]
`Send0Toolkit(client=None, api_key=None, inbox_id=None, include=None, exclude=None, *, async_client=None, base_url=None)`
| Option | |
| -------------- | ------------------------------------------------------------------------------------ |
| `client` | A configured `send0.Send0`. By default the toolkit makes one from `api_key` |
| `api_key` | Defaults to `SEND0_API_KEY` |
| `inbox_id` | Default inbox, so the model doesn't need `inbox_id` |
| `include` | Only these tools |
| `exclude` | All tools but these |
| `async_client` | A configured `send0.AsyncSend0` for `ainvoke`. Without one, async calls use a thread |
| `base_url` | Your [self-hosted](/self-hosting) send0 |
Each tool is also a class you can use alone, such as `WaitForEmailTool(client=Send0(), default_inbox_id="ibx_…")`.
## Approval [#approval]
`send_email` and `reply` have `sends = True`. To have a person approve them before they run, add LangChain's human-in-the-loop middleware. It pauses the agent with an interrupt, so it needs a checkpointer:
```python
from langchain.agents import create_agent
from langchain.agents.middleware import HumanInTheLoopMiddleware
from langgraph.checkpoint.memory import InMemorySaver
agent = create_agent(
"anthropic:claude-opus-5-5",
tools=Send0Toolkit().get_tools(),
checkpointer=InMemorySaver(),
middleware=[HumanInTheLoopMiddleware(interrupt_on={"send_email": True, "reply": True})],
)
```
Resume with `agent.invoke(Command(resume={"decisions": [{"type": "approve"}]}), config)`, one decision per pending call.
## Untrusted content [#untrusted-content]
Every body the tools return sits inside `` tags with a note to treat it as data. Keep the tool set to what the task needs, use a key limited to one inbox, and put sending behind approval when the agent reads mail from strangers. See [Prompt injection](/guides/prompt-injection).
# Vercel AI SDK (https://send0.dev/docs/integrations/vercel-ai-sdk)
`@send0/ai-sdk` turns send0's [tools](/integrations#tools) into [AI SDK](https://ai-sdk.dev) tools for `generateText`, `streamText` and `Agent`. It needs `ai` 7 and `zod` 4.
## Install [#install]
```sh
npm install @send0/ai-sdk ai zod
```
## Example [#example]
```ts
import { anthropic } from "@ai-sdk/anthropic";
import { send0Tools } from "@send0/ai-sdk";
import { generateText, stepCountIs } from "ai";
const { text } = await generateText({
model: anthropic("claude-opus-5-5"),
tools: send0Tools({ include: ["create_inbox", "wait_for_email"] }), // reads SEND0_API_KEY
stopWhen: stepCountIs(5),
prompt: "Create an inbox, wait for the verification email, and tell me the code.",
});
```
`send0Tools()` returns a record keyed by tool name, so you can spread it next to your own tools: `tools: { ...send0Tools(), ...myTools }`. A runnable version is in [examples/ai-sdk-signup](https://github.com/send0-dev/send0-v2/tree/main/examples/ai-sdk-signup).
## Options [#options]
| Option | |
| ----------------- | ------------------------------------------------------------------- |
| `apiKey` | Defaults to `SEND0_API_KEY` |
| `baseUrl` | Your [self-hosted](/self-hosting) send0 |
| `client` | A configured `Send0` client from `@send0/sdk`, instead of the above |
| `inboxId` | Default inbox, so the model doesn't need `inbox_id` |
| `include` | Only these tools, e.g. `["create_inbox", "wait_for_email"]` |
| `exclude` | All tools but these, e.g. `["send_email", "reply"]` |
| `requireApproval` | Ask before `send_email` and `reply` run |
## Approval [#approval]
With `requireApproval: true`, the AI SDK doesn't run `send_email` or `reply` on its own. It returns a `tool-approval-request` part, and the call runs once you answer with a `tool-approval-response` (in `useChat`, call `addToolApprovalResponse`). The read-only tools still run straight away.
You can also decide per call with the AI SDK's `toolApproval` option, using the `send0ToolApproval` preset:
```ts
import { send0ToolApproval, send0Tools } from "@send0/ai-sdk";
await generateText({ model, tools: send0Tools(), toolApproval: send0ToolApproval, prompt });
```
## Untrusted content [#untrusted-content]
Every body the tools return sits inside `` tags with a note to treat it as data. That helps, but a model can still be talked into things. Keep the tool set to what the task needs (`include`), use a key limited to one inbox, and put sending behind approval when the agent reads mail from strangers. See [Prompt injection](/guides/prompt-injection).
# cURL quickstart (https://send0.dev/docs/quickstart/curl)
Base URL: `https://api.send0.dev`. Every request needs `Authorization: Bearer `. Requests and responses are JSON.
```bash
export SEND0_API_KEY=s0_live_…
```
### Create an inbox [#create-an-inbox]
```bash
curl https://api.send0.dev/v1/inboxes \
-H "Authorization: Bearer $SEND0_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{ "name": "research-agent" }'
```
```json
{ "object": "inbox", "id": "ibx_7Hc2…", "address": "research-agent@send0.email", "send_policy": "reply_only", … }
```
### Wait for a message [#wait-for-a-message]
```bash
curl "https://api.send0.dev/v1/inboxes/ibx_7Hc2…/messages/wait?from=*@acme.dev&timeout=60" \
-H "Authorization: Bearer $SEND0_API_KEY"
```
```json
{
"object": "wait_result",
"timed_out": false,
"message": {
"id": "msg_9Kd2…",
"subject": "Your Acme verification code",
"extracted": { "otp": "482913", "links": ["https://acme.dev/verify?…"], "action_link": "https://acme.dev/verify?…" },
…
}
}
```
On timeout you get `{"timed_out": true, "message": null}` with status 200. The longest single wait is 120 seconds.
### Reply [#reply]
```bash
curl https://api.send0.dev/v1/messages/msg_9Kd2…/reply \
-H "Authorization: Bearer $SEND0_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{ "text": "Thanks, confirmed." }'
```
`201` with the sent message, or `202` with a draft if the inbox requires approval.
Send an `Idempotency-Key` with every POST. If the network drops and you retry with the same key
and body, you get the original response back instead of a second email. Keys are remembered for 24
hours.
The full contract is in the [OpenAPI spec](https://api.send0.dev/openapi.json). See [REST API](/reference/api).
# MCP quickstart (https://send0.dev/docs/quickstart/mcp)
The send0 MCP server gives your assistant tools to create inboxes, wait for email, read threads and reply.
Give the assistant an API key limited to what it needs. A key with `read` and `send` scopes,
restricted to one inbox, is the safest choice. See [API keys](/api-keys).
## Connect [#connect]
There are two ways to run it. They have the same tools.
* **Remote**, nothing to install: point your client at `https://api.send0.dev/mcp` and send your API key as a Bearer token.
* **Local**, over stdio: run `npx @send0/mcp` on your machine. Use this for clients that only support local servers.
Self-hosting send0? Both work: use `https:///mcp` for remote, or set `SEND0_BASE_URL` for local. See [Self-hosting](/self-hosting/docker#use-it-from-your-code).
### Remote [#remote]
```bash
claude mcp add --transport http send0 https://api.send0.dev/mcp \
--header "Authorization: Bearer s0_live_…"
```
In `~/.cursor/mcp.json` (or `.cursor/mcp.json` in a project):
```json
{
"mcpServers": {
"send0": {
"url": "https://api.send0.dev/mcp",
"headers": { "Authorization": "Bearer s0_live_…" }
}
}
}
```
In `.vscode/mcp.json`:
```json
{
"servers": {
"send0": {
"type": "http",
"url": "https://api.send0.dev/mcp",
"headers": { "Authorization": "Bearer s0_live_…" }
}
}
}
```
Any client or agent framework that supports **Streamable HTTP** MCP servers works:
| | |
| ------------------------ | ------------------------------------------ |
| URL | `https://api.send0.dev/mcp` |
| Header | `Authorization: Bearer s0_live_…` |
| Default inbox (optional) | `https://api.send0.dev/mcp?inbox_id=ibx_…` |
The server is stateless, so there's no session to keep.
Add `?inbox_id=ibx_…` to the URL if the assistant should always use one inbox. The tools then don't need an `inbox_id`.
### Local [#local]
```bash
claude mcp add send0 --env SEND0_API_KEY=s0_live_… -- npx -y @send0/mcp
```
Add this to `claude_desktop_config.json`, then restart Claude Desktop:
```json
{
"mcpServers": {
"send0": {
"command": "npx",
"args": ["-y", "@send0/mcp"],
"env": { "SEND0_API_KEY": "s0_live_…" }
}
}
}
```
Add the same block to `~/.cursor/mcp.json` (or `.cursor/mcp.json` in a project).
Set `SEND0_INBOX_ID=ibx_…` as well if the assistant should always use one inbox.
## Try it [#try-it]
Ask your assistant:
> Create a send0 inbox called **demo**. I'll email it from my own address. Wait for that email and summarize it, then reply saying thanks.
It will call `create_inbox`, then `wait_for_email`, then `reply`. Email the address from your own mailbox while it waits.
## What the assistant sees [#what-the-assistant-sees]
Message bodies come back wrapped as untrusted data, with the useful parts first:
```text
id: msg_9Kd2 thread: thr_3Lp8 direction: in status: received
from: Acme
subject: Your Acme verification code
one-time code: 482913
action link: https://acme.dev/verify?token=…
sender auth: SPF pass, DKIM pass, DMARC pass
Enter this code to verify your email address: 482913
```
Emails flagged for prompt injection carry a visible warning. See [Prompt-injection safety](/guides/prompt-injection) and the full [tool list](/reference/mcp-tools).
# TypeScript quickstart (https://send0.dev/docs/quickstart/typescript)
The SDK has no dependencies and runs on Node 18+, Bun, Deno, Cloudflare Workers and browsers.
### Install and set your key [#install-and-set-your-key]
```bash
npm install @send0/sdk
export SEND0_API_KEY=s0_live_…
```
### Create an inbox [#create-an-inbox]
```ts
import { Send0 } from "@send0/sdk";
const send0 = new Send0(); // reads SEND0_API_KEY
const inbox = await send0.inboxes.create({ name: "signup-agent", display_name: "Signup Agent" });
console.log(inbox.address); // signup-agent@send0.email
```
Omit `name` for a random address like `agent-x7k2q9@send0.email`. Addresses are unique and never reassigned, even after you delete the inbox.
### Wait for the email [#wait-for-the-email]
Use the address somewhere (a sign-up form, a test email from your own mailbox), then wait:
```ts
const msg = await send0.inboxes.wait(inbox.id, {
from: "*@github.com", // exact address or wildcard
timeout: 60, // seconds
});
if (!msg) throw new Error("No email within 60s");
console.log(msg.extracted?.otp); // "482913"
console.log(msg.extracted?.action_link); // "https://github.com/verify?..."
console.log(msg.extracted_text); // the new text, without quoted history
```
`wait()` also returns a message that arrived up to a minute **before** you called it, so there's no race between triggering the email and starting to wait. See [Waiting for email](/concepts/waiting).
### Reply in the thread [#reply-in-the-thread]
```ts
import { isDraft } from "@send0/sdk";
const reply = await send0.messages.reply(msg.id, { text: "Thanks, confirmed." });
if (isDraft(reply)) console.log("Waiting for human approval:", reply.id);
else console.log("Sent", reply.id, "in thread", reply.thread_id);
```
The reply gets the right `In-Reply-To`, `References` and `Re:` subject, so it lands in the same conversation for the recipient.
Free accounts are **reply-only**: they can send to people who emailed the inbox first. That's
exactly what a reply does. See [Sending](/concepts/sending).
### Read the conversation [#read-the-conversation]
```ts
const thread = await send0.threads.get(inbox.id, msg.thread_id);
for (const m of thread.messages) console.log(m.direction, m.from?.email, m.extracted_text);
```
## Next [#next]
# Event stream (https://send0.dev/docs/realtime/events-stream)
If your process stays running (an agent loop, a dashboard), you can listen for events directly instead of hosting a webhook endpoint.
```ts
for await (const event of send0.events.stream({ inboxId: inbox.id })) {
if (event.type === "message.received") {
console.log(event.data.subject, event.data.extracted_text);
}
}
```
Leave out `inboxId` to stream every inbox in the account. That needs a key that isn't limited to specific inboxes. Events have the same shape as [webhooks](/realtime/webhooks).
## Resume [#resume]
The SDK reconnects on its own and sends `Last-Event-ID`, so events that happened while it was disconnected are replayed first, up to 500. Stop with `break` or an `AbortSignal`:
```ts
const controller = new AbortController();
for await (const e of send0.events.stream({ inboxId, signal: controller.signal })) { … }
```
## Raw HTTP [#raw-http]
```bash
curl -N "https://api.send0.dev/v1/events/stream?inbox_id=ibx_7Hc2…" \
-H "Authorization: Bearer $SEND0_API_KEY" \
-H "Last-Event-ID: evt_8Qw…"
```
```text
retry: 3000
: connected to inbox:ibx_7Hc2…
id: evt_9Rz…
event: message.received
data: {"id":"evt_9Rz…","object":"event","type":"message.received",…}
: ping
```
A `: ping` comment every 25 seconds keeps proxies from closing the connection.
Use webhooks for anything that must not miss events while your process is down for longer than a
few minutes. Webhooks retry for 24 hours.
# Webhooks (https://send0.dev/docs/realtime/webhooks)
## Create a webhook [#create-a-webhook]
```ts
const hook = await send0.webhooks.create({
url: "https://example.com/hooks/send0",
events: ["message.received"], // or ["*"] for everything (default)
inbox_ids: ["ibx_7Hc2…"], // optional: only these inboxes
});
console.log(hook.secret); // whsec_… shown once, store it
```
URLs must be public `https://`. Private, local and cloud-metadata addresses are refused. An account can have up to 20 webhooks. Managing webhooks needs an admin key that isn't limited to specific inboxes.
## Events [#events]
| Type | When |
| -------------------- | ------------------------------------------------------------- |
| `message.received` | A new inbound message was stored |
| `message.sent` | An outbound message was handed to our mail servers |
| `message.delivered` | The recipient's server accepted it |
| `message.bounced` | It bounced. Permanent bounces also suppress the address. |
| `message.complained` | The recipient marked it as spam. The address is suppressed. |
| `draft.created` | A message is waiting for approval |
| `inbox.suspended` | Sending was paused for the account, or an inbox was suspended |
| `webhook.test` | Sent by `POST /v1/webhooks/{id}/test` |
## The request [#the-request]
```http
POST /hooks/send0
Content-Type: application/json
send0-signature: t=1791190000,v1=5f2c…
send0-event-id: evt_8Qw…
send0-event-type: message.received
send0-delivery-id: dlv_2Lk…
```
```json
{
"id": "evt_8Qw…",
"object": "event",
"type": "message.received",
"created_at": "2026-10-06T10:14:03.000Z",
"inbox_id": "ibx_7Hc2…",
"data": {
"id": "msg_9Kd2…",
"thread_id": "thr_3Lp8…",
"from": { "name": "Dana", "email": "dana@acme.com" },
"subject": "Re: PO #4471 delivery date",
"extracted_text": "Can you confirm Thursday instead?",
"extracted": { "otp": null, "links": [], "action_link": null },
"auth": { "spf": "pass", "dkim": "pass", "dmarc": "pass" },
"safety": { "prompt_injection": "none", "reasons": [] },
…
}
}
```
`data` is the full [message](/concepts/messages-and-threads) except `html`. Fetch the message if you need it.
### `inbox.suspended` [#inboxsuspended]
`data.scope` says what stopped. `"org"`: sending is paused for the whole account (receiving still works), either automatically or by our team, and `inbox_id` is the inbox whose message tipped the rate, or `null`. `"inbox"`: one inbox was suspended, so it neither sends nor receives (senders get `5.2.1 Mailbox disabled`).
```json
{ "type": "inbox.suspended", "inbox_id": null, "data": { "scope": "org", "reason": "complaint rate 0.40% is over 0.3%", "complaint_rate": 0.004, "bounce_rate": 0.01, "sent_30d": 250 } }
{ "type": "inbox.suspended", "inbox_id": "ibx_7Hc2…", "data": { "scope": "inbox", "reason": "Phishing reports", "address": "bot@send0.email" } }
```
Automatic pauses add `complaint_rate`, `bounce_rate` and `sent_30d`. Write to [support@send0.dev](mailto:support@send0.dev) to get it lifted.
## Verify the signature [#verify-the-signature]
Always verify before trusting a webhook. The scheme is the same as Stripe's: `v1` is the HMAC-SHA256 of `"{t}.{raw body}"` with your secret. Requests older than 5 minutes are rejected.
```ts
import { verifyWebhook } from "@send0/sdk";
export async function POST(req: Request) {
const body = await req.text(); // the raw body, before JSON.parse
if (!(await verifyWebhook(body, req.headers.get("send0-signature"), process.env.SEND0_WEBHOOK_SECRET!))) {
return new Response("bad signature", { status: 400 });
}
const event = JSON.parse(body);
// … handle event.type
return new Response("ok");
}
```
```ts
import { createHmac, timingSafeEqual } from "node:crypto";
function verify(body: string, header: string, secret: string) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
if (Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) return false;
const expected = createHmac("sha256", secret).update(`${parts.t}.${body}`).digest("hex");
return timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1 ?? ""));
}
```
```python
import hmac, hashlib, time
def verify(body: bytes, header: str, secret: str) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
if abs(time.time() - int(parts["t"])) > 300:
return False
expected = hmac.new(secret.encode(), f'{parts["t"]}.'.encode() + body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts.get("v1", ""))
```
## Responding and retries [#responding-and-retries]
Return any `2xx` within **10 seconds**. Anything else (a timeout, a `4xx` or `5xx`, a redirect) is retried with backoff: 30 s, 2 min, 10 min, 30 min, 1 h, 2 h, 4 h, 8 h, 8 h. That's about 24 hours in total, after which the delivery is marked `failed`.
Deliveries can arrive more than once or out of order. Use `send0-event-id` to de-duplicate.
## Delivery log, replay and testing [#delivery-log-replay-and-testing]
```ts
for await (const d of await send0.webhooks.deliveries(hook.id, { status: "failed" })) {
console.log(d.event_type, d.attempts, d.last_status_code, d.last_error);
await send0.webhooks.retryDelivery(hook.id, d.id); // replay
}
await send0.webhooks.test(hook.id); // sends a webhook.test event
await send0.webhooks.rotateSecret(hook.id); // new secret, shown once
await send0.webhooks.update(hook.id, { status: "disabled" });
```
Every event is stored in the same transaction as the change that caused it, and a background sweep re-queues anything that didn't make it out. An event is never lost between our database and your endpoint.
# REST API (https://send0.dev/docs/reference/api)
* **Base URL:** `https://api.send0.dev`
* **Auth:** `Authorization: Bearer s0_live_…`
* **Format:** JSON in, JSON out, snake_case fields, ISO 8601 timestamps
* **Spec:** [OpenAPI 3.1](https://api.send0.dev/openapi.json), checked against the live API in our test suite. Use it to generate clients in any language.
## Endpoints [#endpoints]
| Method | Path | Scope |
| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------ |
| `POST` | `/v1/inboxes` | send |
| `GET` | `/v1/inboxes` · `/v1/inboxes/{inbox_id}` | read |
| `PATCH` | `/v1/inboxes/{inbox_id}` | send |
| `DELETE` | `/v1/inboxes/{inbox_id}` | admin |
| `GET` | `/v1/inboxes/{inbox_id}/messages` | read |
| `GET` | `/v1/inboxes/{inbox_id}/messages/wait` | read |
| `POST` | `/v1/inboxes/{inbox_id}/messages` | send |
| `GET` | `/v1/messages`: every inbox the key can see, filter by `inbox_id`, `status`, `direction`, `q` | read |
| `GET` | `/v1/messages/{message_id}` · `/raw` · `/attachments/{attachment_id}` | read |
| `POST` | `/v1/messages/{message_id}/reply` · `/forward` | send |
| `GET` | `/v1/inboxes/{inbox_id}/threads` · `/threads/{thread_id}` | read |
| `GET` | `/v1/drafts` (every inbox; `status=pending` is the approval queue) · `/v1/inboxes/{inbox_id}/drafts` · `/v1/drafts/{draft_id}` | read |
| `PATCH` | `/v1/drafts/{draft_id}`: edit a pending draft's `subject`, `text` or `html` | send |
| `POST` | `/v1/drafts/{draft_id}/send` · `/reject` | admin (or a person in the dashboard) |
| `POST` `GET` `PATCH` `DELETE` | `/v1/webhooks[/{webhook_id}]` | admin |
| `POST` | `/v1/webhooks/{webhook_id}/rotate-secret` · `/test` | admin |
| `GET` | `/v1/webhooks/{webhook_id}/deliveries` | admin |
| `POST` | `/v1/webhooks/{webhook_id}/deliveries/{delivery_id}/retry` | admin |
| `GET` | `/v1/events/stream` | read |
| `GET` | `/v1/usage`: plan, inboxes used, sends today against the daily limit, paused state | read |
| `GET` | `/v1/stats?days=14`: mail per UTC day (received, sent, delivered, bounced, failed), up to 90 days | read |
| `POST` | `/mcp`: the [remote MCP server](/reference/mcp-tools#remote) (Streamable HTTP) | as the key |
| `POST` `GET` | `/v1/api-keys` · `DELETE /v1/api-keys/{api_key_id}` | admin |
Conventions: [errors, idempotency and pagination](/reference/errors).
## Rate limits [#rate-limits]
Requests are counted per minute:
| Applies to | Limit | Counted per |
| -------------------------------------------------------------------------------- | --------- | ------------------- |
| Every request with an API key (and the dashboard) | 600 / min | API key (or person) |
| Requests that send mail: send, reply, forward, `POST /v1/drafts/{draft_id}/send` | 60 / min | API key (or person) |
| Requests with a missing or invalid API key, and signed download links | 60 / min | Client IP |
Over a limit, the API answers `429` with the code `rate_limited` and a `Retry-After` header giving the seconds to wait. The SDKs wait that long and retry on their own. `/health` and `/openapi.json` aren't limited.
Don't poll for new mail. One `GET /v1/inboxes/{inbox_id}/messages/wait` counts as one request however long it waits, and it returns as soon as a matching message arrives. For everything else, use [webhooks](/realtime/webhooks) or the [event stream](/realtime/events-stream), which push new mail and events to you as they happen.
# Errors & limits (https://send0.dev/docs/reference/errors)
## Error format [#error-format]
Every error has the same shape and a stable `code` to branch on:
```json
{
"error": {
"code": "recipient_not_allowed",
"message": "This inbox can only reply to people who emailed it first (free plan). Not allowed: stranger@example.com",
"param": "to",
"request_id": "8c1f…"
}
}
```
Include `request_id` when you contact support. It's also in the `x-request-id` response header.
## Codes [#codes]
| Status | Code | Meaning |
| ------ | ------------------------- | -------------------------------------------------------------------------------------------- |
| 400 | `invalid_request` | A field is missing or invalid; `param` names it |
| 401 | `unauthorized` | Missing, malformed, unknown or revoked API key |
| 402 | `plan_limit_reached` | Inbox limit for your plan |
| 403 | `forbidden` | The key lacks the scope, or the action isn't allowed for this key |
| 403 | `recipient_not_allowed` | Reply-only inbox or free plan: the recipient never wrote first |
| 403 | `sending_paused` | Sending paused (complaint or bounce rate); receiving still works |
| 404 | `not_found` | Doesn't exist, or isn't visible to this key |
| 404 | `route_not_found` | Wrong method or path |
| 409 | `address_taken` | That address is in use (or was used before; addresses are never reused) |
| 409 | `idempotency_in_progress` | A request with this Idempotency-Key is still running. Retry shortly. |
| 410 | `message_expired` | The message is past its inbox's retention period: its raw download and forwarding are gone |
| 422 | `recipient_suppressed` | The address bounced or complained before |
| 422 | `idempotency_key_reused` | The same Idempotency-Key was used with a different request |
| 429 | `daily_limit_reached` | Daily send limit (UTC) reached |
| 429 | `rate_limited` | Too many requests; wait `Retry-After` seconds. See [rate limits](/reference/api#rate-limits) |
| 502 | `send_failed` | The mail server rejected the message; the message is marked `failed` |
| 503 | `send_temporarily_failed` | A temporary sending problem. Safe to retry. |
| 500 | `internal_error` | Our fault. It's logged; retry with the same Idempotency-Key. |
## Idempotency [#idempotency]
Send `Idempotency-Key: ` on every POST. A retry with the same key and the same body returns the stored response, with `Idempotent-Replayed: true`, instead of doing the work again. Keys last 24 hours and are scoped to your account. The SDK adds one automatically and reuses it across its own retries.
## Pagination [#pagination]
List endpoints return newest first:
```json
{ "object": "list", "data": [ … ], "next_cursor": "MjAyNi0xMC0wNlQx…" }
```
Pass `cursor=` for the next page; `next_cursor` is `null` on the last one. `limit` is 1–100 (default 20). Cursors stay stable while new items arrive.
## Limits [#limits]
| | Limit |
| --------------------------- | --------------------------------------------------------------------------- |
| Inboxes (free plan) | 5 |
| Sends per day, new accounts | 50 (UTC day; rises with a clean record) |
| Recipients per message | 50 |
| Inbound message size | 25 MiB |
| Message content kept | 1 to 30 days per inbox (`retention_days`, default 7); rows go after 35 days |
| `wait` timeout | 120 s per request (the SDK chains requests for longer waits) |
| Webhooks per account | 20 |
| Webhook response timeout | 10 s |
| Download links | 15 minutes |
| Requests per API key | 600 a minute, 60 of them sends ([rate limits](/reference/api#rate-limits)) |
# MCP tools (https://send0.dev/docs/reference/mcp-tools)
Two ways to run it, with the same tools. See the [MCP quickstart](/quickstart/mcp) for client setup. The same tools are also available for the [Vercel AI SDK and LangChain](/integrations).
## Remote [#remote]
`https://api.send0.dev/mcp`, Streamable HTTP, stateless.
| | |
| ------------------------------------------- | ---------------------------------------------------------------------- |
| `Authorization: Bearer s0_…` | Required. The tools act as this key, with its scopes and inbox limits. |
| `?inbox_id=ibx_…` or `x-send0-inbox` header | Default inbox, so tools don't need `inbox_id` |
A [self-hosted](/self-hosting) send0 serves the same endpoint at `https:///mcp`.
Requests without a valid key get `401` with a `WWW-Authenticate: Bearer` header. Browser-based clients are allowed (CORS).
## Local [#local]
Run with `npx -y @send0/mcp` over stdio.
| Variable | |
| ---------------- | ------------------------------------------------------------------------ |
| `SEND0_API_KEY` | Required. Prefer a `read`+`send` key limited to the agent's inbox. |
| `SEND0_INBOX_ID` | Default inbox, so tools don't need `inbox_id` |
| `SEND0_BASE_URL` | Your [self-hosted](/self-hosting) send0, like `https://mail.example.com` |
## Tools [#tools]
| Tool | Arguments | Notes |
| ----------------- | --------------------------------------------------------------------- | --------------------------------------------------- |
| `create_inbox` | `name?`, `display_name?` | Returns the address and id |
| `list_inboxes` | none | Inboxes this key can use |
| `wait_for_email` | `inbox_id?`, `from?`, `subject?`, `timeout?` (s, default 60, max 600) | Returns the code and action link first. Read-only. |
| `search_messages` | `inbox_id?`, `query?`, `from?`, `direction?`, `limit?` | One line per message. Read-only. |
| `get_message` | `message_id`, `full_text?` | New text only unless `full_text`. Read-only. |
| `list_threads` | `inbox_id?`, `limit?` | Read-only |
| `get_thread` | `inbox_id?`, `thread_id`, `full_text?` | Oldest first. Read-only. |
| `send_email` | `inbox_id?`, `to`, `subject`, `text`, `cc?` | New thread. Marked as acting on the outside world. |
| `reply` | `message_id`, `text`, `reply_all?` | Same thread. Marked as acting on the outside world. |
Read-only tools carry `readOnlyHint`. `send_email` and `reply` carry `openWorldHint`, so clients can ask before sending. API errors come back as tool errors the model can act on, for example `send0 error recipient_not_allowed: …`.
## In your own server [#in-your-own-server]
```ts
import { createSend0Server } from "@send0/mcp";
import { Send0 } from "@send0/sdk";
const server = createSend0Server({ client: new Send0(apiKey), defaultInboxId: "ibx_…" });
await server.connect(transport); // any MCP transport
```
# Python SDK (https://send0.dev/docs/reference/python-sdk)
```bash
pip install send0
```
Python 3.10+. Depends on `httpx` and `pydantic`. Models are generated from the [OpenAPI spec](https://api.send0.dev/openapi.json).
## Sign-up flow [#sign-up-flow]
```python
from send0 import Send0
send0 = Send0() # reads SEND0_API_KEY
inbox = send0.inboxes.create(name="signup-agent")
msg = send0.inboxes.wait(inbox.id, from_="*@github.com", timeout=60)
if msg and msg.extracted and msg.extracted.otp:
print(msg.extracted.otp)
send0.messages.reply(msg.id, text="Thanks, confirmed.")
```
`from` is a Python keyword, so the filter argument and the sender field are both `from_`.
## Async [#async]
```python
from send0 import AsyncSend0
async with AsyncSend0() as send0:
inbox = await send0.inboxes.create(name="async-agent")
msg = await send0.inboxes.wait(inbox.id, timeout=60)
async for m in await send0.messages.list(inbox.id):
...
```
`AsyncSend0` has exactly the same methods as `Send0`; its source is generated from the sync client.
## Configuration [#configuration]
```python
Send0(
api_key="s0_live_…", # default: SEND0_API_KEY
base_url="https://api.send0.dev", # for self-hosted send0
timeout=60, # seconds; wait() and stream() extend this
max_retries=2, # network errors, 429 and 5xx
http_client=httpx.Client(...), # optional, e.g. proxies
)
```
## Methods [#methods]
Same shape as the [TypeScript SDK](/reference/typescript-sdk), in snake_case:
| | |
| ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **inboxes** | `create` · `list` · `get` · `update` · `delete` · `wait(inbox_id, from_=, subject=, direction=, since=, timeout=)` |
| **messages** | `list(inbox_id, from_=, subject=, q=, direction=, since=, thread_id=)` · `list_all(inbox_id=, status=, …)` · `get` · `send` · `reply` · `forward` · `attachment` · `raw_url` |
| **threads** | `list` · `get(inbox_id, thread_id, include_html=)` |
| **drafts** | `list` · `list_all(status=, inbox_id=)` · `get` · `update(draft_id, subject=, text=, html=)` · `send` · `reject` |
| **webhooks** | `create` · `list` · `get` · `update` · `delete` · `rotate_secret` · `test` · `deliveries` · `retry_delivery` · `verify` |
| **api_keys** | `create` · `list` · `revoke` |
| **events** | `stream(inbox_id=, last_event_id=)`: iterator that reconnects and resumes |
| **usage** | `get()`: plan, inboxes used, sends today against the daily limit, paused state · `stats(days=, inbox_id=)`: mail per day |
`update()` arguments you leave out stay unchanged; pass `None` to clear a field (for example `display_name=None`).
## Errors [#errors]
```python
from send0 import Send0Error
try:
send0.messages.send(inbox.id, to="someone@example.com", subject="Hi", text="…")
except Send0Error as err:
if err.code == "recipient_not_allowed":
... # reply-only: only reply to people who wrote first
print(err.status, err.code, err.request_id)
```
Retries use backoff and honour `Retry-After`. Every POST carries a stable `Idempotency-Key`, so retries never send twice.
## Webhooks [#webhooks]
```python
from send0 import verify_webhook
ok = verify_webhook(raw_body, headers.get("send0-signature"), secret)
```
Pages are iterable: `for m in send0.messages.list(inbox.id): ...` walks every page.
# TypeScript SDK (https://send0.dev/docs/reference/typescript-sdk)
```bash
npm install @send0/sdk
```
```ts
import { Send0 } from "@send0/sdk";
const send0 = new Send0({
apiKey: process.env.SEND0_API_KEY, // default: SEND0_API_KEY
baseUrl: "https://api.send0.dev", // for self-hosted send0
maxRetries: 2, // network errors, 429 and 5xx
timeout: 60_000, // ms per request (wait/stream extend this)
fetch, // custom fetch, e.g. for proxies or tests
});
```
No dependencies. Runs on Node 18+, Bun, Deno, Cloudflare Workers and browsers. Types are generated from the [OpenAPI spec](https://api.send0.dev/openapi.json).
## Methods [#methods]
| | |
| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **inboxes** | `create(params?)` · `list(params?)` · `get(id)` · `update(id, params)` · `delete(id)` · `wait(id, { from, subject, direction, since, timeout })` |
| **messages** | `list(inboxId, { from, subject, q, direction, since, thread_id })` · `listAll({ inbox_id, status, … })` · `get(id)` · `send(inboxId, params)` · `reply(id, params)` · `forward(id, params)` · `attachment(id, attachmentId)` · `rawUrl(id)` |
| **threads** | `list(inboxId)` · `get(inboxId, threadId, { include_html })` |
| **drafts** | `list(inboxId, { status })` · `listAll({ status, inbox_id })` · `get(id)` · `update(id, { subject, text, html })` · `send(id)` · `reject(id)` |
| **webhooks** | `create` · `list` · `get` · `update` · `delete` · `rotateSecret` · `test` · `deliveries` · `retryDelivery` · `verify(rawBody, header, secret)` |
| **apiKeys** | `create` · `list` · `revoke` |
| **events** | `stream({ inboxId?, lastEventId?, signal? })`: an async iterable of events that reconnects and resumes on its own |
| **usage** | `get()`: plan, inboxes used, sends today against the daily limit, and whether sending is paused · `stats({ days, inbox_id })`: mail per day |
Also exported: `verifyWebhook()`, `isDraft()`, `Send0Error`, `Page`, and types for every object (`Inbox`, `Message`, `Thread`, `Draft`, `Webhook`, `Event`, …).
## Errors [#errors]
```ts
import { Send0Error } from "@send0/sdk";
try {
await send0.messages.send(inboxId, {
to: "someone@example.com",
subject: "Hi",
text: "…",
});
} catch (err) {
if (err instanceof Send0Error && err.code === "recipient_not_allowed") {
// reply-only inbox: only reply to people who wrote first
}
throw err;
}
```
`Send0Error` has `status` (0 for network errors and timeouts), `code`, `message`, `param` and `requestId`. See [Errors & limits](/reference/errors).
## Retries and idempotency [#retries-and-idempotency]
Network errors, `429` and `5xx` are retried with exponential backoff, honouring `Retry-After`. Every POST gets an `Idempotency-Key` that stays the same across retries, so a retried send never produces two emails. Pass your own with `{ idempotencyKey }` if you retry across process restarts.
## Pagination [#pagination]
`list()` methods return a `Page`:
```ts
const page = await send0.messages.list(inboxId, { limit: 50 });
page.data; // this page
page.hasMore; // more pages?
await page.next(); // the next Page, or null
for await (const m of page) {
/* every message, fetching pages as needed */
}
```
# Deploy to Cloudflare (https://send0.dev/docs/self-hosting/cloudflare)
This edition runs the API, the dashboard, inbound mail, webhooks and real-time events in one Worker. It's built from the template repo [send0-dev/send0-cloudflare](https://github.com/send0-dev/send0-cloudflare), which each release regenerates.
[](https://deploy.workers.cloudflare.com/?url=https://github.com/send0-dev/send0-cloudflare)
## What you need [#what-you-need]
* **A Cloudflare account.** The Workers Free plan is enough to start; see [limits](#limits).
* **A Postgres connection string**, such as a free [Neon](https://neon.tech) database. The Worker reaches it through Hyperdrive and creates its tables on first start.
* **Amazon SES.** An SES identity for your mail domain, and an IAM user allowed `ses:SendRawEmail`. send0 sends everything through SES, including the owner's verification email, so it won't start without it.
* **A domain on Cloudflare** to receive mail on, like `agents.example.com`. Its zone's nameservers must point to Cloudflare, because Email Routing only works there.
## Deploy [#deploy]
Click the button. Cloudflare copies the template to your GitHub or GitLab account, then creates:
| Resource | Name or binding | Used for |
| -------------- | --------------------------------- | ------------------------------------------------- |
| Worker | `send0` | Everything |
| R2 bucket | `send0-blobs` (binding `BLOBS`) | Raw mail and attachments |
| Queue | `send0-events` (binding `EVENTS`) | Webhook fan-out and delivery retries |
| Hyperdrive | binding `HYPERDRIVE` | Pooled connections to your Postgres |
| Durable Object | class `Hub` (binding `HUB`) | The SSE event stream and the `wait` long-poll |
| Cron trigger | `0 * * * *` (hourly) | Outbox safety net and purge of deleted workspaces |
It asks for:
| Setting | What to enter |
| -------------------------------------------- | -------------------------------------------------------------------------------------------- |
| Hyperdrive | Your Postgres connection string |
| `MAIL_DOMAINS` | The domain(s) you receive mail on, comma-separated. The first is the default for new inboxes |
| `SES_REGION` | The AWS region of your SES identity, like `us-east-1` |
| `ALLOW_SIGNUP` | Leave `false` |
| `SECRET_KEY` | 32 or more random characters: `openssl rand -hex 32` |
| `OWNER_EMAIL` | Your email. The first account must use it |
| `SES_ACCESS_KEY_ID`, `SES_SECRET_ACCESS_KEY` | The IAM user's keys |
You can add optional settings later, under **Workers & Pages > send0 > Settings > Variables and Secrets**: `MAIL_FROM`, `PUBLIC_URL`, `SES_CONFIGURATION_SET`, and `SES_EVENTS_TOKEN` with `SES_EVENTS_TOPIC_ARN`. See the [configuration reference](/self-hosting/configuration#cloudflare).
## After deploying [#after-deploying]
### Route mail to the Worker [#route-mail-to-the-worker]
For each mail domain, in the Cloudflare dashboard:
1. Open the domain and go to **Email > Email Routing**. Enable it. Cloudflare adds the MX and SPF records.
2. Under **Routing rules**, set the **catch-all** action to **Send to a Worker** and pick `send0` (or the name you gave the Worker).
Mail to an address that isn't an inbox is refused during the SMTP session, so the sender gets a bounce.
### Set up SES [#set-up-ses]
1. Verify the mail domain as an SES identity in `SES_REGION`, with Easy DKIM. Add the three CNAME records SES shows. A custom MAIL FROM domain and a DMARC record help deliverability; see [Mail setup](/self-hosting/email-setup#sending).
2. Request **production access**. Until AWS grants it, SES delivers only to verified addresses. Verify your own email address in SES meanwhile, or the owner's verification email won't arrive.
### Get delivery events (optional) [#get-delivery-events-optional]
Without this, sent messages never become `delivered`, `bounced` or `complained`, and send0 can't suppress bounced addresses or pause sending. To turn it on:
1. Create an SNS topic in `SES_REGION`.
2. Create an SES configuration set with an event destination that publishes to that topic. Set `SES_CONFIGURATION_SET` to its name.
3. Set `SES_EVENTS_TOKEN` (`openssl rand -hex 32`) and `SES_EVENTS_TOPIC_ARN` (the topic's ARN) on the Worker.
4. Add an HTTPS subscription to the topic, with this endpoint:
```text
https:///internal/ses-events?token=
```
Use your `PUBLIC_URL` if you set one. send0 confirms the subscription itself.
### Add a custom domain (optional) [#add-a-custom-domain-optional]
The Worker answers at `https://send0..workers.dev`. To use your own hostname, like `mail.example.com`, attach it under **Settings > Domains & Routes**, then set `PUBLIC_URL=https://mail.example.com`.
With `PUBLIC_URL` set, send0 redirects every other hostname, workers.dev included, to it. Sign-in, download links and webhooks then all use one URL. Unset, each request's own origin is used.
### Sign up [#sign-up]
Open the Worker's URL, sign up with `OWNER_EMAIL` and click the link in the verification email. Then create inboxes and API keys, and invite your team.
Point the SDKs and MCP server at the same URL, as in [Docker Compose](/self-hosting/docker#use-it-from-your-code).
## If a setting is wrong [#if-a-setting-is-wrong]
The Worker checks its settings on the first request. If any are missing or invalid, the dashboard shows **send0 isn't configured yet** with a list of problems:
```text
send0 can't start: its configuration has problems.
- MAIL_DOMAINS: replace the example domain with the domain(s) you receive mail on, like agents.acme.com
- SECRET_KEY: must be at least 32 characters (generate one with `openssl rand -hex 32`)
Set these in the Cloudflare dashboard (Workers & Pages > send0 > Settings > Variables and Secrets),
or in wrangler.jsonc and with `wrangler secret put`, then deploy again.
```
Each line names the variable and the rule it breaks, never its value. API routes answer with the same text and status `500`. Fix the variables, deploy again, then reload.
If the database is slow to wake up, requests get `503` with the code `starting_up` and a `Retry-After` header. Retry after a few seconds.
## Upgrading [#upgrading]
Each release replaces the template repo's contents with a new build, as one fresh commit. To upgrade your copy:
1. Copy the new release's files over your repo, but keep the resource IDs the button wrote into your `wrangler.jsonc`.
2. Commit and push. Workers Builds redeploys it.
Or deploy by hand with `npm install && npx wrangler deploy`. Database migrations run on first start.
## Limits [#limits]
* **Receiving.** Only domains whose DNS is on your Cloudflare account, through Email Routing. Messages can be up to 25 MiB.
* **Workers Free CPU.** The Free plan allows 100,000 requests a day and 10 ms of CPU per request. Sign-up and sign-in hash the password with PBKDF2 (100,000 iterations), which can use more than 10 ms. If sign-in fails with a CPU-limit error, or webhook volume, big attachments or busy inboxes hit limits, move to Workers Paid ($5/month).
* **Neon compute.** Neon's free plan has a monthly compute allowance and scales to zero when idle. The hourly cron wakes the database once an hour; webhooks, mail and API calls wake it too.
* **Hourly cron.** The cron is only a safety net for events the queue missed. The queue consumer sweeps after every batch, so webhooks don't wait for it.
* **Storage.** Raw mail and attachments live in R2 (10 GB free). Message content is deleted after each inbox's `retention_days` (default 7, at most 30), and messages after 35 days. See [Retention](/self-hosting/operations#retention).
* **No plan limits.** The hosted service's reply-only free tier and daily send caps are off. You own the SES account and its reputation.
* **Rate limits.** The [API rate limits](/reference/api#rate-limits) apply. Each Worker instance counts requests itself, and Workers Rate Limiting bindings add a count shared across instances in a location. Both are approximate on Workers; with Docker, one process counts everything.
# Configuration reference (https://send0.dev/docs/self-hosting/configuration)
Both editions read their settings once at start and check all of them together. A bad setting stops send0 with a list of every problem, naming the variable but never its value. Values are trimmed, and blank means unset.
## Docker Compose [#docker-compose]
Set these in `.env`, next to `compose.yml`. Compose passes the whole file to the `send0` container. After a change, run `docker compose up -d` to recreate the container; `docker compose restart` doesn't reload `.env`. Run `docker compose exec send0 send0 doctor` to check them.
### Required [#required]
| Name | Default | Required | Description |
| ------------------- | -------- | ------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `DOMAIN` | none | Yes | The hostname people and SDKs use, like `mail.example.com`. A hostname only: no scheme, port or path. Caddy gets an HTTPS certificate for it. |
| `MAIL_DOMAIN` | `DOMAIN` | No | The domain your inboxes live on, like `agents.example.com`. Point its MX at `DOMAIN`. Set this or `MAIL_DOMAINS`, not both. |
| `OWNER_EMAIL` | none | Yes, unless `ALLOW_SIGNUP=true` | The only email that can create the first account, which becomes the owner. Must be an email address. |
| `SECRET_KEY` | none | Yes | Signs download links for files on local disk. At least 32 characters: `openssl rand -hex 32`. |
| `POSTGRES_PASSWORD` | none | Yes (compose) | Used by the `postgres` service and inside `DATABASE_URL`, so keep it URL-safe: `openssl rand -hex 24`. Read by compose, not by send0. |
| `SMTP_URL` | none | Yes, when `MAILER=smtp` | The outbound relay: `smtp://user:pass@host:587` (STARTTLS) or `smtps://user:pass@host:465`. Must include a port. Checked at start. See [SMTP_URL](/self-hosting/email-setup#smtp_url). |
### Mail [#mail]
| Name | Default | Required | Description |
| ----------------------- | ----------------------------- | ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `MAIL_DOMAINS` | `MAIL_DOMAIN` | No | Several mail domains, comma-separated. The first is the default for new inboxes. Each must be a domain name with a TLD, like `agents.example.com`. |
| `MAIL_FROM` | `noreply@` | No | Sender of system email: verification, password resets and invites. Must be an email address. |
| `OPERATOR_FORWARD_TO` | none | No | Where mail for `postmaster@` and `abuse@` on your mail domains goes. send0 relays it through your mailer, from `MAIL_FROM`, with the original attached. Unset, that mail is refused. Must be an email address, and not a reserved one on your own mail domains. |
| `MX_HOSTNAME` | `DOMAIN` | No | The name this MX uses in its greeting, its self-signed certificate and the `Authentication-Results` it writes. Must be a hostname. |
| `SMTP_TLS_CERT` | none | Set both or neither | Path inside the container to a certificate (PEM) for inbound STARTTLS. Without it, send0 makes a self-signed certificate for `MX_HOSTNAME`. |
| `SMTP_TLS_KEY` | none | Set both or neither | Path inside the container to that certificate's private key. |
| `SMTP_MAX_CLIENTS` | `30` | No | Concurrent inbound SMTP sessions, from 1 to 10000. Each can buffer a message of about 25 MiB, so this bounds memory. |
| `TRUSTED_AUTHSERV_IDS` | none | No | Comma-separated authserv-ids whose `Authentication-Results` headers are trusted. `MX_HOSTNAME` is always trusted. |
| `MAILER` | `smtp` | No | `smtp` (uses `SMTP_URL`) or `ses`. |
| `SES_REGION` | none | Yes, when `MAILER=ses` | The region of your SES identity, like `us-east-1`. |
| `SES_ACCESS_KEY_ID` | none | Yes, when `MAILER=ses` | Access key of an IAM user allowed `ses:SendRawEmail`. Add `ses:GetAccount` so `send0 doctor` can check the account. |
| `SES_SECRET_ACCESS_KEY` | none | Yes, when `MAILER=ses` | That user's secret key. |
| `SES_CONFIGURATION_SET` | none | Yes, when `MAILER=ses` | The SES configuration set to send with. |
| `SES_EVENTS_TOKEN` | none | Set both or neither | Only with `MAILER=ses`. At least 32 characters. The secret in the SNS subscription URL `https://DOMAIN/internal/ses-events?token=…`. |
| `SES_EVENTS_TOPIC_ARN` | none | Set both or neither | Only with `MAILER=ses`. The SNS topic that carries SES events. Messages from any other topic are refused. |
### Storage [#storage]
| Name | Default | Required | Description |
| ---------------------- | ------------- | -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `BLOB_DRIVER` | `fs` | No | Where raw mail and attachments go: `fs` (local disk) or `s3`. |
| `BLOB_DIR` | `/data/blobs` | No | The directory for `BLOB_DRIVER=fs`. The image sets it to `/data/blobs`, on the `blobs` volume. |
| `S3_BUCKET` | none | Yes, when `BLOB_DRIVER=s3` | Bucket name. |
| `S3_REGION` | none | Yes, when `BLOB_DRIVER=s3` | Bucket region. |
| `S3_ACCESS_KEY_ID` | none | Yes, when `BLOB_DRIVER=s3` | Access key with read and write access to the bucket. |
| `S3_SECRET_ACCESS_KEY` | none | Yes, when `BLOB_DRIVER=s3` | Its secret key. |
| `S3_ENDPOINT` | none | No | For S3-compatible stores other than AWS (R2, MinIO, Backblaze), like `https://.r2.cloudflarestorage.com`. Must be an `http://` or `https://` URL. Addressed path-style. |
### Advanced [#advanced]
| Name | Default | Required | Description |
| ----------------- | ------------------------------------------------------------------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `SEND0_VERSION` | `latest` | No | The image tag compose runs, like `0.2.0` or `0.2`. Read by compose, not by send0. |
| `PUBLIC_URL` | `https://DOMAIN` | No | Where people reach send0, if not `https://DOMAIN`. An origin only (scheme, host and optional port), no path. Used for links in email, download links and the dashboard's CSRF check. |
| `ALLOW_SIGNUP` | `false` | No | `true` lets anyone sign up once the owner exists. `false`: others join by invite. Accepts `true`, `false`, `1` or `0`. |
| `LIMITS` | `none` | No | `hosted` turns on the hosted service's limits: reply-only free plan, daily send caps and inbox caps. `none` turns them off. |
| `HOST` | `0.0.0.0` | No | HTTP bind address inside the container. An IP address or hostname. |
| `PORT` | `3000` | No | HTTP port inside the container, 0 to 65535. If you change it, change `send0:3000` in the `Caddyfile`. |
| `TRUSTED_PROXIES` | `127.0.0.0/8,::1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,fc00::/7` | No | Proxies whose `X-Forwarded-For` is believed, as IPs or CIDR ranges, comma-separated, or `none`. The default covers Caddy on the compose network. |
### Set by compose and the image [#set-by-compose-and-the-image]
You don't normally set these. They're listed because send0 reads them.
| Name | Default | Required | Description |
| ---------------- | --------------------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------- |
| `DATABASE_URL` | Set by `compose.yml` from `POSTGRES_PASSWORD` | Yes | A `postgres://` or `postgresql://` URL. Compose's value overrides one in `.env`; edit `compose.yml` to use another Postgres. |
| `SMTP_PORT` | `2525` | No | Inbound SMTP port inside the container. Compose maps host port 25 to it. |
| `WEB_DIR` | `/app/web` in the image | No | The built dashboard. |
| `MIGRATIONS_DIR` | `/app/migrations` in the image | No | The SQL migrations. |
## Cloudflare [#cloudflare]
Plain settings are **vars** in `wrangler.jsonc`. Secrets are set with the Deploy button's prompts, `wrangler secret put`, or under **Workers & Pages > send0 > Settings > Variables and Secrets**. Either kind works for any setting; secrets keep the value hidden.
| Name | Default | Required | Description |
| ----------------------- | ----------------------------- | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `MAIL_DOMAINS` | `agents.example.com` (var) | Yes | Domains you receive mail on, comma-separated. The first is the default for new inboxes. Route each one's Email Routing catch-all to the Worker. The `example.com` placeholder is refused. |
| `SES_REGION` | `us-east-1` (var) | Yes | The AWS region of your verified SES identity. Must look like an AWS region. |
| `ALLOW_SIGNUP` | `false` (var) | No | `true` lets anyone sign up once the owner exists. Accepts `true`, `false`, `1` or `0`. |
| `SECRET_KEY` | none (secret) | Yes | Signs download links. At least 32 characters: `openssl rand -hex 32`. |
| `OWNER_EMAIL` | none (secret) | Yes, unless `ALLOW_SIGNUP=true` | The only email that can create the first account, which becomes the owner. |
| `SES_ACCESS_KEY_ID` | none (secret) | Yes | Access key of an IAM user allowed `ses:SendRawEmail` for your SES identity. |
| `SES_SECRET_ACCESS_KEY` | none (secret) | Yes | That user's secret key. |
| `MAIL_FROM` | `noreply@` | No | Sender of verification, password-reset and invite email. Must be an email address. |
| `PUBLIC_URL` | Each request's own origin | No | The one URL people and SDKs use, like `https://mail.example.com`. An origin only. Requests to any other hostname, workers.dev included, are redirected to it. |
| `OPERATOR_FORWARD_TO` | none | No | Where mail for `postmaster@` and `abuse@` on your mail domains goes, forwarded by Email Routing. Must be a verified destination address (Email Routing > Destination addresses). Unset, that mail is refused. |
| `SES_CONFIGURATION_SET` | none | No | The SES configuration set to send with. Unset, SES uses the account's default configuration set, if any. |
| `SES_EVENTS_TOKEN` | none | Set both or neither | At least 32 characters. The secret in the SNS subscription URL `https:///internal/ses-events?token=…`. |
| `SES_EVENTS_TOPIC_ARN` | none | Set both or neither | The SNS topic that carries SES events. Messages from any other topic are refused. |
| `TRUSTED_AUTHSERV_IDS` | `mx.cloudflare.net` | No | Comma-separated authserv-ids whose `Authentication-Results` headers are trusted. `mx.cloudflare.net` is always trusted; these add to it. |
| `LIMITS` | `none` | No | `hosted` turns on the hosted service's limits; `none` turns them off. |
### Bindings [#bindings]
`wrangler.jsonc` declares these. The Deploy button creates the resources.
| Binding | Type | Default name |
| ------------------------------ | ----------------------------- | --------------------------------------------- |
| `HYPERDRIVE` | Hyperdrive | Your Postgres |
| `BLOBS` | R2 bucket | `send0-blobs` |
| `EVENTS` | Queue (producer and consumer) | `send0-events` |
| `HUB` | Durable Object | class `Hub` |
| `ASSETS` | Static assets | The dashboard |
| `RL_KEY` `RL_KEY_SEND` `RL_IP` | Rate Limiting | [API rate limits](/reference/api#rate-limits) |
# Docker Compose (https://send0.dev/docs/self-hosting/docker)
The compose file runs three containers:
* `postgres`: Postgres 17, the only database
* `send0`: the image `ghcr.io/send0-dev/send0`, for linux/amd64 and linux/arm64. It serves the API and dashboard, receives mail on port 25, and delivers webhooks
* `caddy`: HTTPS in front of `send0`
The examples use `mail.example.com` for the dashboard and API (`DOMAIN`) and `agents.example.com` for inboxes (`MAIL_DOMAIN`). Replace both with your own.
### Check the prerequisites [#check-the-prerequisites]
* A Linux server with a public IP, and Docker with Docker Compose v2 (`docker compose version`).
* A domain whose DNS you control.
* **Inbound port 25 open.** Mail servers always deliver on port 25. Many cloud providers block it by default; some unblock it on request.
* A way to send: an SMTP relay (Postmark, Resend, Mailgun, SES SMTP, Google Workspace) or Amazon SES. The relay must be allowed to send from your mail domain.
Use a subdomain for mail, like `agents.example.com`. Its MX record points at send0, so it can't be a domain whose mail already goes to Google Workspace or Microsoft 365.
### Download the files [#download-the-files]
Get the three files from the release tag:
```bash
mkdir send0 && cd send0
for f in compose.yml Caddyfile .env.example; do
curl -fsSLO "https://raw.githubusercontent.com/send0-dev/send0-v2/v0.2.0/selfhost/$f"
done
cp .env.example .env
```
### Fill in `.env` [#fill-in-env]
Set the required values. Blank means unset.
```bash title=".env"
DOMAIN=mail.example.com
MAIL_DOMAIN=agents.example.com
OWNER_EMAIL=you@example.com
SECRET_KEY= # openssl rand -hex 32
POSTGRES_PASSWORD= # openssl rand -hex 24
SMTP_URL=smtp://user:password@smtp.example.net:587
SEND0_VERSION=0.2.0
```
| Variable | What it is |
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| `DOMAIN` | The hostname people and SDKs use. Caddy gets an HTTPS certificate for it. A hostname only: no `https://`, no path. |
| `MAIL_DOMAIN` | The domain your inboxes live on. Defaults to `DOMAIN`. For several domains, set `MAIL_DOMAINS` instead. |
| `OWNER_EMAIL` | The only email that can create the first account. That account owns the install. |
| `SECRET_KEY` | Signs download links. At least 32 characters. |
| `POSTGRES_PASSWORD` | The database password. Compose puts it inside `DATABASE_URL`, so it must be URL-safe: hex from `openssl` is. |
| `SMTP_URL` | Your outbound relay. Percent-encode special characters in the user and password. See [SMTP_URL](/self-hosting/email-setup#smtp_url). |
| `SEND0_VERSION` | The image tag. Defaults to `latest`. Pin it so upgrades happen when you choose. |
Generate the two secrets:
```bash
openssl rand -hex 32 # SECRET_KEY
openssl rand -hex 24 # POSTGRES_PASSWORD
```
To send through Amazon SES instead of a relay, set `MAILER=ses` and the `SES_*` variables. Every setting is in the [configuration reference](/self-hosting/configuration#docker-compose).
Set `POSTGRES_PASSWORD` before the first start. Postgres stores it when it creates the database
volume, so changing it later in `.env` breaks the connection.
### Add DNS records [#add-dns-records]
Point `DOMAIN` at the server and `MAIL_DOMAIN`'s MX at `DOMAIN`. Add your relay's SPF and DKIM records, and a DMARC record. With a server at `203.0.113.10`:
| Type | Name | Value |
| ------------ | --------------------------- | --------------------------------------------------------------------------------------------------------------- |
| A | `mail.example.com` | `203.0.113.10` |
| AAAA | `mail.example.com` | The server's IPv6 address, if it has one. See the [IPv6 note](/self-hosting/email-setup#ipv6-and-dockers-proxy) |
| MX | `agents.example.com` | `10 mail.example.com` |
| TXT | `agents.example.com` | `v=spf1 include: ~all` |
| TXT or CNAME | As your relay instructs | Your relay's DKIM record(s) |
| TXT | `_dmarc.agents.example.com` | `v=DMARC1; p=none; rua=mailto:dmarc@example.com` |
* To use a different name for the MX, like `mx.example.com`, point it at the server with an A record, use it in the MX record, and set `MX_HOSTNAME=mx.example.com`.
* If your DNS is on Cloudflare, leave these records **DNS only** (grey cloud). A proxied record doesn't carry SMTP.
[Mail setup and deliverability](/self-hosting/email-setup) explains each record.
### Start it [#start-it]
```bash
docker compose up -d
```
Compose pulls the images, starts Postgres, and then starts send0, which applies database migrations before it serves anything. Caddy requests the certificate for `DOMAIN` on first start, so DNS must already point at the server.
Watch it come up:
```bash
docker compose logs -f send0
```
Logs are JSON lines. When you see `"event":"server.listening"` and `"event":"smtp.listening"`, it's ready.
### Run the doctor [#run-the-doctor]
```bash
docker compose exec send0 send0 doctor
```
```text
send0 doctor
✓ config loaded (https://mail.example.com, mail for agents.example.com)
✓ database connected; migrations are up to date
✓ mailer SMTP relay smtp.example.net:587 accepted the connection and login
✓ mx agents.example.com MX → mail.example.com
✓ spf agents.example.com v=spf1 include:spf.example.net ~all
! port 25 mail.example.com could not connect (no answer within 5s); may be blocked by your provider, or hairpin NAT
- Test from another network: `nc -vz 25`.
✓ public url https://mail.example.com/healthz answered 200
6 ok, 1 warning(s), 0 failed
```
`✓` passed, `!` is a warning and `✗` failed. The command exits with `1` if anything failed; warnings don't fail it.
| Check | What it does |
| -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `config` | Validates every variable and lists all problems at once. If it fails, the other checks don't run. |
| `database` | Connects to Postgres. Warns about pending migrations; they run on the next start. |
| `mailer` | SMTP: connects to the relay and logs in, without sending. SES: checks the keys. Warns if the account is still in the SES sandbox, fails if sending is disabled. |
| `mx ` | One per mail domain. Passes if the MX points at `DOMAIN` or `MX_HOSTNAME`, or at a host with the same IP as `DOMAIN`. Warns if it points somewhere else, which is fine only if that host forwards port 25 here. Fails with no MX. |
| `spf ` | One per mail domain. Warns if there's no `v=spf1` TXT record. |
| `port 25 ` | Opens a TCP connection to the MX host on port 25, from inside the container. A warning can mean your provider blocks port 25, or that the server can't reach its own public IP (hairpin NAT). Test from another network: `nc -vz mail.example.com 25`. |
| `public url` | Fetches `PUBLIC_URL/healthz`. Warns if it isn't reachable or doesn't answer 200; usually DNS or a certificate that Caddy hasn't issued yet. |
Fix failures before going further. Read warnings: each one says what might be wrong.
### Sign up and test [#sign-up-and-test]
1. Open `https://mail.example.com` and sign up with `OWNER_EMAIL`.
2. Click the link in the verification email. It's sent through your relay, so it also proves sending works.
3. Follow the onboarding: name your workspace, create a first inbox and an API key. An inbox called `test` gets the address `test@agents.example.com`.
4. Email that address from your own mailbox. It appears in the dashboard within seconds.
5. Reply from the dashboard, and check that the reply lands in the same conversation in your mail client.
Then invite your team from the dashboard. Create more keys under **API keys**.
### Use it from your code [#use-it-from-your-code]
The API, MCP server and dashboard share one origin: `https://mail.example.com`. Point each client at it.
```ts
import { Send0 } from "@send0/sdk";
const send0 = new Send0({ apiKey: process.env.SEND0_API_KEY, baseUrl: "https://mail.example.com" });
```
```python
from send0 import Send0
send0 = Send0(base_url="https://mail.example.com") # reads SEND0_API_KEY
```
```bash
claude mcp add send0 \
--env SEND0_API_KEY=s0_live_… \
--env SEND0_BASE_URL=https://mail.example.com \
-- npx -y @send0/mcp
```
```bash
claude mcp add --transport http send0 https://mail.example.com/mcp \
--header "Authorization: Bearer s0_live_…"
```
```bash
curl https://mail.example.com/v1/inboxes \
-H "Authorization: Bearer $SEND0_API_KEY"
```
The OpenAPI spec is at `https://mail.example.com/openapi.json`.
## Ports [#ports]
| Host port | Container | What |
| ----------------- | ----------------- | ---------------------------------------------- |
| 25 | `send0` port 2525 | Inbound mail (the MX) |
| 80 | `caddy` | Certificate challenges, and redirects to HTTPS |
| 443 (TCP and UDP) | `caddy` | HTTPS and HTTP/3 |
The API and dashboard listen on port 3000 inside the compose network. Don't publish it: Caddy reaches it there, and send0 trusts `X-Forwarded-For` from private addresses (see `TRUSTED_PROXIES`).
## Volumes [#volumes]
| Volume | Mounted at | Holds |
| -------------- | -------------------------- | ------------------------------------------------------------------ |
| `postgres` | `/var/lib/postgresql/data` | The database |
| `blobs` | `/data` in `send0` | Raw mail and attachments, in `/data/blobs` (with `BLOB_DRIVER=fs`) |
| `caddy_data` | `/data` in `caddy` | Certificates and ACME account keys |
| `caddy_config` | `/config` in `caddy` | Caddy's saved config |
Compose names the project `send0`, so on the host the volumes are `send0_postgres`, `send0_blobs` and so on. Back up the first two; see [Backups](/self-hosting/operations#backups).
## HTTPS [#https]
The `Caddyfile` serves `DOMAIN` and proxies to `send0:3000`. Caddy gets a certificate from a public ACME CA (Let's Encrypt or ZeroSSL) on first start and renews it on its own. That needs:
* `DOMAIN` resolving to this server
* ports 80 and 443 reachable from the internet
If the certificate doesn't arrive, check `docker compose logs caddy`.
If you change `PORT`, change `send0:3000` in the `Caddyfile` to match.
## Running roles separately [#running-roles-separately]
The send0 process has three roles:
* `http`: the API, MCP and the dashboard
* `smtp`: the inbound mail server
* `worker`: webhook delivery, the outbox sweep every five minutes, and a daily purge of deleted workspaces
`send0 start` runs all three. `--only` runs a subset:
```bash
send0 start --only=http,smtp
send0 start --only=worker
```
For example, to run the worker in its own container, add a `compose.override.yml` next to `compose.yml` (Compose reads it automatically):
```yaml title="compose.override.yml"
services:
send0:
command: ["start", "--only=http,smtp"]
send0-worker:
image: ghcr.io/send0-dev/send0:${SEND0_VERSION:-latest}
command: ["start", "--only=worker"]
restart: unless-stopped
env_file: .env
environment:
DATABASE_URL: postgres://send0:${POSTGRES_PASSWORD}@postgres:5432/send0
depends_on:
postgres:
condition: service_healthy
volumes:
- blobs:/data
stop_grace_period: 30s
```
Every process runs pending migrations at start (one at a time, under a lock) and shares events through Postgres, so live streams and `wait` work across processes. Scheduled jobs run once per tick however many workers you run.
* Run at least one `worker`, or webhooks are never delivered.
* With `BLOB_DRIVER=fs`, every process must mount the same `blobs` volume. To run roles on different machines, use `BLOB_DRIVER=s3`.
* Each process opens up to 10 Postgres connections.
## Next [#next]
* [Mail setup and deliverability](/self-hosting/email-setup)
* [Upgrades, backups and troubleshooting](/self-hosting/operations)
# Mail setup and deliverability (https://send0.dev/docs/self-hosting/email-setup)
This page covers the Docker edition in detail. On Cloudflare, Email Routing receives mail and SES sends it; see [Deploy to Cloudflare](/self-hosting/cloudflare#after-deploying).
## Receiving [#receiving]
### MX record [#mx-record]
Mail for `MAIL_DOMAIN` goes wherever its MX record points. Point it at `DOMAIN`, or at `MX_HOSTNAME` if you set one:
```text
agents.example.com. MX 10 mail.example.com.
```
Use a subdomain that only send0 receives for. An MX record replaces any other mail server for that name.
### Port 25 [#port-25]
Other mail servers always deliver on port 25. Compose maps host port 25 to send0's SMTP server on port 2525 in the container. send0 only needs port 25 **inbound**; it sends through your relay on 587 or 465.
Some hosting providers block port 25, and some unblock it on request. To test from a machine outside the server's network:
```bash
nc -vz mail.example.com 25
```
A test from the server itself can fail even when the port is open, if the network doesn't route traffic back to its own public IP (hairpin NAT).
### What send0 accepts [#what-send0-accepts]
send0 checks every recipient during the SMTP session, so senders get a bounce instead of silent loss:
| Recipient | Reply |
| --------------------------------------------------------- | ---------------------------------- |
| An active inbox (plus-tags like `bot+task@` work) | `250`, accepted |
| An address that isn't an inbox | `550 5.1.1 Mailbox does not exist` |
| A disabled inbox | `550 5.2.1 Mailbox disabled` |
| A domain not in `MAIL_DOMAINS` | `550 5.7.1 Relaying denied` |
| `postmaster@` or `abuse@`, with `OPERATOR_FORWARD_TO` set | `250`, relayed to that address |
| A reserved name, like `admin@` | `550 5.1.1 Mailbox unavailable` |
Messages over about 25 MiB get `552 5.3.4 Message too big`. If the database or storage fails, send0 answers `451` and the sender retries later.
### Inbound authentication [#inbound-authentication]
send0's SMTP server checks SPF, DKIM, DMARC and ARC for each message itself. It adds an `Authentication-Results` header named after `MX_HOSTNAME`, and removes any header from the sender that claims the same name. Each message's `auth` field shows the SPF, DKIM and DMARC verdicts:
```json
"auth": { "spf": "pass", "dkim": "pass", "dmarc": "pass" }
```
SPF is checked against the IP that connected to send0. If another server relays mail to send0, SPF sees that server's IP.
On Cloudflare, Email Routing checks the message and send0 reads the `Authentication-Results` it writes as `mx.cloudflare.net`. That id is always trusted; `TRUSTED_AUTHSERV_IDS` adds to it.
### IPv6 and Docker's proxy [#ipv6-and-dockers-proxy]
When a sender connects over IPv6 and Docker's userland proxy forwards the connection, send0 sees the Docker bridge's address instead of the sender's. SPF then fails for those senders, and they all share one per-IP connection limit.
Avoid it in one of two ways:
* Give the MX hostname an A record only, with no AAAA. Senders then connect over IPv4. Set `MX_HOSTNAME` to a separate name, like `mx.example.com`, if `DOMAIN` needs an AAAA record for the web.
* Turn on native IPv6 in Docker, so connections reach the container with their real address.
### Inbound TLS [#inbound-tls]
send0 offers STARTTLS with a self-signed certificate for `MX_HOSTNAME`. Sending servers use STARTTLS opportunistically and accept that. To use a real certificate, mount it into the container and set `SMTP_TLS_CERT` and `SMTP_TLS_KEY` to the paths inside it. The container runs as user ID 10001, so it must be able to read the key.
### Reserved addresses [#reserved-addresses]
These local parts can't be inboxes, on any of your mail domains: `postmaster`, `abuse`, `admin`, `administrator`, `hostmaster`, `webmaster`, `support`, `noreply`, `no-reply`, `security`, `billing` and `mailer-daemon`. Mail to them is refused, and send0 won't send to them on your mail domains.
The exception is `postmaster@` and `abuse@`: mail servers are expected to accept mail for postmaster (RFC 5321), and abuse@ is where people report spam. Set `OPERATOR_FORWARD_TO` to an address you read, on another domain, and send0 passes that mail on instead of refusing it:
* **Docker** relays it through your mailer (`SMTP_URL` or SES). The relayed copy comes from `MAIL_FROM`, so it passes SPF and DMARC, with the original attached as `original.eml`. If the relay fails, the sender is asked to retry.
* **Cloudflare** forwards it with Email Routing. The address must be a verified destination address (Email Routing > Destination addresses), or forwarding fails.
Each message logs `message.forwarded_to_operator`. Point DMARC reports and other role addresses at a mailbox on another domain, like `dmarc@example.com`.
## Sending [#sending]
send0 builds each message, with threading headers and a `Message-ID` on your mail domain, and hands it to a relay or to SES. It doesn't sign the message itself: the relay or SES adds the DKIM signature.
| | SMTP relay (`MAILER=smtp`) | Amazon SES (`MAILER=ses`) |
| ------------------------------------ | ----------------------------- | ------------------------------------------------------- |
| Setup | One `SMTP_URL` | IAM keys, a configuration set, and SNS for events |
| DKIM | Signed by the relay | Signed by SES (Easy DKIM) |
| `delivered`, `bounced`, `complained` | No. Messages stay `sent` | Yes, with `SES_EVENTS_TOKEN` and `SES_EVENTS_TOPIC_ARN` |
| Bounce and complaint suppression | No. Your relay may do its own | Yes |
| Automatic pause | No | Yes |
### What you lose without SES events [#what-you-lose-without-ses-events]
send0 learns about delivery, bounces and complaints only from SES events. With a relay, or with SES but no events subscription:
* sent messages stay `sent`; there are no `message.delivered`, `message.bounced` or `message.complained` webhooks
* addresses that hard-bounce or complain aren't added to the suppression list, so send0 will send to them again
* sending isn't paused automatically when complaints pass 0.3% or hard bounces pass 5% (over 30 days, from 20 sent)
Watch bounces and complaints in your relay's dashboard instead.
### Domain records [#domain-records]
Set these up for each mail domain you send from:
* **Verify the domain at your relay** (or in SES). The relay must be allowed to send as `@agents.example.com`.
* **DKIM.** Add the records your relay gives you. This is what makes DMARC pass.
* **SPF.** Add your relay to a TXT record on the mail domain, like `v=spf1 include: ~all`. `send0 doctor` warns when the mail domain has no SPF record. Many relays also offer a custom return-path (bounce) domain; set it up as they describe.
* **DMARC.** Start with monitoring, then tighten once reports look clean:
```text
_dmarc.agents.example.com. TXT "v=DMARC1; p=none; rua=mailto:dmarc@example.com"
```
send0 gives the relay the inbox's address as the envelope sender. Most relays replace it with their own bounce address and handle bounces themselves. If yours keeps it, bounce reports arrive in the sending inbox as ordinary messages.
### Warm-up [#warm-up]
A new sending domain has no reputation. Start with low volume, mostly replies to people who wrote first, and grow slowly. The advice in [Deliverability](/guides/deliverability) applies to your domain too.
### SES [#ses]
To send through SES directly:
```bash title=".env"
MAILER=ses
SES_REGION=us-east-1
SES_ACCESS_KEY_ID=…
SES_SECRET_ACCESS_KEY=…
SES_CONFIGURATION_SET=send0
```
1. Verify the mail domain as an SES identity with Easy DKIM, and add the CNAME records.
2. Create the configuration set.
3. Give the IAM user `ses:SendRawEmail`, and `ses:GetAccount` for `send0 doctor`.
4. Request production access. Until then, SES only delivers to verified addresses, and the doctor warns about it.
For delivery events, add an SNS topic as the configuration set's event destination, set `SES_EVENTS_TOKEN` (`openssl rand -hex 32`) and `SES_EVENTS_TOPIC_ARN`, and subscribe `https://mail.example.com/internal/ses-events?token=` to the topic over HTTPS. send0 confirms the subscription.
## SMTP_URL [#smtp_url]
```text
smtp://user:password@host:587 STARTTLS
smtps://user:password@host:465 TLS from the start
```
* The port is required.
* `smtp://` connects in plain text and upgrades with STARTTLS when the relay offers it.
* When the URL has a user or password and the host isn't `localhost` or a loopback address, STARTTLS is **required**. If the relay doesn't offer it, sending fails instead of exposing the password.
* Add `?require_tls=false` only for a relay on a private network without TLS. Add `?require_tls=true` to require STARTTLS even without credentials. Both accept `true`, `false`, `1` or `0`.
* Port 465 uses TLS from the start, so it needs `smtps://`. `smtp://…:465` is rejected.
* send0 checks `SMTP_URL` when it starts, with the rest of the configuration. A problem shows up in the `Invalid configuration:` list and in `send0 doctor`, naming the rule but never the password.
* Percent-encode special characters in the user and password. `@` becomes `%40`, `/` becomes `%2F`, `:` becomes `%3A`:
```bash
python3 -c 'import sys, urllib.parse; print(urllib.parse.quote(sys.argv[1], safe=""))' 'p@ss/word'
# p%40ss%2Fword
```
### Examples [#examples]
The user and password are both your server's API token.
```bash
SMTP_URL=smtp://:@smtp.postmarkapp.com:587
```
The user is `resend`; the password is an API key.
```bash
SMTP_URL=smtps://resend:@smtp.resend.com:465
```
The user is the domain's SMTP login, so its `@` is encoded. For an EU domain, use `smtp.eu.mailgun.org`.
```bash
SMTP_URL=smtp://postmaster%40agents.example.com:@smtp.mailgun.org:587
```
Use SES SMTP credentials, not IAM access keys, and the endpoint for your region. SMTP passwords often contain `/` or `+`, so encode them.
```bash
SMTP_URL=smtp://:@email-smtp.us-east-1.amazonaws.com:587
```
With a relay, send0 gets no SES events. Use `MAILER=ses` to get them.
Workspace's SMTP relay service, set up in the Admin console to accept mail from your server's IP. There are no credentials, so require TLS explicitly.
```bash
SMTP_URL=smtp://smtp-relay.gmail.com:587?require_tls=true
```
For which senders and domains the relay accepts, see Google's SMTP relay settings.
`send0 doctor` logs in to the relay without sending, so run it after every change.
# Self-hosting overview (https://send0.dev/docs/self-hosting)
Self-hosted send0 is the same product as send0.dev: the same REST API, SDKs, MCP server and dashboard. It runs for one team, on infrastructure you own, and receives mail on your domain instead of `send0.email`.
The current release is [v0.2.0](https://github.com/send0-dev/send0-v2/releases/tag/v0.2.0).
## Two editions [#two-editions]
| | Docker Compose | Cloudflare |
| ------------------------ | -------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
| What runs where | One server: Postgres, the send0 container and Caddy | One Worker on your Cloudflare account, plus a Postgres you bring (reached through Hyperdrive) |
| Receiving mail | Built-in SMTP server on port 25. It checks SPF, DKIM, DMARC and ARC itself | Cloudflare Email Routing hands each message to the Worker |
| Sending mail | Any SMTP relay (`SMTP_URL`), or Amazon SES | Amazon SES only |
| Database | Postgres 17 in the compose file | Any Postgres. Neon's free plan works |
| Raw mail and attachments | A local Docker volume, or any S3-compatible bucket | An R2 bucket |
| Queue | pg-boss, inside Postgres. No Redis | Cloudflare Queues |
| Real-time (SSE, `wait`) | Postgres `LISTEN`/`NOTIFY` | Durable Objects |
| HTTPS | Caddy gets and renews the certificate | Cloudflare |
| Cost | A server with port 25 open, plus your relay's or SES's sending fees | Can start at $0 on free plans, plus SES sending fees |
| Free-tier notes | None | Workers Free allows 10 ms of CPU per request, which password sign-in can exceed. See [limits](/self-hosting/cloudflare#limits) |
## How to choose [#how-to-choose]
Pick **Docker Compose** if:
* you want mail and data on a machine you control
* you want to send through an SMTP relay you already use (Postmark, Resend, Mailgun, Google Workspace)
* your mail domain's DNS isn't on Cloudflare
Pick **Cloudflare** if:
* you don't want to run a server, or can't get port 25 open
* your mail domain's DNS is already on Cloudflare
* you're fine with Amazon SES for sending
## What's different from send0.dev [#whats-different-from-send0dev]
* **No plan limits.** Reply-only free accounts, daily send caps and inbox caps are off. You own the sending reputation.
* **Closed sign-up.** Only `OWNER_EMAIL` can create the first account, and that account owns the install. After that, people join by invite. Set `ALLOW_SIGNUP=true` to let anyone sign up after the owner.
* **Your domain.** Inboxes live on the domains in `MAIL_DOMAIN` or `MAIL_DOMAINS`, like `agent@agents.example.com`.
* **Point the SDKs at your URL.** Pass your server's URL as `baseUrl` (TypeScript), `base_url` (Python) or `SEND0_BASE_URL` (MCP). See [Docker Compose](/self-hosting/docker#use-it-from-your-code).
## License [#license]
The server is [AGPL-3.0](https://github.com/send0-dev/send0-v2/blob/main/LICENSE); the SDKs and the MCP server are MIT.
## Next [#next]
* [Configuration reference](/self-hosting/configuration): every setting for both editions
* [Mail setup and deliverability](/self-hosting/email-setup): MX, SPF, DKIM, DMARC and `SMTP_URL`
* [Upgrades, backups and troubleshooting](/self-hosting/operations)
# Upgrades, backups and troubleshooting (https://send0.dev/docs/self-hosting/operations)
Commands run in the directory with `compose.yml`. For the Cloudflare edition, see [Upgrading](/self-hosting/cloudflare#upgrading) there.
## Upgrading [#upgrading]
```bash
docker compose pull && docker compose up -d
```
send0 applies database migrations when it starts, before it serves anything. If several send0 processes start at once, they take turns under a lock.
* **Pin a version.** Set `SEND0_VERSION=0.2.0` in `.env` to stay on one release, or `0.2` to get its patch releases. `latest` moves to every new release. To upgrade, change it and run the commands above.
* **Back up first.** Migrations change the database, and there's no automatic rollback.
* **Check the release notes** on [GitHub releases](https://github.com/send0-dev/send0-v2/releases). If `compose.yml`, `Caddyfile` or `.env.example` changed, download them again from the new tag.
## Backups [#backups]
Back up the database and the blobs (raw mail and attachments). One without the other is incomplete: messages point at blobs by key.
```bash
docker compose exec -T postgres pg_dump -U send0 -Fc send0 > send0-$(date +%F).dump
docker run --rm -v send0_blobs:/data -v "$PWD":/backup alpine \
tar czf /backup/blobs-$(date +%F).tgz -C /data .
```
Also keep a copy of `.env`. Without `SECRET_KEY` and the passwords, a restore needs new secrets.
* With `BLOB_DRIVER=s3`, the blobs live in your bucket. Back it up with your provider's tools, like versioning or replication.
* On Cloudflare, back up Postgres with `pg_dump` and your connection string, or your provider's backups (Neon has point-in-time restore). R2 has an S3-compatible API, so any S3 tool can copy the `send0-blobs` bucket.
### Restoring [#restoring]
On a new server, with the same `.env`, `compose.yml` and `Caddyfile`:
```bash
docker compose up -d postgres
docker compose exec -T postgres pg_restore -U send0 -d send0 --clean --if-exists < send0-2026-10-07.dump
docker run --rm -v send0_blobs:/data -v "$PWD":/backup alpine \
tar xzf /backup/blobs-2026-10-07.tgz -C /data
docker compose up -d
```
Then run `docker compose exec send0 send0 doctor`, and move DNS if the server's IP changed.
## Retention [#retention]
send0 removes old mail on its own, every hour (the `worker` role on Docker, the cron on Cloudflare):
* When a message is older than its inbox's `retention_days` (1 to 30, default 7), its content is deleted: body, extracted fields, attachments and the raw `.eml`. The API then returns it with `"expired": true`. Addresses, subject and status stay, for reply-only sending, threading and bounce and complaint rates.
* 35 days after a message was created, the row is deleted, with threads left empty. Webhook events and delivery logs older than 35 days go too.
It works in batches of 1,000 for up to 20 seconds per run and logs a `retention` line when it changed anything. If it stops early, the next run carries on.
Where the blobs go depends on the storage:
* **Local disk** (`BLOB_DRIVER=fs`) and **R2** (the Cloudflare edition): send0 deletes them itself when it removes a message's content.
* **S3 or S3-compatible** (`BLOB_DRIVER=s3`): send0 doesn't delete from the bucket, so it never needs delete permission. Add a lifecycle rule that expires `raw/` and `att/` after 35 days:
```bash
aws s3api put-bucket-lifecycle-configuration --bucket \
--lifecycle-configuration '{"Rules":[
{"ID":"send0-raw","Filter":{"Prefix":"raw/"},"Status":"Enabled","Expiration":{"Days":35}},
{"ID":"send0-att","Filter":{"Prefix":"att/"},"Status":"Enabled","Expiration":{"Days":35}}]}'
```
This command replaces the bucket's existing lifecycle rules, so add these to them if it has any. MinIO has the same rules (`mc ilm rule add --prefix raw/ --expire-days 35 /`, and again for `att/`). On R2, where send0 deletes blobs itself, the same rule is an optional backstop for anything left behind, such as the blobs of a workspace purged before its mail expired:
```bash
npx wrangler r2 bucket lifecycle add send0-blobs send0-raw raw/ --expire-days 35
npx wrangler r2 bucket lifecycle add send0-blobs send0-att att/ --expire-days 35
```
## Logs [#logs]
send0 writes one JSON object per line, with an `event` field.
```bash
docker compose logs -f send0
docker compose logs send0 | grep '"event":"message.rejected"'
```
On Cloudflare, use Workers Logs in the dashboard, or `npx wrangler tail send0`.
| Event | Means |
| ------------------------------------------------------ | ---------------------------------------------------------------------------------------------- |
| `server.listening`, `smtp.listening`, `worker.started` | A role started |
| `message.received` | An inbound message was stored, with its sender, subject, thread and auth verdicts |
| `message.rejected` | An inbound message was refused, with the `reason` |
| `message.forwarded_to_operator` | Mail for `postmaster@` or `abuse@` went to `OPERATOR_FORWARD_TO` |
| `smtp.ingest_failed`, `smtp.lookup_failed` | The SMTP server couldn't store a message or look up an inbox; the sender got `451` and retries |
| `auth.email_failed` | A verification, reset or invite email couldn't be sent. Check the relay or SES |
| `sending.paused` | SES events pushed a workspace over the bounce or complaint limit |
| `queue.error`, `sweep.error` | Webhook delivery or the outbox sweep failed; it's retried |
| `sweep` | The outbox sweep found and re-queued missed events or deliveries |
| `retention` | Old messages were scrubbed or deleted, with counts; `complete: false` means more next run |
| `retention.blob_delete_failed`, `retention.error` | Deleting old blobs or the retention run failed; the next run retries |
| `sns.subscription_confirmed` | The SES events subscription is confirmed |
| `healthz.failed` | `/healthz` couldn't reach Postgres |
| `config.invalid`, `boot.failed`, `migrations.applied` | Cloudflare only: settings rejected, database boot failed, migrations ran |
| `server.crashed` | The process stopped on an unexpected error |
## Scaling [#scaling]
One container runs all three roles: `http`, `smtp` and `worker`. To split them, or run more than one, see [Running roles separately](/self-hosting/docker#running-roles-separately).
* Inbound memory is bounded by `SMTP_MAX_CLIENTS` (default 30 sessions, each up to about 25 MiB).
* Each send0 process opens up to 10 Postgres connections.
* Roles on different machines need `BLOB_DRIVER=s3`, because the `smtp` role writes blobs that `http` reads.
## Security checklist [#security-checklist]
* **Set `OWNER_EMAIL` and sign up right away.** Until that account exists, sign-up is open to `OWNER_EMAIL` only, and it must verify the address. Whoever controls that mailbox claims the install, so use an address you control.
* **Keep port 3000 unpublished.** Only Caddy should reach it. The compose file doesn't publish it.
* **Set `TRUSTED_PROXIES` if your network has other private hosts.** By default, send0 believes `X-Forwarded-For` from any loopback or private address, which covers Caddy. If other machines on a private network can reach port 3000, narrow it to the Docker network's range. It matters for rate limits and session records.
* **Rotate `SECRET_KEY`** by setting a new value and running `docker compose up -d`. It signs download links for blobs on local disk (and for every download on Cloudflare). Links already issued stop working; they last 15 minutes anyway. Sessions and API keys aren't affected. With `BLOB_DRIVER=s3`, links are pre-signed by S3 instead.
* **Treat `SES_EVENTS_TOKEN` as a password.** It's the only credential on `/internal/ses-events`, and it's part of the SNS subscription URL. send0 also refuses messages from any topic except `SES_EVENTS_TOPIC_ARN`. To rotate it, set a new token, then replace the subscription with the new URL.
* **Keep `.env` private.** It holds every secret. Don't commit it.
## Troubleshooting [#troubleshooting]
| Symptom | Cause | Fix |
| --------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `send0` keeps restarting; logs start with `Invalid configuration:` | A setting is missing or invalid. Each line names one | Fix `.env`, then `docker compose up -d`. `restart` doesn't reload `.env`. |
| Dashboard shows **send0 isn't configured yet** (Cloudflare) | A var or secret is missing or invalid | Fix the listed variables, deploy again, reload. See [If a setting is wrong](/self-hosting/cloudflare#if-a-setting-is-wrong). |
| Doctor: `✗ database cannot connect` | Postgres isn't up, or `POSTGRES_PASSWORD` changed after the volume was created | `docker compose ps` and `docker compose logs postgres`. Put the original password back, or change it inside Postgres with `ALTER USER`. |
| Doctor: `✗ mailer` with "rejected the login" | Wrong relay credentials | Check the user and password in `SMTP_URL`. See [SMTP_URL](/self-hosting/email-setup#smtp_url). |
| `Invalid configuration:` lists `SMTP_URL`, like "port 465 uses implicit TLS" or "is missing a port" | The URL doesn't match the rules for `SMTP_URL` | Fix it as the message says. See [SMTP_URL](/self-hosting/email-setup#smtp_url). |
| Doctor: `✗ mx … no MX record` | The mail domain has no MX | Add `MAIL_DOMAIN MX 10 DOMAIN`. DNS can take a while to update. |
| Doctor: `! port 25 … could not connect`, or no mail arrives at all | Your provider blocks port 25, or hairpin NAT | Run `nc -vz mail.example.com 25` from another network. If it fails, ask your provider to open inbound port 25, or move to one that allows it. |
| Doctor: `! public url … is not reachable` | DNS doesn't point at the server yet, or Caddy hasn't got a certificate | Check the A record and `docker compose logs caddy`. Ports 80 and 443 must be open. |
| Dashboard actions fail with `403` "Cross-site request refused." | The browser's URL doesn't match `PUBLIC_URL` (by default `https://DOMAIN`) | Open send0 at exactly that URL, or set `PUBLIC_URL` to the origin you use, then `docker compose up -d`. |
| Mail doesn't arrive in an inbox | MX, port 25, or the recipient | Run the doctor. Then `grep '"event":"message.rejected"'` in the logs: the `reason` says why. On Cloudflare, check the Email Routing catch-all points at the Worker. |
| Senders get `550 5.1.1 Mailbox does not exist` | No inbox with that address | Create the inbox. Check the domain is in `MAIL_DOMAIN` or `MAIL_DOMAINS`. |
| Senders get `550 5.7.1 Relaying denied` | The domain isn't one of your mail domains | Add it to `MAIL_DOMAINS`. |
| Sign-up fails with `signup_closed`: "This send0 is waiting for its owner to sign up." | No account exists yet, and the email isn't `OWNER_EMAIL` | Sign up with exactly `OWNER_EMAIL`. |
| Sign-up fails with `signup_closed`: "Sign-up is closed here." | The owner exists and `ALLOW_SIGNUP` is `false` | Ask a workspace owner or admin for an invite, or set `ALLOW_SIGNUP=true`. |
| The verification email never arrives | Sending fails (`auth.email_failed` in the logs), or SES is in the sandbox | Run the doctor. With SES in the sandbox, verify the recipient address in SES. Then use resend on the verification screen. |
| SPF fails for mail from some senders | They connect over IPv6 through Docker's proxy | See [IPv6 and Docker's proxy](/self-hosting/email-setup#ipv6-and-dockers-proxy). |
| `503` with `starting_up` (Cloudflare) | The database took too long to wake up | Retry after a few seconds. |
# What is send0? (https://send0.dev/docs/what-is-send0)
Most email APIs are built to **send** receipts and password resets. Receiving mail is an add-on that forwards raw MIME to a webhook. send0 starts from the **inbox**: each agent gets a real address that can receive, send and hold threaded conversations, with the parts agents need already done.
## What you get [#what-you-get]
* **An address in one call.** `name@send0.email`, ready in milliseconds. Plus-addressing (`bot+task42@`) tags messages without creating more inboxes.
* **Clean, structured messages.** Every inbound message is parsed. You get the new reply text without quoted history, the one-time code and verification link, SPF, DKIM and DMARC results, and a prompt-injection flag.
* **Wait, don't poll.** One long-poll call blocks until the email you need arrives.
* **Real threads.** Replies carry `In-Reply-To` and `References`, so conversations stay threaded for both your agent and the human on the other side.
* **Webhooks and live events on every plan.** Signed webhooks with retries and replay, plus a Server-Sent Events stream.
* **Safety built in.** Inbox-scoped API keys, human approval for outgoing mail, reply-only sending for free accounts, and automatic suppression of bounces and complaints.
* **Open source.** The server is AGPL-3.0; the [SDK](/reference/typescript-sdk) and [MCP server](/reference/mcp-tools) are MIT. You can [self-host](/self-hosting) the same API on your own domain, with Docker Compose or one Cloudflare Worker.
## How a message flows [#how-a-message-flows]
```text
Inbound sender ─▶ MX (send0.email) ─▶ accept/refuse during SMTP ─▶ store raw .eml
─▶ parse, extract code + links, check SPF/DKIM/DMARC, flag injection
─▶ thread ─▶ webhook + live stream + any waiting wait() call
Outbound your agent ─▶ policy checks (reply-only, suppression, daily cap)
─▶ MIME with threading headers, DKIM-signed ─▶ Amazon SES
─▶ delivered / bounced / complained events ─▶ webhooks
```
## Concepts at a glance [#concepts-at-a-glance]
| Object | ID | What it is |
| ---------- | ------- | ----------------------------------------------------------------- |
| Inbox | `ibx_…` | One address, with a send policy and metadata |
| Thread | `thr_…` | A conversation inside an inbox |
| Message | `msg_…` | Inbound or outbound, with extracted fields |
| Draft | `drf_…` | An outgoing message waiting for human approval |
| Attachment | `att_…` | A file, downloaded through a short-lived link |
| Webhook | `whk_…` | An endpoint that receives signed events |
| Event | `evt_…` | Something that happened, delivered by webhook and stream |
| API key | `key_…` | `s0_live_…` or `s0_test_…`, scoped to the org or specific inboxes |