# sendbunny > sendbunny is a self-hosted, all-in-one email platform — marketing campaigns, > a transactional email API, a shared team inbox, and analytics — that installs > into the customer's own AWS account with one click. It is a one-time purchase > ($199 once; optional yearly updates subscription at $99/yr), with sending > billed directly by AWS at Amazon SES's raw rate of > about $0.10 per 1,000 emails. Unlimited contacts and sending domains, no > monthly platform fee, and sendbunny never has access to the customer's AWS > credentials or data (zero-access by design). ## Key facts - Category: self-hosted email marketing platform / Amazon SES dashboard and UI - Runs entirely in the customer's own AWS account (Amazon SES + serverless infrastructure); no third party can suspend sending or hold data hostage, and the platform keeps running even if sendbunny disappears - Pricing: $199 one-time; no per-contact fees, no monthly fees; AWS usage billed by Amazon at cost - Common comparison points: Mailchimp, Sendy, Resend, Mailgun - Includes: campaign editor with live preview and templates, audience management with background CSV imports, REST transactional email API, DKIM/SPF/DMARC authenticated sending setup, RFC 8058 one-click unsubscribe, automatic bounce/complaint suppression, deliverability dashboard, invite-only team roles, shared inbox, audit log - Install: one pre-filled CloudFormation button in the AWS console, about ten minutes from checkout to sending; updates apply with one click in-app - The transactional API endpoint is unique per install (an AWS Lambda function URL shown in the customer's dashboard) — there is no shared api.sendbunny.co ## Docs Complete developer documentation with runnable examples. An AI agent integrating sendbunny should start at the quickstart; the full docs content is also inlined in https://sendbunny.co/llms-full.txt and the API contract is machine-readable at https://sendbunny.co/openapi.json (OpenAPI 3.1). - [Documentation index](https://sendbunny.co/docs) - [Quickstart: Send Your First Email via the API](https://sendbunny.co/docs/quickstart): Send a transactional email through your self-hosted SendBunny install in four steps: verify a domain, create an API key, copy your endpoint URL, POST JSON. - [Transactional Email API Reference](https://sendbunny.co/docs/send-email-api): Complete reference for SendBunny's transactional send endpoint: authentication, every request field, template sends, all error responses, and rate behavior. - [Email Templates and Merge Variables](https://sendbunny.co/docs/email-templates): How SendBunny templates work: merge-tag syntax with fallbacks, required-variable enforcement, aliases, and sending templates through the transactional API. - [OpenAPI 3.1 spec for the transactional API](https://sendbunny.co/openapi.json) - [llms-full.txt — this file plus the full docs content inline](https://sendbunny.co/llms-full.txt) # Full documentation content Everything an integration needs is below, verbatim from https://sendbunny.co/docs. The machine-readable API contract is at https://sendbunny.co/openapi.json. # Quickstart: send your first email Canonical URL: https://sendbunny.co/docs/quickstart This guide takes you from a fresh SendBunny install to a delivered transactional email. Every step is explicit — no context is assumed beyond the prerequisites listed below. ## Prerequisites - **A SendBunny install in your AWS account.** SendBunny is self-hosted: there is no shared SaaS endpoint. If you have not installed it yet, see [Deploy](https://sendbunny.co/deploy) — installation is a pre-filled CloudFormation template and takes about 10 minutes. - **Admin access to your SendBunny dashboard.** The install emails the first admin a temporary password. API keys can only be created by users in the ADMIN role. - **A verified sender domain.** In the dashboard, open your domain's onboarding page and complete DNS setup (DKIM/SPF/DMARC records). The API refuses to send from unverified addresses with HTTP 403. - **SES production access (for real recipients).** New AWS accounts start in the SES sandbox, which only delivers to verified recipient addresses. The dashboard includes a wizard for requesting production access. In the sandbox, sends to unverified recipients fail with HTTP 502. ## Step 1 — Create an API key - In your SendBunny dashboard, go to **API Keys** (left sidebar). - Click **New key**, name it, and select the sending domain(s) the key may use. A key can only send from the root domains it was issued for. - Copy the key immediately. It starts with `sb_` and is **shown exactly once** — SendBunny stores only a hash. If you lose it, revoke it and create a new one. ## Step 2 — Copy your API URL Because SendBunny runs in your own AWS account, your API endpoint is unique to your install. The same **API Keys** page in the dashboard shows your **Transactional API URL** — an AWS Lambda function URL that looks like `https://abc123xyz.lambda-url.us-east-1.on.aws/`. All requests are a `POST` to that URL. ## Step 3 — Send an email Replace the URL, the key, and `from` (must be an address on a domain you verified — see prerequisites — and allowed for this key in Step 1). In the SES sandbox, `to` must be a verified recipient or an [SES mailbox simulator](https://docs.aws.amazon.com/ses/latest/dg/send-an-email-from-console.html) address like `success@simulator.amazonses.com`. **curl:** ```bash curl -X POST 'https://YOUR-INSTALL.lambda-url.us-east-1.on.aws/' \ -H 'Authorization: Bearer sb_YOUR_KEY_HERE' \ -H 'Content-Type: application/json' \ -d '{ "from": "hello@yourdomain.com", "to": "success@simulator.amazonses.com", "subject": "Hello from SendBunny", "html": "

