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"msg = client.email.send(
from_={"email": "onboarding@messagebird.dev", "name": "Bird"},
to=["delivered@messagebird.dev"],
subject="Hello from Bird",
html="<p>My first Bird email.</p>",
)
print(msg.id, msg.status)package main
import (
"context"
"fmt"
"log"
"os"
bird "github.com/messagebird/bird-sdk-go"
"github.com/messagebird/bird-sdk-go/option"
)
func main() {
client, err := bird.NewClient(option.WithAPIKey(os.Getenv("BIRD_API_KEY")))
if err != nil {
log.Fatal(err)
}
msg, err := client.Email.Send(context.Background(), bird.EmailSendParams{
From: "onboarding@messagebird.dev",
To: []string{"delivered@messagebird.dev"},
Subject: "Hello from Bird",
HTML: "<p>My first Bird email.</p>",
})
if err != nil {
log.Fatal(err)
}
fmt.Println(msg.Id, *msg.Status)
}$message = $bird->email->send(
from: 'Bird <onboarding@messagebird.dev>',
to: ['delivered@messagebird.dev'],
subject: 'Hello from Bird',
html: '<p>My first Bird email.</p>',
);
echo $message->getId(), ' ', $message->getStatus();bird email send \
--from hello@yourdomain.com \
--to delivered@messagebird.dev \
--subject 'Hello from Bird' \
--html '<p>It works.</p>'curl -X POST https://us1.platform.bird.com/v1/email/messages \
-H "Authorization: Bearer bk_us1_..." \
-H "Content-Type: application/json" \
-d '{
"from": "hello@yourdomain.com",
"to": ["delivered@messagebird.dev"],
"subject": "Hello from Bird",
"html": "<p>It works.</p>"
}'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
| Field | Type | Required | Limits and notes |
|---|---|---|---|
| from | address | yes | Must be on a verified domain, or the onboarding domain |
| to | address[] | yes | 1 to 50 |
| cc, bcc | address[] | no | Up to 50 each |
| subject | string | inline sends | Up to 998 characters; omit on template sends |
| html, text | string | at least one | Up to 524,288 characters each; omit on template sends |
| reply_to | address[] | no | 1 to 25; replies hit every listed address |
| headers | object (string → string) | no | Up to 25; reserved names rejected (see custom headers) |
| parameters | object | no | Values for {{ tokens }} in inline content; up to 16 KB serialized; shared across recipients |
| tags | {name, value}[] | no | Up to 20; name ≤ 32 chars, value ≤ 64 chars; [A-Za-z0-9_-] only; names unique per send |
| metadata | object | no | Arbitrary JSON, up to 2 KB serialized |
| track_opens | boolean | no | Default true |
| track_clicks | boolean | no | Default true |
| category | string | no | marketing or transactional; defaults to the template's on a template send, otherwise marketing |
| ip_pool_id | string | no | ipp_... or ipp_shared; omit for your organization's default pool |
| template | object | no | Send a published template by id or slug, with parameters for its variables and an optional language |
| attachments | object[] | no | Up to 20; see attachments |
| scheduled_at | RFC 3339 timestamp | no | Single 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);msg = client.email.send(
from_={"email": "onboarding@messagebird.dev", "name": "Bird"},
to=["delivered@messagebird.dev"],
category="transactional",
template="welcome-email",
parameters={"first_name": "Jane"},
)
print(msg.id, msg.status)msg, err := client.Email.Send(context.Background(), bird.EmailSendParams{
From: "onboarding@messagebird.dev",
To: []string{"delivered@messagebird.dev"},
Category: "transactional",
Template: "welcome-email",
Parameters: map[string]any{"first_name": "Jane"},
})
if err != nil {
log.Fatal(err)
}
fmt.Println(msg.Id, *msg.Status)$message = $bird->email->send(
from: 'Bird <onboarding@messagebird.dev>',
to: ['delivered@messagebird.dev'],
category: 'transactional',
template: (new EmailMessageSendRequestTemplate())
->setSlug('welcome-email')
->setParameters(['first_name' => 'Jane']),
);
echo $message->getId(), ' ', $message->getStatus();bird email send \
--from 'hello@yourdomain.com' \
--to delivered@messagebird.dev \
--category transactional \
--template welcome-email \
--parameters '{"first_name":"Jane"}'curl -X POST https://us1.platform.bird.com/v1/email/messages \
-H "Authorization: Bearer bk_us1_..." \
-H "Content-Type: application/json" \
-d '{
"from": "hello@yourdomain.com",
"to": ["delivered@messagebird.dev"],
"category": "transactional",
"template": {
"slug": "welcome-email",
"parameters": { "first_name": "Jane" }
}
}'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
- Email templates: author and publish the templates you send here
- Categories: how marketing and transactional change suppression behavior
- Suppressions: who we will not deliver to, and why
- Scheduled sending: deliver at a future time with scheduled_at
- Testing sandbox: sandbox recipients and pre-verification sending
- API reference: the full request and response schemas