HTTP API
One CloudFront host. One x-api-key header. One URL tree under /api/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>/api/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. Everything lives under/api/v1.- JSON in, JSON out.
Content-Type: application/json. - Browse lists use
cursor+limit(default 25, max 50). - MCP is at
/api/mcpon the same CloudFront host; the dashboard API keys page shows the full URL. An AI agent signs in through OAuth (an admin grants Send mail, Inbox, or Full for chosen domains) or uses an API key the same way as/api/v1. Its tools call these routes with the same scopes: every route has one, named asx-mcp-toolin /openapi.json.
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 /api/v1/emails, inbox compose, and looking up what was sent |
emails:read | GET /api/v1/emails, GET /api/v1/emails/{id} (look up only) |
inbox:read | conversations, messages, bodies, attachments, inbox addresses, smart inbox folders and settings, unread counts |
inbox:reply | POST /api/v1/messages/{id}/reply |
inbox:manage | mark read or unread, move conversations |
inbox:settings | add and remove inbox addresses, turn receiving on or off, receiving status, change smart inbox folders |
campaigns:read | campaigns, plans, the daily allowance, the recipient report |
campaigns:write | create, edit, delete and test drafts |
campaigns:send | start or schedule, pause, resume, cancel, move to the front |
audience:read / audience:write | lists, contacts, fields, segments (and their preview), imports, topics, a person's subscriptions and history / add and edit them, record a person's choices |
audience:delete | delete contacts (one or many), lists, segments and topics |
audience:export | export a domain's or a list's contacts to a file |
automations:read / automations:write | automations and who is in them / build, test, take a person out, send events |
automations:manage | turn automations on or off, add people, delete, read the webhook secret |
templates:read / templates:write | templates and test sends; shared templates also need a key for every domain |
domains:read / domains:write | identities, DNS records, delete preview, domain health, setup progress, account status / create, refresh, tracking, delete, hosted zones, setup choices |
suppression:read / suppression:write | list suppression (key for every domain) / add and remove |
images:read / images:write | the email image library |
settings:read / settings:write | data retention, sending shares, a domain's email footer and double opt-in / change them (data and sending shares need a key for every domain) |
The scopes follow the dashboard's roles: what the dashboard leaves to an ADMIN (sending campaigns, deleting and exporting people, turning automations on, receiving, domains, settings) has scopes of its own. Keys issued before those scopes existed keep what they could do then, except that inbox:read no longer shows receiving status. A key limited to some addresses cannot make changes that cover the whole domain (receiving, the domain identity, hosted zones, domain setup). 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, a marketing email to someone who said no or from a domain without its footer address (see code), or a reply envelope cannot be derived. |
| 409 | Wrong state (for example a campaign that is not editable now), or a yes only the person can give. |
| 500 | Unexpected failure. Always { "error": "Internal error" }. |
| 502 | SES rejected the send. Always { "error": "Send failed" }. |
Send email
POST /api/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/api/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 /api/v1/emails/{id} (ledger id or SES messageId). GET /api/v1/emails?root= lists the 90-day transactional log for one domain. These rows are not Inbox Sent.
This is a transactional send (a receipt, a password reset). For a marketing email to one person, add a topicId: see Subscriptions and topics.
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 /api/v1/conversations?root=&folder=: browse.inbox:read.GET /api/v1/conversations/{threadId}: messages in a thread.GET /api/v1/messages/{id}and/bodyand/attachments/{n}.POST /api/v1/messages/{id}/reply:inbox:reply. Recipients are derived from the thread. Do not sendto. Defaults to send; passmode: draftto preview.POST /api/v1/inbox/messages: new mail from an InboxAddress (email:send). Optionalmode: draft.POST /api/v1/inbox/actions: trash / move / read-unread. Requiresinbox:manage.GET /api/v1/inbox/unread?root=: unread counts for the domain, addresses and smart inbox folders (inbox:read).GET|POST /api/v1/inbox/receivingandGET .../receiving/disable-preview: status, enable or disable inbound mail for a root (inbox:settings).GET /api/v1/inbox/addresses(inbox:read);POST /api/v1/inbox/addressesandDELETE /api/v1/inbox/addresses/{address}(inbox:settings).GET /api/v1/inbox/folders?address=andGET /api/v1/inbox/smart-inbox?address=(inbox:read);PATCH /api/v1/inbox/smart-inbox,POST .../smart-inbox/import,POST .../smart-inbox/dry-run(inbox:settings; a dry run is billed per email).
Campaigns, audience, templates, domains
These map 1:1 to the dashboard. Writes go through the same Lambda cores the UI uses. Campaign start, pause, resume, cancel and move to the front require campaigns:send. Deleting contacts, lists, segments and topics requires audience:delete, and contact deletes take a root so a key cannot wipe another domain.
- Campaigns:
GET|POST /api/v1/campaigns,GET|PATCH|DELETE /api/v1/campaigns/{id},GET .../plan,POST .../test,POST .../start,POST .../pause,POST .../resume,POST .../cancel,POST .../prioritize,GET .../recipientsandGET .../recipients/{email}, andGET /api/v1/campaigns/allowance. - Lists:
GET|POST /api/v1/lists,GET|PATCH|DELETE /api/v1/lists/{id},POST /api/v1/lists/{id}/contacts,DELETE /api/v1/lists/{id}/contacts/{email},POST /api/v1/lists/{id}/contacts/remove(up to 1,000),POST /api/v1/lists/{id}/imports/presign,GET|POST /api/v1/lists/{id}/imports. - Contacts:
GET /api/v1/contacts?root=or?listId=,POST /api/v1/contacts,GET|PATCH|DELETE /api/v1/contacts/{email}?root=,GET .../history,POST /api/v1/contacts/bulk(delete up to 1,000),POST /api/v1/contact-exportsandGET /api/v1/contact-exports/{exportId},GET|POST /api/v1/contact-properties. - Segments:
GET|POST /api/v1/segments,POST /api/v1/segments/preview,GET|PATCH|DELETE /api/v1/segments/{id},GET .../contacts. - Topics:
GET|POST /api/v1/topics,PATCH|DELETE /api/v1/topics/{id},POST .../restore. - Subscriptions:
GET|PATCH /api/v1/contacts/{email}/subscriptions,GET .../subscriptions/history,GET /api/v1/contacts/{email}/data,GET .../preferences-link,POST .../confirmation. - Automations:
GET|POST /api/v1/automations,GET|PATCH|DELETE /api/v1/automations/{id},POST .../start,.../pause,.../enroll,.../test,GET .../webhook-secret,GET .../runs,DELETE .../runs/{runId},POST /api/v1/events,GET /api/v1/contacts/{email}/automations. - Templates:
GET|POST /api/v1/templates,GET|PATCH|DELETE /api/v1/templates/{id},POST .../test,POST /api/v1/templates/shared. Aliases resolve this domain, then Shared. - Identities:
GET|POST /api/v1/identities,GET|DELETE /api/v1/identities/{identity},GET .../delete-preview,PATCH .../tracking,POST .../refresh,GET /api/v1/hosted-zones. - Domains:
GET /api/v1/domains/{root}/status,.../health,GET|POST .../onboarding, andGET /api/v1/account/status. - Suppression:
GET /api/v1/suppression(suppression:read, key for every domain),POST /api/v1/suppressionandDELETE /api/v1/suppression/{email}(suppression:write). - Stats:
GET /api/v1/stats/domain?root=&range=(14d/1m/3m/1y/5y). - Images:
GET|POST /api/v1/images,GET /api/v1/images/usage,POST /api/v1/images/{id}/confirm,DELETE /api/v1/images/{id}. - Settings:
GET|PATCH /api/v1/settings/dataandGET|PATCH /api/v1/settings/sending(changes need a key for every domain), andGET|PUT /api/v1/email-settingsfor one domain's footer and double opt-in.
Campaign sending and the daily allowance
Amazon allows your account a number of emails in any rolling 24 hours. Campaigns never send past it. Part of it, the headroom (10% unless you change it in Settings), is kept free for mail that cannot wait: receipts, password resets, automations, and inbox replies. GET /api/v1/campaigns/allowance shows the numbers, including campaignFree, what campaigns may still send now.
- Plan first.
GET /api/v1/campaigns/{id}/planshows how a campaign will go out, day by day (?at=plans from another start time).needsChoice: truemeans it has not started and does not fit in one day. - Big campaigns are never spread silently.
POST .../startrefuses a campaign that does not fit in one day with a400that gives the reason, unless the body is{"spreadOverDays": true}or the campaign uses Gradual Send. Scheduling is checked the same way, for the scheduled time, and the choice is asked on every start and schedule: an earlier one does not count. The other choices are to send to fewer people or to raise the Amazon sending limit first. An AI agent should ask the person which one they want. A campaign that could not finish within a year is refused either way. - Editing a scheduled campaign is checked again. Changing its audience, time, or Gradual Send settings is refused with a
400if it no longer fits in one day, unless it was scheduled to spread over several days. - Waiting is not failing. When the allowance is used up, a campaign shows
waitingFor: "DAILY_LIMIT"and carries on by itself as the allowance frees up. Nobody is skipped or marked failed for that. - Pause and resume.
POST .../pauseholds a queuing or sending campaign (statusPAUSED); emails already on their way finish.POST .../resumecarries on with everyone not sent yet. SendBunny pauses a campaign itself when Amazon refuses to send for the account, or when Gradual Send's safety level is passed:pausedByis thenSENDBUNNYandpausedReasonsays why. - Cancel, then delete. Cancel works on scheduled, queuing, sending, and paused campaigns. Deleting a queuing, sending, or paused campaign is refused: cancel it first.
- Gradual Send starts small and grows each day, for a new domain or a cold list. Set
gradualEnabled,gradualStartVolume(1 to 1,000,000),gradualGrowth("1","1.25","1.5","1.75"or"2", text or a number, stored as text) andgradualSafety(CAREFUL,BALANCEDorRELAXED; anything else is a400) on create or edit. Between days the campaign showswaitingFor: "GRADUAL_NEXT_DAY"andwaitingUntil. It is paused when bounces or complaints reach the safety level, judged once at least 100 emails have gone out since it started or was last resumed.
Subscriptions and topics
Topics are the kinds of email a domain sends, such as Newsletter, Promotions or Events. Every domain starts with one called Updates. GET /api/v1/topics?root= lists them with their ids. Every campaign and automation email has a topic.
- Each person has a status and a choice per topic. The status is subscribed, waiting to confirm (double opt-in), or unsubscribed from all email from the domain. Each topic is on or off for them.
GET /api/v1/contacts/{email}/subscriptions?root=shows both, with who chose what and when. - Anyone may record a no. Only the reader turns it back into a yes.
PATCH /api/v1/contacts/{email}/subscriptionswith{"all": false}or{"topics": {"<id>": false}}always works. A yes over a no is refused with a409, unless your sign-up form sendssignedUpAgain: truewith the person's own new sign-up. Adding, importing or editing someone never subscribes them again. - Lists only group people. Taking someone off a list, or deleting a list, does not change their subscription. To send to everyone on a domain, use
listId: "everyone". - Marketing needs your company name and postal address. The law asks for them in every marketing email. Set them once per domain with
PUT /api/v1/email-settings(settings:write). Until then campaigns and automation emails wait. Receipts and other email without a topic are never held. - Transactional or marketing.
POST /api/v1/emailswithouttopicIdis transactional and goes to anyone not suppressed. WithtopicIdit is marketing: one recipient, the footer with their own unsubscribe link, and a422for someone who said no (code:UNSUBSCRIBED,TOPIC_OFForWAITING_TO_CONFIRM) or a domain without its address (FOOTER_MISSING).
Your own sign-up form. The form posts to your server, and your server calls POST /api/v1/contacts with the ticked topics and the words beside the tick box. The API key stays on your server, never in the page; a key with only audience:write for that domain is enough. Unticked topics are sent as false, so the person gets only what they chose.
<form method="post" action="/subscribe">
<input type="email" name="email" required>
<label>
<input type="checkbox" name="topics" value="t4k2m9ab">
Newsletter
</label>
<label>
<input type="checkbox" name="topics" value="t7pq3xyz">
Sales and offers
</label>
<label>
<input type="checkbox" name="agree" required>
Yes, email me what I ticked. I can unsubscribe at any time.
</label>
<button>Subscribe</button>
</form>import express from "express"
const API = "https://YOUR-DISTRIBUTION.cloudfront.net/api/v1"
// The topic ids of the boxes on the form (GET /api/v1/topics?root=)
const TOPICS = ["t4k2m9ab", "t7pq3xyz"]
// The words beside the tick box, kept as proof of consent
const WORDS =
"Yes, email me what I ticked. I can unsubscribe at any time."
const app = express()
const form = express.urlencoded({ extended: false })
app.post("/subscribe", form, async (req, res) => {
if (!req.body.agree) return res.redirect("/signup?error=1")
const ticked = [].concat(req.body.topics ?? [])
const topics = {}
for (const id of TOPICS) topics[id] = ticked.includes(id)
const r = await fetch(`${API}/contacts`, {
method: "POST",
headers: {
"x-api-key": process.env.SENDBUNNY_API_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({
rootDomain: "yourdomain.com",
email: req.body.email,
agreed: { how: "form", note: WORDS },
topics,
signedUpAgain: true,
}),
})
res.redirect(r.ok ? "/thanks" : "/signup?error=1")
})
app.listen(3000)Anyone can type any address into a form, so turn on double opt-in ("doubleOptIn": true in PUT /api/v1/email-settings): new sign-ups then confirm by email before they get anything. For a settings screen in your own app, GET /api/v1/contacts/{email}/preferences-link?root= gives a person their own preferences page.
Not in the API
- Kept in the dashboard on purpose, for an ADMIN: API keys and MCP connections (a key never makes keys), inviting users, Updates, edge protection, flood protection, private access, and the smart inbox's model key.
- Webhooks: not offered in the dashboard yet, so not in the API either.
- Realtime inbox websocket events (dashboard AppSync only).