It works!

" }' ``` A successful send returns HTTP 200 with the SES message id: **Response:** ```json { "messageId": "0100018f2ab4c123-..." } ``` ## Troubleshooting | Status | Error | Fix | | --- | --- | --- | | 401 | `Unauthorized` / `Invalid API key` | Send the key as `Authorization: Bearer sb_...`. Check the key was not revoked. | | 403 | `This API key is not allowed to send from that domain` | The `from` domain is not in this key's allowed domains. The response lists `allowedRootDomains`; use one of those or issue a new key. | | 403 | `From address is not a verified sender identity` | Complete domain verification in the dashboard for the `from` domain (DNS records must be live). | | 400 | `'subject' is required` and similar | Field validation failed — the error message names the exact field. See the [API reference](https://sendbunny.co/docs/send-email-api) for the full request contract. | | 422 | `Recipient is on the suppression list` | The recipient previously bounced, complained, or was manually suppressed. Remove them in dashboard → Suppression only if you are certain the address is deliverable. | | 502 | SES error message | SES rejected the send — most often sandbox mode (unverified recipient) or sending-quota limits. The message is passed through from SES. | ## Next steps - [Full API reference](https://sendbunny.co/docs/send-email-api) — every field, every error, rate behavior. - [Email templates](https://sendbunny.co/docs/email-templates) — store templates in the dashboard, send with `templateAlias` + `data` merge variables instead of inline HTML. - [OpenAPI spec](https://sendbunny.co/openapi.json) — machine-readable contract for codegen and AI agents. --- # Transactional email API Canonical URL: https://sendbunny.co/docs/send-email-api SendBunny exposes one transactional endpoint: `POST` a JSON body to your install's API URL to send a single email. This page documents the complete contract — every field and every error the endpoint can return. A machine-readable version is published at [/openapi.json](https://sendbunny.co/openapi.json). ## Endpoint and base URL SendBunny is self-hosted, so there is no shared API host. Your endpoint is an AWS Lambda function URL unique to your install, shown as **Transactional API URL** on the dashboard's **API Keys** page. It looks like `https://abc123xyz.lambda-url.us-east-1.on.aws/`. - Method: `POST` only. Any other method returns `405 { "error": "Method not allowed" }`. - Body: JSON, UTF-8. Send `Content-Type: application/json`. AWS Lambda caps the request body at ~6 MB; Amazon SES separately caps total message size (10 MB by default). - One recipient per request. To send to N recipients, make N requests. - Responses from the API are JSON with `Content-Type: application/json`. The one exception is `429`, which is emitted by AWS Lambda itself when concurrency is capped — don't rely on its body shape. - **Not supported** (today): multiple recipients, `cc`/`bcc`, `replyTo`, attachments, and custom headers. Unrecognized JSON fields are silently ignored, so a `cc` field does not error — it just does nothing. ## Authentication Every request needs an API key in the `Authorization` header: `Authorization: Bearer sb_...`. Keys are created by an ADMIN user in dashboard → **API Keys**, are shown exactly once at creation (only a SHA-256 hash is stored), and can be revoked at any time. Each key is scoped to one or more **root domains** at creation. The key may only send from addresses whose registrable domain is in that list, and may only use templates that belong to those domains. A missing or malformed header returns `401 { "error": "Unauthorized" }`; an unknown or revoked key returns `401 { "error": "Invalid API key" }`. Treat keys as server-side secrets. The endpoint sends permissive CORS headers, but calling it from browser JavaScript would expose your key to every visitor — always call it from your backend. ## Request fields There are two send modes: **raw** (you provide `subject` + `html`/`text` inline) and **template** (you reference a stored template by `templateId` or `templateAlias`, optionally with `data` merge variables). Fields from the two modes cannot be mixed. | Field | Type | Required | Description | | --- | --- | --- | --- | | `from` | string | always | Sender address as a **bare address** (`hello@yourdomain.com` — display-name forms like `Ann ` are rejected). Must be on a **verified** sender identity, and its registrable domain must be in the API key's allowed domains. | | `to` | string | always | Recipient address (exactly one, bare address). Lowercased before processing. Checked against the account suppression list. | | `subject` | string | raw mode | Email subject, non-empty. In template mode it is optional — a non-empty `subject` overrides the template's stored subject. | | `html` | string | raw mode* | HTML body. *At least one of `html` / `text` is required in raw mode. Forbidden in template mode. Raw sends ship exactly the parts you provide — a plain-text part is **not** auto-derived from `html` (that only happens for stored templates without a text body). | | `text` | string | raw mode* | Plain-text body. Same rule as `html`. Providing both is recommended for deliverability. | | `templateId` | string | template mode* | Id of a stored template. *Provide exactly one of `templateId` / `templateAlias`. | | `templateAlias` | string | template mode* | Alias of a stored template. Lowercased; resolved within the `from` address's root domain (aliases are unique per domain). | | `data` | object | no | Merge variables for template mode only (sending `data` without a template is a 400). See [value rules](#template-data) below. | ## Example: raw send **curl:** ```bash curl -X POST 'https://YOUR-INSTALL.lambda-url.us-east-1.on.aws/' \ -H 'Authorization: Bearer sb_YOUR_KEY_HERE' \ -H 'Content-Type: application/json' \ -d '{ "from": "receipts@yourdomain.com", "to": "customer@example.com", "subject": "Your order shipped", "html": "

