Documentation
Sign inGet started

Sending email

POST /v1/email/messages sends one email. You build a single JSON payload with a sender, recipients, and content, and we return 202 Accepted with a message ID and deliver the email asynchronously. The API reference has the full request and response schemas.

A minimal send

The smallest valid payload is a from, at least one to recipient, a subject, and a body (html, text, or both). The from address has to be on a domain you have verified in this workspace, or on the onboarding domain.
const msg = await bird.email.send({
  from: { email: "onboarding@messagebird.dev", name: "Bird" },
  to: ["delivered@messagebird.dev"],
  subject: "Hello from Bird",
  html: "<p>My first Bird email.</p>",
});
console.log(msg.id, msg.status); // "em_…", "accepted"
Use your regional host (https://us1.platform.bird.com or https://eu1.platform.bird.com) with a matching bk_{region}_... key.
The recipient above is delivered@messagebird.dev, a sandbox address that always accepts mail. Placeholder domains do not: example.com, example.net, example.org, example.edu, test.com, and anything under the reserved .test, .example, .invalid, or .localhost TLDs are rejected with a 422, because a send there can only bounce and the bounces cost you sender reputation.

Sending before you verify a domain

During onboarding you can send from our shared onboarding domain, onboarding@messagebird.dev. Those sends skip the domain check but reach only verified members of your own workspace and sandbox addresses, under a daily recipient cap. The quickstart has the exact rules and limits.

Building the payload

Recipients

to, cc, and bcc each take up to 50 addresses, and to needs at least one. Each entry is a plain email string, an RFC 5322 mailbox string (Jane <jane@acme.com>), or an object with an optional display name.
Recipients on the workspace suppression list do not fail the request. It still returns a 202, and each suppressed recipient comes back on the read endpoints as status: rejected with the reason recipient_suppressed, including when that is every recipient on the send.

Content

subject is required for inline sends, up to 998 characters. Provide html, text, or both, each up to 524,288 characters. Send both where you can: a client that cannot render HTML falls back to the text part.
To personalize inline content, put {{ variable }} tokens in the subject or body and pass their values in parameters, up to 16 KB serialized. One set of values covers every recipient of the send, and a token with no matching key renders empty. For content you reuse, send a template instead.

Reply-to and custom headers

reply_to takes 1 to 25 addresses, in the same formats as recipients. Every recipient reply goes to all of them, so one or two is typical.
headers is a string-to-string object for your own headers, for example {"X-Campaign": "spring-2026"}, capped at 25 headers with values up to 998 characters. Three kinds of header come back as a 422:
  • Addressing and platform headers. Set the message's addressing through the dedicated fields (from, to, cc, bcc, reply_to, subject). Those names, and the headers we generate for you (Content-Type, Content-Transfer-Encoding, DKIM-Signature, Received, Return-Path), cannot be set here.
  • List-Unsubscribe and List-Unsubscribe-Post on a marketing send. We set a compliant one-click unsubscribe header on those ourselves. On a transactional send we leave yours exactly as you set it.
  • Any value with a carriage return or line feed.

Tracking

track_opens and track_clicks both default to true. Set either to false to skip open-pixel injection or link rewriting on this send. Tracking and metrics covers what each one changes in the message.

Category and IP pool

category classifies the content and sets suppression policy: marketing blocks delivery on every suppression reason, and transactional delivers through complaint and unsubscribe suppressions. It defaults to the template's category on a template send and to marketing otherwise, so set transactional explicitly for receipts, password resets, and other operational mail. Categories covers the choice. Mail submitted over SMTP takes its category from the key's SMTP configuration instead.
ip_pool_id picks the sending pool: a pool ID (ipp_...), or ipp_shared to route through the shared pool explicitly. Omit it for your organization's default pool. An unknown pool, or one with no dedicated IPs available to send from, is rejected with a 422.

Field reference

FieldTypeRequiredLimits and notes
fromaddressyesMust be on a verified domain, or the onboarding domain
toaddress[]yes1 to 50
cc, bccaddress[]noUp to 50 each
subjectstringinline sendsUp to 998 characters; omit on template sends
html, textstringat least oneUp to 524,288 characters each; omit on template sends
reply_toaddress[]no1 to 25; replies hit every listed address
headersobject (string → string)noUp to 25; reserved names rejected (see custom headers)
parametersobjectnoValues for {{ tokens }} in inline content; up to 16 KB serialized; shared across recipients
tags{name, value}[]noUp to 20; name ≤ 32 chars, value ≤ 64 chars; [A-Za-z0-9_-] only; names unique per send
metadataobjectnoArbitrary JSON, up to 2 KB serialized
track_opensbooleannoDefault true
track_clicksbooleannoDefault true
categorystringnomarketing or transactional; defaults to the template's on a template send, otherwise marketing
ip_pool_idstringnoipp_... or ipp_shared; omit for your organization's default pool
templateobjectnoSend a published template by id or slug, with parameters for its variables and an optional language
attachmentsobject[]noUp to 20; see attachments
scheduled_atRFC 3339 timestampnoSingle sends only, and never with template; see scheduled sending

Sending with a template

Instead of inline content, send a published template: set template to an object naming it by id (emt_...) or by slug, exactly one of the two, with its variable values in template.parameters. Omit subject, html, and text, because the template already has them.
const msg = await bird.email.send({
  from: { email: "onboarding@messagebird.dev", name: "Bird" },
  to: ["delivered@messagebird.dev"],
  category: "transactional",
  template: {
    slug: "welcome-email",
    parameters: { first_name: "Jane" },
  },
});
console.log(msg.id, msg.status);
A template's content is Liquid, so besides plain {{ variable }} substitution it can use filters, {% if %} conditionals, and {% for %} loops. Personalizing with variables lists the few constructs a publish rejects. template.parameters is where you put the values for the template's own parameters, keyed by name. Leave one out and the send is rejected with a 422 naming it. Everything else about the send behaves as it does inline, including recipients, tags, metadata, tracking, and attachments. What is specific to a template send:
  • Inline or templated, never both. Sending template alongside subject, html, or text is rejected with a 422, and so is putting the variable values in the top-level parameters field; on a template send they belong in template.parameters.
  • bird is the only reserved name. A placeholder path starting with bird. names our own data, like the unsubscribe link or the recipient's contact record, so a template.parameters key can't be named bird. Every other key is yours to define, and each is a flat single word: {"order_number": "A-1043"} fills {{ order_number }}.
  • A template send goes out immediately. template and scheduled_at are mutually exclusive, and combining them is rejected with a 422. To schedule, provide inline content; to send the template, drop scheduled_at.
  • A send uses the template's published version. Drafts are never sent. An unknown template is rejected with a 404, and a template with no published version with a 422.
  • language picks one of the template's languages. Omit it to send the template's default. Ask for one the template does not have, and its own on_missing_language setting decides whether the closest match goes out instead or the send is rejected. A template that sets language_source_required rejects a send that names no language at all.
  • The template's category is a default, and yours overrides it. Omit category and the send inherits the template's, so a transactional template does not need it repeated on every call.
Email templates covers authoring, publishing, and the constructs a template can hold.

Tags vs metadata

Both attach your own data to a send, and they differ in how you query it later:
  • tags are structured {name, value} pairs: up to 20 per send, name up to 32 characters, value up to 64, ASCII letters, digits, underscore, and hyphen only, and names unique within the send. Tags are filter dimensions, so you can filter the message list by tag and slice analytics and dashboard rollups by tag. Use them for low-cardinality labels like campaign, experiment_variant, or source.
  • metadata is an arbitrary JSON object, up to 2 KB serialized. We store it, return it on API reads, and echo it on every webhook event, so it suits context you want handed back to you: internal IDs, foreign keys, structured payloads.
Both ride on every webhook event beside the correlation IDs (email_id, recipient_id), so you can reconcile against your own records without a second lookup. Tag names and top-level metadata keys beginning __bird are rejected. You do not need to encode device, geography, mailbox provider, bounce type, or recipient domain into either field, because we capture each of those as an analytics dimension already.
Codebeispiel
{
  "tags": [{ "name": "campaign", "value": "onboarding" }],
  "metadata": { "user_id": "usr_12345", "order_id": "ord_98765" }
}

Attachments

attachments takes up to 20 files per message, as base64-encoded bytes inline. We reject a send whose estimated generated message size passes 20 MB, measured after base64 encoding, so keep raw attachment content at or below 15 MB for headroom. Attachments has the field contract, inline images, the blocked file types, and how to download an attachment back.

What a 202 means

A successful send returns 202 Accepted with an em_-prefixed message ID and status: accepted:
Codebeispiel
{
  "id": "em_01ky7ma8y2es1s2akzk53tmjn0",
  "status": "accepted",
  "category": "marketing",
  "from": { "email": "hello@yourdomain.com" },
  "to": [{ "email": "delivered@messagebird.dev" }],
  "subject": "Hello from Bird",
  "accepted_count": 1,
  "processed_count": 0,
  "delivered_count": 0,
  "deferred_count": 0,
  "bounced_count": 0,
  "complained_count": 0,
  "rejected_count": 0,
  "open_count": 0,
  "click_count": 0,
  "track_opens": true,
  "track_clicks": true,
  "created_at": "2026-07-23T13:58:20.866Z"
}
The 202 means we have durably accepted the send. Failures you can fix come back on the request itself as a 422: an unverified sender domain, or a field that does not validate. Per-recipient outcomes (delivered, bounced, deferred, complained) arrive afterwards through webhooks and the message read endpoints.
Two things follow from that:
  • Reads return state without the body. GET /v1/email/messages/{message_id} returns message and recipient state, never the html or text body. Bodies are stored separately: content storage is on by default per workspace, and while it is on the stored html and text stay available for 30 days from GET /v1/email/messages/{message_id}/content.
  • A read can briefly trail the send. A 404 on the read endpoints straight after a 202 means the message is not visible yet, so retry it in a moment.

Retrying safely

Send an Idempotency-Key header with a unique value per logical send. If a request succeeded but you never saw the response, replaying it with the same key returns the original result instead of sending a second email, with an Idempotency-Replay header so you can tell the two apart. Idempotency has the key format and retention.

Batch sending

To send many independent emails at once, reach for POST /v1/email/batches rather than looping over this endpoint: it takes up to 100 complete send payloads in one request, validates them all-or-nothing, and is safe to retry as a unit. Each item is exactly the payload on this page, with one exception: a batch item cannot have scheduled_at, so a scheduled send goes through this endpoint instead.

Billing

Email sends meter per recipient against your plan's monthly allowance, so a message to three recipients consumes three sends. Billing and usage covers the metering model and the live usage read.

Next steps