# HTTP API

Canonical URL: https://sendbunny.co/docs/send-email-api

One CloudFront host. One `x-api-key` header. One URL tree under `/v1`. The key's **scopes** decide which routes work. The machine-readable contract is [/openapi.json](https://sendbunny.co/openapi.json). Language-tab examples live on [/docs/reference](https://sendbunny.co/docs/reference).

## Base URL

SendBunny is self-hosted. There is no shared API host. Your base URL is `https://<cloudfront-domain>/v1`, shown on the dashboard **API keys** page. It is a CloudFront URL, never a Lambda function URL and never an execute-api hostname.

- `POST /api` and `/agent/*` are gone. They return `404` with `{ "error" }` pointing at `/v1`.
- JSON in, JSON out. `Content-Type: application/json`.
- Browse lists use `cursor` + `limit` (default 25, max 50).
- MCP is not in this release. When it ships it will be `POST /mcp` on the same CloudFront host.

## Authentication and scopes

Every request needs the key in `x-api-key: sb...` (`Authorization: Bearer sb...` also works). The key is checked at the edge before anything in your account runs. Keys are created by an ADMIN. A key cannot mint another key. Treat keys as server-side secrets.

| Scope | Routes |
| --- | --- |
| `email:send` | `POST /v1/emails`, `GET /v1/emails`, `GET /v1/emails/{id}`, inbox compose |
| `inbox:read` | conversations, messages, bodies, attachments, receiving status |
| `inbox:reply` | `POST /v1/messages/{id}/reply` |
| `inbox:manage` | inbox addresses, receiving, trash/move/read-unread |
| `campaigns:read` / `campaigns:write` | campaign list, get, create, patch, start, cancel |
| `audience:read` / `audience:write` | lists, contacts, imports, contact properties |
| `templates:read` / `templates:write` | template CRUD |
| `domains:read` / `domains:write` | sender identities and domain status |
| `suppression:write` | list, add, and remove suppression |

Existing keys with no scopes array are treated as `email:send` only. Default for a new key is also send-only unless the admin checks more boxes.

## Errors

| Status | Meaning |
| --- | --- |
| 401 | Always `{ "error": "Invalid API key" }` for missing, unknown, or revoked keys. |
| 403 | Wrong scope, wrong domain, or unverified From. |
| 400 | Bad input. The message names the field. |
| 404 | Missing resource. Inbox items outside the key stay 404, not 403. |
| 422 | Recipient suppressed, or a reply envelope cannot be derived. |
| 409 | Campaign is not editable in its current status. |
| 500 | Unexpected failure. Always `{ "error": "Internal error" }`. |
| 502 | SES rejected the send. Always `{ "error": "Send failed" }`. |

## Send email

`POST /v1/emails` requires `email:send`. `to` is **always an array**. A string is a 400. Optional `cc` is also an array. Raw body (`subject` + `html`/`text`) or a template (`templateId` **or** `templateAlias` + optional `data`). Do not mix the two.

**curl:**

```bash
curl -X POST 'https://YOUR-DISTRIBUTION.cloudfront.net/v1/emails' \
  -H 'x-api-key: sbYOUR_KEY_HERE' \
  -H 'Content-Type: application/json' \
  -d '{
    "from": "hello@yourdomain.com",
    "to": ["success@simulator.amazonses.com"],
    "subject": "Hello from SendBunny",
    "html": "<p>It works!</p>"
  }'
```

200: `{ "messageId": "<ses-id>", "id": "<ledger-id>" }`. Look it up later with `GET /v1/emails/{id}` (ledger id or SES messageId). `GET /v1/emails?root=` lists the 90-day transactional log for one domain. These rows are not Inbox Sent.

## Template merge data

`data` is a JSON object. Nested objects flatten to dot paths (`{user:{name}}` fills `{{user.name}}`). Numbers and booleans are stringified. Arrays and null are rejected (`400` with `invalid[]`). `{{email}}` defaults to the first `to` address. A variable without an inline `{{key|fallback}}` must be present or the send is `400` with `missing[]`.

## Inbox

- `GET /v1/conversations?root=&folder=` — browse. `inbox:read`.
- `GET /v1/conversations/{threadId}` — messages in a thread.
- `GET /v1/messages/{id}` and `/body` and `/attachments/{n}`.
- `POST /v1/messages/{id}/reply` — `inbox:reply`. Recipients are derived from the thread. Do not send `to`. Defaults to send; pass `mode: draft` to preview.
- `POST /v1/inbox/messages` — new mail from an InboxAddress (`email:send`). Optional `mode: draft`.
- `POST /v1/inbox/actions` — trash / move / read-unread. Requires `inbox:manage`.
- `GET|POST /v1/inbox/receiving` — enable or disable inbound mail for a root.
- `GET|POST /v1/inbox/addresses` and `DELETE /v1/inbox/addresses/{address}` — manage requires `inbox:manage`.

## Campaigns, audience, templates, domains

These map 1:1 to the dashboard. Writes go through the same Lambda cores the UI uses. Campaign start and cancel require `campaigns:write`. Destructive audience deletes require `audience:write` and a `root` query so a key cannot wipe another domain.

- Campaigns: `GET|POST /v1/campaigns`, `GET|PATCH|DELETE /v1/campaigns/{id}`, `POST .../test`, `POST .../start`, `POST .../cancel`.
- Lists: `GET|POST /v1/lists`, `GET|PATCH|DELETE /v1/lists/{id}`, `POST /v1/lists/{id}/imports/presign`, `GET|POST /v1/lists/{id}/imports`.
- Contacts: `GET /v1/contacts?root=` or `?listId=`, `POST /v1/contacts`, `DELETE /v1/contacts/{email}?root=`.
- Templates: `GET|POST /v1/templates`, `GET|PATCH|DELETE /v1/templates/{id}`. Aliases resolve this domain, then Shared.
- Identities: `GET|POST /v1/identities` (create/get/delete only), `PATCH /v1/identities/{identity}/tracking`, `POST .../refresh`, `DELETE /v1/identities/{identity}`, `GET /v1/domains/{root}/status`.
- Suppression: `GET|POST /v1/suppression`, `DELETE /v1/suppression/{email}` (`suppression:write`, account-wide).
- Stats: `GET /v1/stats/domain?root=&range=` (`14d` / `1m` / `3m` / `1y` / `5y`).

## Not in the API

- Invite users, mint or revoke API keys, one-click Updates, onboarding wizard.
- Realtime inbox websocket events (dashboard AppSync only).
- MCP (`POST /mcp`) — later, same CloudFront host.