Transactional 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.
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:
POSTonly. Any other method returns405 { "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 is429, 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 accfield 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 <a@b.com> 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 below. |
Example: raw send
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": "<h1>On its way</h1><p>Order #1042 shipped today.</p>",
"text": "On its way — order #1042 shipped today."
}'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: "<h1>On its way</h1><p>Order #1042 shipped today.</p>",
}),
})
const body = await res.json()
if (!res.ok) throw new Error(`SendBunny ${res.status}: ${body.error}`)
console.log("sent", body.messageId)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": "<h1>On its way</h1><p>Order #1042 shipped today.</p>",
}).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 -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.
Template data rules
datamust be a JSON object. Values may be strings, numbers, booleans, or nested objects of those. Arrays andnullare 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 thetoaddress; an explicitdata.emailoverrides it.- Every template variable without an inline fallback must be present in
data, otherwise the send is rejected with400 { "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:
{ "messageId": "0100018f2ab4c123-2d1e8a77-..." }All errors are JSON: { "error": "<human-readable message>" }, 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
200means 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
502with 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
429from AWS before any send happens, so a429is 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
422for suppressed recipients rather than a delivery attempt.