Sending email
This guide covers the single-send endpoint, POST /v1/email/messages. You build one JSON payload with a sender, recipients, and content; Bird returns 202 Accepted with a message ID and delivers asynchronously. Full request and response schemas live in the API reference.
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 must be on a domain you have verified in the workspace, with one exception for testing covered under Sending before you verify a domain.
Codebeispiel
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 here is delivered@messagebird.dev, a sandbox address that always accepts mail, which is handy while you wire things up: placeholder domains like example.com and anything under the reserved .test, .example, .invalid, or .localhost TLDs are rejected with a 422, because they can't receive mail and the bounces would hurt your reputation.
Sending before you verify a domain
During onboarding you can send from the shared onboarding domain (onboarding@messagebird.dev). These sends skip the domain check but only deliver to verified members of your own workspace and sandbox addresses, under a daily recipient cap; the quickstart states the exact rules and limits.
Building up the payload
Recipients
to, cc, and bcc each take an array of up to 50 addresses; to requires at least one. Each entry can be a plain email string, an RFC 5322 mailbox string (Jane <jane@example.com>), or an object with an optional display name. Recipients on your workspace's suppression list are blocked per the active category's policy, and each one surfaces as a rejected recipient with an inspectable reason, never a silent drop. Suppression is applied asynchronously, after the 202: a suppressed recipient is accepted and then marked status: rejected (with reason recipient_suppressed) on the read endpoints, rather than failing the request up front. Even when every requested recipient is suppressed, the request is still accepted with a 202; each recipient comes back rejected.
Content
subject is required for inline sends (max 998 characters). Provide html, text, or both; at least one is required, and each is capped at 524,288 characters. Sending both is good practice: clients that can't (or won't) render HTML fall back to the text part.
To personalize inline content, put {{ variable }} tokens in the subject or body and pass their values in the parameters object (max 16 KB serialized). Values are shared across all recipients of the send, and a token with no matching key renders empty. For reusable content, send a stored template instead.
Reply-to and custom headers
reply_to is an array (1–25 entries, same address formats as recipients). Every recipient reply goes to all listed addresses, so one or two is typical.
headers is a string-to-string object for custom email headers, e.g. {"X-Campaign": "spring-2026"}, capped at 25 headers with values up to 998 characters. Three rules keep a custom header from silently misfiring:
- Addressing and platform headers are reserved. Set the message's addressing through the dedicated fields (from, to, cc, bcc, reply_to, subject), not headers; those names, and headers the platform generates (Content-Type, Content-Transfer-Encoding, DKIM-Signature, Received, Return-Path), are rejected with a 422.
- List-Unsubscribe and List-Unsubscribe-Post depend on the category. On transactional sends they are honored as-is. On marketing sends Bird sets a compliant one-click unsubscribe header for you, so supplying them is rejected with a 422 rather than silently overwritten.
- Values can't contain line breaks. A carriage return or line feed in a header value is rejected with a 422.
Tracking
track_opens and track_clicks both default to true. Set them to false to skip open-pixel injection or link rewriting for a given send. See tracking and metrics for what each one does to your message.
Category and IP pool
category classifies the content and controls suppression policy: marketing blocks delivery on all suppression reasons, while transactional delivers through complaint and unsubscribe suppressions. On the send and batch endpoints it defaults to marketing, so set it to transactional explicitly for operational mail like receipts and password resets. See categories for how to choose. (Mail submitted over SMTP takes its category from the key's SMTP configuration instead.)
ip_pool_id selects the sending pool: a pool ID (ipp_...) or ipp_shared to route through the shared pool explicitly. Omit it to use your organization's default pool. An unknown pool, or a pool with no dedicated IPs available, is rejected with a 422.
Field reference
| Field | Type | Required | Limits / notes |
|---|---|---|---|
| from | address | yes | Must be on a verified domain (or the onboarding domain) |
| to | address[] | yes | 1–50 |
| cc, bcc | address[] | no | Max 50 each |
| subject | string | inline sends | Max 998 characters; omit on template sends |
| html, text | string | at least one | Max 524,288 characters each; omit on template sends |
| reply_to | address[] | no | 1–25; replies hit all listed addresses |
| headers | object (string → string) | no | Max 25; reserved names rejected (see custom headers) |
| parameters | object | no | Values for {{ tokens }} in inline content; max 16 KB serialized; shared across recipients |
| tags | {name, value}[] | no | Max 20; name ≤ 32 chars, value ≤ 64 chars; [A-Za-z0-9_-] only; names unique per send |
| metadata | object | no | Arbitrary JSON, max 2 KB serialized |
| track_opens | boolean | no | Default true |
| track_clicks | boolean | no | Default true |
| category | string | no | marketing or transactional; default marketing |
| ip_pool_id | string | no | ipp_... or ipp_shared; omit for your org's default pool |
| template | object | no | Send a published template by id or name, with parameters for its variables |
| attachments | object[] | no | Max 20; see attachments |
| scheduled_at | RFC 3339 timestamp | no | Deliver at a future time; see scheduled sending |
Sending with a template
Instead of inline content, you can send a published email template: set template to an object naming the template by id (emt_...) or by name (exactly one of the two), with its variable values in template.parameters. Omit subject, html, and text; the template supplies them.
Codebeispiel
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": {
"name": "welcome-email",
"parameters": { "first_name": "Jane" }
}
}'The template supplies the subject and body; template.parameters fills its {{ variable }} placeholders (a placeholder with no matching key renders empty). Everything else about the send, including recipients, tags, metadata, category, tracking, and attachments, works exactly as an inline send. The rules to know:
- Inline or templated, not both. Sending template together with subject, html, or text is rejected with a 422. So is putting the variable values in the top-level parameters field: on a template send they belong in template.parameters.
- Sends use the current published version. A send resolves the template's live published version; drafts are never sent. An unknown template is rejected with a 404, and a template with no published version with a 422.
- The template's category does not ride along. Suppression policy follows the send's own category field, which still defaults to marketing; set category: "transactional" on the send even when the template is transactional content.
- Template sends can't be scheduled yet. Combining template with scheduled_at is rejected with a 422; see scheduled sending.
Authoring templates, drafts, and publishing are covered in email templates.
Tags vs metadata
Both attach your own data to a send, but they serve different jobs:
- tags are structured {name, value} pairs (max 20 per send; name ≤ 32 characters, value ≤ 64, ASCII [A-Za-z0-9_-] only; names unique within a send). They are first-class filter dimensions: filter the message list by tag and slice analytics and dashboard rollups by tag. Use tags for low-cardinality labels like campaign, experiment_variant, or source.
- metadata is an arbitrary JSON object (max 2 KB serialized). It is stored, returned on API reads, and echoed on every webhook event, but it is not a dashboard filter dimension. Use it for round-trip context: internal IDs, foreign keys, structured context you want handed back with each event.
Rule of thumb: if you want a dashboard breakdown by this dimension, use a tag; if you want context to round-trip through the API, use metadata. Both are echoed on every webhook event alongside the correlation IDs (email_id, recipient_id), so you can route and reconcile against your own records without an extra lookup. Tag names and top-level metadata keys starting with __bird are reserved and rejected. You don't need to encode device, geo, mailbox provider, bounce type, or recipient domain into tags; Bird captures those as first-class analytics dimensions automatically.
Codebeispiel
{
"tags": [{ "name": "campaign", "value": "onboarding" }],
"metadata": { "user_id": "usr_12345", "order_id": "ord_98765" }
}The async model: what 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": "onboarding@messagebird.dev" },
"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 is returned only after the send is durably accepted; it is never accepted and then silently dropped. Hard failures you can fix (unverified sender domain, field validation) fail immediately with a 422 instead. Suppression is not one of them: suppressed recipients are accepted and later surface as status: rejected, never a synchronous error. Actual delivery happens asynchronously: per-recipient outcomes (delivered, bounced, deferred, complained) arrive afterwards through webhooks and the message read endpoints.
Two consequences of the async design worth knowing:
- Reads return state, not content. GET /v1/email/messages/{id} returns message and recipient state, never the html or text body. Bodies are stored separately: content storage is on by default per workspace (you can opt out), and when it is on the stored html and text are available for up to 30 days from the dedicated GET /v1/email/messages/{id}/content endpoint.
- Reads can briefly trail the 202. A short window separates the 202 from the message becoming visible on the read endpoints; a 404 immediately after a send resolves itself within moments.
Attachments
Add files to a send with an attachments array: base64-encoded file bytes inline, up to 20 per message, within the per-message size budget. Inline images, the field contract, blocked file types, and downloading attachments back are covered in attachments.
Reserved fields
Three fields are part of the request vocabulary but not yet available. Including any of them returns a 422 (UnsupportedEmailFeature); they are reserved now so SDKs and integrations converge on one name before launch:
topic_id, contact_id, in_reply_to_message_id
Don't build around these yet; watch the API reference for availability.
Retrying safely
Send the Idempotency-Key header with a unique value per logical send, and retries become safe: if your first request succeeded but you never saw the response (timeout, dropped connection), replaying the same request with the same key returns the original result instead of sending a duplicate email. Replayed responses carry an Idempotency-Replay header so you can tell them apart. See idempotency for key format and retention.
Cost and billing
Email sends are metered against your plan's included monthly volume, counted per recipient: a message to three recipients consumes three sends. On paid plans, volume beyond the allowance bills as overage up to a hard ceiling; the Free plan stops at its caps instead. Billing & usage covers the metering model and the live usage read, and Plans & pricing has each plan's allowance.
Next steps
- Categories: how marketing vs transactional changes suppression behavior
- Suppressions: who Bird won't deliver to, and why
- Scheduled sending: deliver at a future time with scheduled_at
- Email metrics: opens, clicks, and the analytics they feed
- Testing sandbox: sandbox recipients and pre-verification sending
- API reference: full request and response schemas