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: 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.

FieldTypeRequiredDescription
fromstringalwaysSender 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.
tostringalwaysRecipient address (exactly one, bare address). Lowercased before processing. Checked against the account suppression list.
subjectstringraw modeEmail subject, non-empty. In template mode it is optional — a non-empty subject overrides the template's stored subject.
htmlstringraw 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).
textstringraw mode*Plain-text body. Same rule as html. Providing both is recommended for deliverability.
templateIdstringtemplate mode*Id of a stored template. *Provide exactly one of templateId / templateAlias.
templateAliasstringtemplate mode*Alias of a stored template. Lowercased; resolved within the from address's root domain (aliases are unique per domain).
dataobjectnoMerge variables for template mode only (sending data without a template is a 400). See value rules below.

Example: raw send

curl
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."
  }'
Node.js (built-in fetch, Node 18+)
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)
Python (standard library only)
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
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

  • 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
{ "messageId": "0100018f2ab4c123-2d1e8a77-..." }

All errors are JSON: { "error": "<human-readable message>" }, sometimes with extra machine-readable fields (listed below). The complete set:

StatusError messageExtra fieldsMeaning
400Invalid JSON bodyBody was not parseable JSON.
400'from' must be a valid email addressMissing or malformed from.
400'to' must be a valid email addressMissing or malformed to.
400'subject' is requiredRaw mode without a subject.
400Provide 'html' and/or 'text'Raw mode with no body.
400Provide 'templateId' or 'templateAlias', not bothBoth template references sent.
400Provide either a template or raw 'html'/'text', not bothMixed template and raw fields.
400'data' requires 'templateId' or 'templateAlias'data sent in raw mode.
400'data' must be a JSON objectdata 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.
400Missing required template variables: ...missing: string[]Template variables without fallbacks absent from data.
401UnauthorizedNo Authorization: Bearer header.
401Invalid API keyUnknown or revoked key.
403This API key has no allowed domains configuredKey has an empty domain scope; issue a new key.
403This API key is not allowed to send from that domainallowedRootDomains: string[]from domain outside the key's scope.
403From address is not a verified sender identityThe from address (or its domain) is not verified in this install.
403This API key is not allowed to use that template's domainallowedRootDomains: string[]Template belongs to a domain outside the key's scope.
404Template not foundNo template with that id, or no template with that alias on the from domain.
405Method not allowedRequest was not a POST.
422Recipient is on the suppression listreason: "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.