# 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](/docs/api/reference/create-email-message).

## 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](#sending-before-you-verify-a-domain).

```bash
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](/docs/guides/email/testing-sandbox) 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](/docs/get-started/send-your-first-email#about-the-onboarding-domain) 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](/docs/guides/email/suppressions) 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](#sending-with-a-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](/docs/guides/email/unsubscribe-links) 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](/docs/guides/email/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](/docs/guides/email/categories) for how to choose. (Mail submitted [over SMTP](/docs/guides/email/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](#reply-to-and-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](/docs/guides/email/attachments)                                   |
| `scheduled_at` | RFC 3339 timestamp       | no           | Deliver at a future time; see [scheduled sending](/docs/guides/email/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.

```bash
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](/docs/guides/email/scheduled-sending).

Authoring templates, drafts, and publishing are covered in [email templates](/docs/guides/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.

```json
{
  "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`:

```json
{
  "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`](/docs/api/reference/get-email-message-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](/docs/guides/email/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](/docs/api/reference/create-email-message) 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](/docs/guides/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](/docs/guides/billing-and-usage) covers the metering model and the live usage read, and [Plans & pricing](/docs/knowledge-base/billing/plans-and-pricing) has each plan's allowance.

## Next steps

- [Categories](/docs/guides/email/categories): how `marketing` vs `transactional` changes suppression behavior
- [Suppressions](/docs/guides/email/suppressions): who Bird won't deliver to, and why
- [Scheduled sending](/docs/guides/email/scheduled-sending): deliver at a future time with `scheduled_at`
- [Email metrics](/docs/guides/email/tracking-and-metrics): opens, clicks, and the analytics they feed
- [Testing sandbox](/docs/guides/email/testing-sandbox): sandbox recipients and pre-verification sending
- [API reference](/docs/api/reference/create-email-message): full request and response schemas