HTTP 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. Language-tab examples live on /docs/reference.
Last updated
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 /apiand/agent/*are gone. They return404with{ "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 /mcpon 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 -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/bodyand/attachments/{n}.POST /v1/messages/{id}/reply—inbox:reply. Recipients are derived from the thread. Do not sendto. Defaults to send; passmode: draftto preview.POST /v1/inbox/messages— new mail from an InboxAddress (email:send). Optionalmode: draft.POST /v1/inbox/actions— trash / move / read-unread. Requiresinbox:manage.GET|POST /v1/inbox/receiving— enable or disable inbound mail for a root.GET|POST /v1/inbox/addressesandDELETE /v1/inbox/addresses/{address}— manage requiresinbox: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.