.md

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 /api and /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/mcp on 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 as x-mcp-tool in /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.

ScopeRoutes
email:sendPOST /api/v1/emails, inbox compose, and looking up what was sent
emails:readGET /api/v1/emails, GET /api/v1/emails/{id} (look up only)
inbox:readconversations, messages, bodies, attachments, inbox addresses, smart inbox folders and settings, unread counts
inbox:replyPOST /api/v1/messages/{id}/reply
inbox:managemark read or unread, move conversations
inbox:settingsadd and remove inbox addresses, turn receiving on or off, receiving status, change smart inbox folders
campaigns:readcampaigns, plans, the daily allowance, the recipient report
campaigns:writecreate, edit, delete and test drafts
campaigns:sendstart or schedule, pause, resume, cancel, move to the front
audience:read / audience:writelists, contacts, fields, segments (and their preview), imports, topics, a person's subscriptions and history / add and edit them, record a person's choices
audience:deletedelete contacts (one or many), lists, segments and topics
audience:exportexport a domain's or a list's contacts to a file
automations:read / automations:writeautomations and who is in them / build, test, take a person out, send events
automations:manageturn automations on or off, add people, delete, read the webhook secret
templates:read / templates:writetemplates and test sends; shared templates also need a key for every domain
domains:read / domains:writeidentities, DNS records, delete preview, domain health, setup progress, account status / create, refresh, tracking, delete, hosted zones, setup choices
suppression:read / suppression:writelist suppression (key for every domain) / add and remove
images:read / images:writethe email image library
settings:read / settings:writedata 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

StatusMeaning
401Always { "error": "Invalid API key" } for missing, unknown, or revoked keys.
403Wrong scope, wrong domain, or unverified From.
400Bad input. The message names the field.
404Missing resource. Inbox items outside the key stay 404, not 403.
422Recipient 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.
409Wrong state (for example a campaign that is not editable now), or a yes only the person can give.
500Unexpected failure. Always { "error": "Internal error" }.
502SES 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
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 /body and /attachments/{n}.
  • POST /api/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 /api/v1/inbox/messages: new mail from an InboxAddress (email:send). Optional mode: draft.
  • POST /api/v1/inbox/actions: trash / move / read-unread. Requires inbox:manage.
  • GET /api/v1/inbox/unread?root=: unread counts for the domain, addresses and smart inbox folders (inbox:read).
  • GET|POST /api/v1/inbox/receiving and GET .../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/addresses and DELETE /api/v1/inbox/addresses/{address} (inbox:settings).
  • GET /api/v1/inbox/folders?address= and GET /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 .../recipients and GET .../recipients/{email}, and GET /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-exports and GET /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, and GET /api/v1/account/status.
  • Suppression: GET /api/v1/suppression (suppression:read, key for every domain), POST /api/v1/suppression and DELETE /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/data and GET|PATCH /api/v1/settings/sending (changes need a key for every domain), and GET|PUT /api/v1/email-settings for 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}/plan shows how a campaign will go out, day by day (?at= plans from another start time). needsChoice: true means it has not started and does not fit in one day.
  • Big campaigns are never spread silently. POST .../start refuses a campaign that does not fit in one day with a 400 that 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 400 if 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 .../pause holds a queuing or sending campaign (status PAUSED); emails already on their way finish. POST .../resume carries 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: pausedBy is then SENDBUNNY and pausedReason says 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) and gradualSafety (CAREFUL, BALANCED or RELAXED; anything else is a 400) on create or edit. Between days the campaign shows waitingFor: "GRADUAL_NEXT_DAY" and waitingUntil. 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}/subscriptions with {"all": false} or {"topics": {"<id>": false}} always works. A yes over a no is refused with a 409, unless your sign-up form sends signedUpAgain: true with 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/emails without topicId is transactional and goes to anyone not suppressed. With topicId it is marketing: one recipient, the footer with their own unsubscribe link, and a 422 for someone who said no (code: UNSUBSCRIBED, TOPIC_OFF or WAITING_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.

signup.html
<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>
server.js (Node 18+ and Express)
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).