Documentation
Sign inGet started

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, below.
Esempio di codice
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 — handy while you wire things up, since placeholder domains like @example.com and anything under .test, .example, .invalid, or .localhost are rejected with a 422 (they can't receive mail, and the bounces would hurt your reputation).
Haven't verified a domain yet? During onboarding you can send from the shared onboarding domain (for example onboarding@messagebird.dev). These sends skip the domain check but can only go to verified members of your own workspace and are subject to a daily recipient limit per organization.

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 — each one surfaces as a rejected recipient with an inspectable reason, never a silent drop. If every requested recipient is suppressed, the whole request fails with a 422.

Content

subject is required (max 998 characters). Provide html, text, or both — at least one is required, and each is capped at 512 KB. Sending both is good practice: clients that can't (or won't) render HTML fall back to the text part.

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"}.

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 (use it for marketing content), while transactional delivers through complaint and unsubscribe suppressions (use it for receipts, password resets, and similar operational messages). It defaults to transactional, independent of which endpoint you call — set it explicitly for marketing sends. See categories.
ip_pool 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

FieldTypeRequiredLimits / notes
fromaddressyesMust be on a verified domain (or the onboarding domain)
toaddress[]yes1–50
cc, bccaddress[]noMax 50 each
subjectstringyesMax 998 characters
html, textstringat least oneMax 512 KB each
reply_toaddress[]no1–25; replies hit all listed addresses
headersobject (string → string)noCustom email headers
tags{name, value}[]noMax 20; name ≤ 32 chars, value ≤ 64 chars; [A-Za-z0-9_-] only
metadataobjectnoArbitrary JSON, max 2 KB serialized
track_opensbooleannoDefault true
track_clicksbooleannoDefault true
categorystringnomarketing or transactional; default transactional
ip_poolstringnoipp_... or ipp_shared; omit for your org's default pool

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). 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 category, experiment_variant, or template_id.
  • metadata is an arbitrary JSON object (max 2 KB serialized). It is stored and returned on API reads — but it is not a dashboard filter dimension. Use it for round-trip context: internal IDs, foreign keys, structured context you want back when you fetch the message.
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. Webhook event payloads carry the correlation IDs (email_id, recipient_id) — fetch the message by email_id to recover its tags and metadata in your event handlers. 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.
Esempio di codice
{
  "tags": [{ "name": "category", "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:
Esempio di codice
{
  "id": "em_019c1930687b7bfa...",
  "status": "accepted",
  "category": "transactional",
  "recipient_count": 1,
  "created_at": "2026-06-10T14:30:00Z"
}
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, all recipients suppressed, field validation) fail immediately with a 422 instead. 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:
  • The body is never stored. html and text exist only long enough to hand the message to the delivery infrastructure; reads of GET /v1/email/messages/{id} return message and recipient state, not content.
  • Reads can briefly trail the 202. There is a short window between the 202 and the message becoming visible on the read endpoints; a 404 immediately after a send resolves itself within moments.

Reserved fields

The following fields are part of the request vocabulary but are not yet available. Including any of them returns 422 unsupported_feature — they are reserved now so SDKs and integrations converge on one name before launch:
attachments, scheduled_at, topic_id, template, campaign_id, audience_id, contact_id, broadcast_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.

Next steps