On its way

Order #1042 shipped today.

", "text": "On its way — order #1042 shipped today." }' ``` **Node.js (built-in fetch, Node 18+):** ```javascript const res = await fetch(process.env.SENDBUNNY_API_URL, { method: "POST", headers: { Authorization: `Bearer ${process.env.SENDBUNNY_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ from: "receipts@yourdomain.com", to: "customer@example.com", subject: "Your order shipped", html: "

On its way

Order #1042 shipped today.

", }), }) const body = await res.json() if (!res.ok) throw new Error(`SendBunny ${res.status}: ${body.error}`) console.log("sent", body.messageId) ``` **Python (standard library only):** ```python import json, os, urllib.request req = urllib.request.Request( os.environ["SENDBUNNY_API_URL"], method="POST", headers={ "Authorization": f"Bearer {os.environ['SENDBUNNY_API_KEY']}", "Content-Type": "application/json", }, data=json.dumps({ "from": "receipts@yourdomain.com", "to": "customer@example.com", "subject": "Your order shipped", "html": "

On its way

Order #1042 shipped today.

", }).encode(), ) with urllib.request.urlopen(req) as res: print(json.load(res)) # {"messageId": "..."} ``` ## Example: template send Templates are created in dashboard → **Templates** and can be given an alias (e.g. `password-reset`). Reference one by alias or id, and pass merge variables in `data`: **curl:** ```bash curl -X POST 'https://YOUR-INSTALL.lambda-url.us-east-1.on.aws/' \ -H 'Authorization: Bearer sb_YOUR_KEY_HERE' \ -H 'Content-Type: application/json' \ -d '{ "from": "hello@yourdomain.com", "to": "priya@example.com", "templateAlias": "password-reset", "data": { "name": "Priya", "reset_url": "https://app.example.com/reset/abc123" } }' ``` If the template has no stored plain-text body, SendBunny derives one from the rendered HTML automatically. Template syntax (`{{name}}`, `{{name|fallback}}`) and variable rules are documented in [Email templates](https://sendbunny.co/docs/email-templates). ## Template data rules - `data` must be a JSON **object**. Values may be strings, numbers, booleans, or nested objects of those. Arrays and `null` are rejected with a 400 that names the offending paths. - Nested objects are flattened to dot paths: `{ "user": { "name": "Priya" } }` fills `{{user.name}}`. Numbers and booleans are converted to strings. - `{{email}}` is always available and defaults to the `to` address; an explicit `data.email` overrides it. - Every template variable **without an inline fallback** must be present in `data`, otherwise the send is rejected with `400 { "error": "Missing required template variables: ...", "missing": [...] }` — SendBunny never sends an email with blank merge fields. ## Responses Success is `200` with the SES message id — the same id that later appears in delivery/bounce events in the dashboard: **200 OK:** ```json { "messageId": "0100018f2ab4c123-2d1e8a77-..." } ``` All errors are JSON: `{ "error": "" }`, sometimes with extra machine-readable fields (listed below). The complete set: | Status | Error message | Extra fields | Meaning | | --- | --- | --- | --- | | 400 | `Invalid JSON body` | — | Body was not parseable JSON. | | 400 | `'from' must be a valid email address` | — | Missing or malformed `from`. | | 400 | `'to' must be a valid email address` | — | Missing or malformed `to`. | | 400 | `'subject' is required` | — | Raw mode without a subject. | | 400 | `Provide 'html' and/or 'text'` | — | Raw mode with no body. | | 400 | `Provide 'templateId' or 'templateAlias', not both` | — | Both template references sent. | | 400 | `Provide either a template or raw 'html'/'text', not both` | — | Mixed template and raw fields. | | 400 | `'data' requires 'templateId' or 'templateAlias'` | — | `data` sent in raw mode. | | 400 | `'data' must be a JSON object` | — | `data` was an array, string, number, or null. | | 400 | `'data' values must be strings, numbers, booleans, or nested objects...` | `invalid: string[]` | Unsupported value types; `invalid` lists the dot paths. | | 400 | `Missing required template variables: ...` | `missing: string[]` | Template variables without fallbacks absent from `data`. | | 401 | `Unauthorized` | — | No `Authorization: Bearer` header. | | 401 | `Invalid API key` | — | Unknown or revoked key. | | 403 | `This API key has no allowed domains configured` | — | Key has an empty domain scope; issue a new key. | | 403 | `This API key is not allowed to send from that domain` | `allowedRootDomains: string[]` | `from` domain outside the key's scope. | | 403 | `From address is not a verified sender identity` | — | The `from` address (or its domain) is not verified in this install. | | 403 | `This API key is not allowed to use that template's domain` | `allowedRootDomains: string[]` | Template belongs to a domain outside the key's scope. | | 404 | `Template not found` | — | No template with that id, or no template with that alias on the `from` domain. | | 405 | `Method not allowed` | — | Request was not a POST. | | 422 | `Recipient is on the suppression list` | `reason: "BOUNCE" \| "COMPLAINT" \| "MANUAL"` | Recipient previously hard-bounced, complained, or was manually suppressed. The send is blocked to protect your sender reputation. | | 429 | (AWS-generated, no fixed body) | — | Throttled by AWS Lambda when the install caps endpoint concurrency. The request was rejected **before any send**, so retrying with backoff is always safe. | | 502 | (message passed through from SES) | — | Amazon SES rejected the send — commonly sandbox restrictions, unverified recipient (sandbox), or sending-quota limits. | ## Rate behavior and delivery - Sends are synchronous: a `200` means Amazon SES accepted the message in your AWS account. Delivery, bounce, open, and click events then flow into your dashboard's deliverability views. - Your AWS account's SES sending quota and rate apply — they are your own limits, not SendBunny platform limits. Quota-exceeded errors surface as `502` with the SES message. - Some installs cap the endpoint's concurrency (AWS Lambda reserved concurrency of 10 — an install-time option, not universal). Bursts beyond the cap receive HTTP `429` from AWS before any send happens, so a `429` is always safe to retry with exponential backoff. - There is no idempotency-key support: a retried request that already succeeded sends a second email. Only retry requests that certainly did not return `200` — treat a client-side timeout as possibly-sent. - Suppression (bounces, complaints, manual entries) is enforced on every transactional send automatically — expect `422` for suppressed recipients rather than a delivery attempt. --- # Email templates and merge variables Canonical URL: https://sendbunny.co/docs/email-templates Templates let you design an email once in the dashboard and send it through the [transactional API](https://sendbunny.co/docs/send-email-api) with per-recipient variables. The same merge-tag syntax also powers campaign personalization. ## Creating templates - Templates are created and edited in dashboard → **Templates** (rich editor or raw HTML mode). Each template belongs to one sending domain and stores a subject, an HTML body, and optionally a plain-text body. - A template may have an **alias** — a short handle like `password-reset` for API sends. Aliases are lowercase and unique **per root domain**, so `password-reset` on `yourdomain.com` and on `otherdomain.com` are different templates. - When a template has no stored plain-text body, API sends derive one from the rendered HTML automatically, so every email ships with a text part. ## Merge-tag syntax Variables appear in the subject, HTML body, or text body as double-brace tags. Keys may contain letters, digits, underscores, and dots. | Syntax | Behavior | | --- | --- | | `{{name}}` | Replaced with the value of `name`. **Required**: if any occurrence has no fallback, the variable must be supplied or the send fails. | | `{{name\|there}}` | Replaced with the value of `name`, or the literal `there` when `name` is not supplied. The fallback is everything after the first `\|` (it may itself contain `\|`, but not `}`), trimmed. | | `{{name\|}}` | Explicit empty-string fallback — renders as nothing when `name` is missing, and counts as optional. | | `{{user.plan}}` | Dot-path key, filled by nested `data`: `{ "user": { "plan": "Pro" } }`. | | `{{email}}` | Always available on API sends — defaults to the recipient (`to`) address unless `data.email` overrides it. | A variable counts as optional only if **every** occurrence across subject, HTML, and text carries a fallback. One bare `{{name}}` anywhere makes `name` required for the whole template. ## Required-variable enforcement SendBunny refuses to send an email that would render with blank merge fields. If required variables are missing from `data`, the API returns a 400 naming them, and nothing is sent: **400 response:** ```json { "error": "Missing required template variables: name, reset_url", "missing": ["name", "reset_url"] } ``` ## Sending a template Reference the template by `templateAlias` (resolved on the `from` address's root domain) or by `templateId`, never both. Raw `html`/`text` fields are forbidden in template mode; a non-empty `subject` overrides the template's stored subject. **curl:** ```bash curl -X POST 'https://YOUR-INSTALL.lambda-url.us-east-1.on.aws/' \ -H 'Authorization: Bearer sb_YOUR_KEY_HERE' \ -H 'Content-Type: application/json' \ -d '{ "from": "hello@yourdomain.com", "to": "priya@example.com", "templateAlias": "welcome", "data": { "name": "Priya", "user": { "plan": "Pro" } } }' ``` Value rules for `data` (full details in the [API reference](https://sendbunny.co/docs/send-email-api#template-data)): JSON object; strings, numbers, booleans, and nested objects only; arrays and `null` are rejected with the offending paths listed. ## Pages - [Home — features, pricing, cost comparison, FAQ](https://sendbunny.co/) - [Own your email platform — pay once, send forever](https://sendbunny.co/own-your-email-platform) - [Self-hosted email platform](https://sendbunny.co/self-hosted-email) - [Amazon SES email platform](https://sendbunny.co/amazon-ses-email-platform) - [Cheap email marketing](https://sendbunny.co/cheap-email-marketing) - [Privacy-first email platform](https://sendbunny.co/privacy-first-email) - [Serverless email platform](https://sendbunny.co/serverless-email-platform) - [Email guides index](https://sendbunny.co/guides) - [Privacy policy](https://sendbunny.co/privacy) - [Terms of service](https://sendbunny.co/terms) - [Refund policy](https://sendbunny.co/refund) ## Alternatives pages (https://sendbunny.co/alternatives/) mailchimp, klaviyo, activecampaign, brevo, beehiiv, kit, sendgrid, mailgun, postmark, resend, loops, customer-io, sendy, listmonk, freeresend, opensend ## Comparison pages (https://sendbunny.co/compare/) sendbunny-vs-mailchimp, sendbunny-vs-resend, sendbunny-vs-sendy, sendbunny-vs-listmonk, sendbunny-vs-sendgrid, sendbunny-vs-mailgun, sendbunny-vs-postmark, sendbunny-vs-brevo, sendbunny-vs-klaviyo, sendbunny-vs-loops, ses-vs-resend, ses-vs-sendgrid ## Guides (https://sendbunny.co/guides/) email-account-suspension, per-contact-pricing, paying-for-unsubscribed-contacts, own-your-email-list, shared-ip-deliverability, email-privacy-vendor-access, ses-vs-cheap-vps-mail-server, amazon-ses-needs-a-dashboard, resend-expensive-at-scale, mailchimp-pricing-increases, email-analytics-that-matter, diy-email-stack-ops-burden, what-if-email-vendor-shuts-down, campaigns-transactional-inbox, ses-sandbox-to-production, ses-production-access-denied, ses-account-paused, ses-bounce-rate-too-high, ses-open-click-tracking, ses-suppression-list, send-newsletter-with-amazon-ses, ses-ip-warmup ## Use cases (https://sendbunny.co/use-cases/) saas-founders, newsletters-creators, agencies, ecommerce, developer-transactional