---
title: Send email
description: POST /v1/emails — deliver transactional email with a raw HTML body, or render against a pre-approved template.
---

import Endpoint from '../../../components/Endpoint.astro';
import Callout from '../../../components/Callout.astro';

Send a transactional email to a single recipient. You own the
subject and body; we handle deliverability, signing (DKIM, SPF,
DMARC), and the lifecycle events your app reacts to.

<Endpoint method="POST" path="/v1/emails" />

## Quick example

The body of your email is generated in your codebase the same
way you'd render it for any other transactional email service.
Pass the HTML (and optionally a plain-text fallback) and we'll
deliver it.

```bash
curl https://api.zevsend.com/v1/emails \
  -H "Authorization: Bearer sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "from": "noreply@acme.com",
    "to": "ada@example.com",
    "subject": "Your receipt for order 12345",
    "html": "<p>Hi Ada,</p><p>Thanks for your order. Your receipt is attached.</p>",
    "text": "Hi Ada, Thanks for your order. Your receipt is attached."
  }'
```

## Request body

| Field             | Type                | Required        | Description                                                                                          |
| ----------------- | ------------------- | --------------- | ---------------------------------------------------------------------------------------------------- |
| `to`              | string \| string\[\] | yes             | Primary recipient(s). A single email string or an array of up to 50 addresses. Combined with `cc` + `bcc` the total must stay at or under 50. |
| `cc`              | string\[\]          | no              | Carbon-copy recipients (visible to To + Cc). Up to 50 entries.                                       |
| `bcc`             | string\[\]          | no              | Blind-carbon-copy recipients. Delivered out-of-band — their addresses are NOT placed in the MIME headers. Up to 50 entries. |
| `reply_to`        | string              | no              | Reply-To header. Hitting Reply in the recipient's mail client pre-fills this address instead of `from`. |
| `attachments`     | object\[\]          | no              | File attachments. See [Attachments](#attachments) below. Up to 20 per send.                          |
| `from`            | string              | live mode       | Sender address. Accepts either a bare email (`"noreply@acme.com"`) or RFC 5322 name-addr (`"Acme Receipts <noreply@acme.com>"`). Must be on one of your verified + approved domains. Sandbox sends are locked to the platform sender. See [Sender display name](#sender-display-name) below. |
| `from_display_id` | string              | no              | `dn_…` public id of a registered + admin-approved alternate display name on the sending domain. Recommended over inlining the display string in `from` — id-based lookup is typo-proof. |
| `subject`         | string              | direct mode     | Subject line. Up to 200 characters.                                                                  |
| `html`            | string              | direct mode\*   | HTML body. At least one of `html` or `text` is required when not using a template.                   |
| `text`            | string              | direct mode\*   | Plain-text body. Strongly recommended alongside `html` for accessibility and spam-filter safety.     |
| `template_id`     | string              | template mode   | Public id of a pre-approved template (`tpl_…`). Mutually exclusive with `subject` / `html` / `text`. |
| `variables`       | object              | template mode   | Values for the template's declared `customer.*` variables.                                           |

<Callout type="info">
You can't mix the two modes — passing both `template_id` and
`subject`/`html`/`text` returns `400 mixed_send_mode`.
</Callout>

## Two modes for one endpoint

### Direct mode

The default path for transactional email. Your codebase owns the
body — render it with React Email, MJML, Handlebars, a string
template, whatever you already use — and we deliver it. This is
how teams migrating from Resend, Postmark, SendGrid, or SES
typically integrate.

```bash
curl https://api.zevsend.com/v1/emails \
  -H "Authorization: Bearer sk_live_..." \
  -d '{
    "from": "noreply@acme.com",
    "to": "ada@example.com",
    "subject": "Welcome to Acme",
    "html": "<p>Hi Ada, welcome aboard.</p>"
  }'
```

### Template mode

Reference a template by id and pass per-send variables. The
template owns the subject and body; you supply the values. Useful
when:

- Your support team needs to tweak wording without a code deploy.
- You want server-injected brand identity (`{{brand.name}}` etc.).
- You're sending the same message to many recipients with
  different per-recipient data.

```bash
curl https://api.zevsend.com/v1/emails \
  -H "Authorization: Bearer sk_live_..." \
  -d '{
    "to": "ada@example.com",
    "template_id": "tpl_01HXYZ...",
    "variables": { "first_name": "Ada", "order_id": "ORD-1234" }
  }'
```

See the [Templates](/guide/templates) guide for the full
walkthrough.

## Response

Every successful send returns the same shape. Fields are always
present, with empty arrays and `null` for anything the send did
not use — so you can rely on the structure without checking
which fields exist.

```http
HTTP/1.1 202 Accepted
Content-Type: application/json

{
  "id": "eml_01HXYZ...",
  "to": "ada@example.com",
  "to_addresses": ["ada@example.com"],
  "cc": [],
  "bcc": [],
  "reply_to": null,
  "from": "noreply@acme.com",
  "subject": "Your receipt for order 12345",
  "recipient_count": 1,
  "attachments_count": 0,
  "status": "sending",
  "sandbox": false,
  "created_at": "2025-05-24T12:34:56.789Z"
}
```

| Field               | Type        | Description                                                                                                |
| ------------------- | ----------- | ---------------------------------------------------------------------------------------------------------- |
| `id`                | string      | Public id of the message (`eml_…`). Stable for the message's lifetime.                                     |
| `to`                | string      | Primary recipient. Always a single string — the first address in the To list.                              |
| `to_addresses`      | string\[\]  | Full To list. At least one entry; the first matches `to`.                                                  |
| `cc`                | string\[\]  | Cc recipients. Empty array when none.                                                                      |
| `bcc`               | string\[\]  | Bcc recipients after any suppression-list drops. Empty array when none.                                    |
| `reply_to`          | string \| null | Reply-To header. `null` when not set.                                                                   |
| `from`              | string      | The resolved sender address.                                                                               |
| `subject`           | string      | The subject as delivered.                                                                                  |
| `recipient_count`   | integer     | Total recipients across To + Cc + Bcc (after Bcc suppression drops). This is the unit billed against your quota. |
| `attachments_count` | integer     | Number of attachments. `0` when none.                                                                      |
| `status`            | string      | Initial state. Almost always `sending` at this point.                                                      |
| `sandbox`           | boolean     | `true` for sandbox sends.                                                                                  |
| `created_at`        | string      | ISO 8601 UTC timestamp of the request.                                                                     |

<Callout type="info">
**Backward compatibility.** `to` remains a single string — the
primary recipient — so existing integrations that read
`response.to` as a string keep working unchanged. New fields like
`to_addresses`, `cc`, and `bcc` are additive; clients that ignore
unknown fields are unaffected.
</Callout>

## Sender display name

The `from` field accepts both a bare email and an RFC 5322
name-addr. zevsend always emits a properly composed `From:` header
on the wire so recipients see a brand name, not a bare address.

### Default — auto-injected brand

If you pass just the email, the display name is taken from the
**primary brand identity** you submitted in the dashboard for that
domain. This is the common path.

```bash
curl https://api.zevsend.com/v1/emails \
  -H "Authorization: Bearer sk_live_..." \
  -d '{
    "from": "noreply@acme.com",
    "to": "ada@example.com",
    "subject": "Welcome to Acme",
    "html": "<p>Hi Ada, welcome aboard.</p>"
  }'
```

Recipient sees:

```
From: Acme <noreply@acme.com>
```

The brand name is server-controlled — a leaked key can't change
how your sends present to recipients.

### Override — an approved alternate display name

When you need a second brand string on the same domain (e.g.
"Acme Receipts" vs your primary "Acme"), register it in the
dashboard under **Domains → \[your domain\] → Additional display
names** and submit it for review. The submission gets a stable
`dn_…` id immediately, but it only becomes usable on the API
once an admin approves it.

You can reference an approved alternate two ways. Both produce
the same `From:` header on the wire.

**By id (recommended)** — copy the `dn_…` from the dashboard and
pass it on `from_display_id`. The id is immune to typos, casing
drift, or accidental edits to the display string in your codebase.

```bash
curl https://api.zevsend.com/v1/emails \
  -H "Authorization: Bearer sk_live_..." \
  -d '{
    "from": "noreply@acme.com",
    "from_display_id": "dn_8AYnhCe0z1C4",
    "to": "ada@example.com",
    "subject": "Receipt for your order",
    "html": "<p>Thanks for your purchase.</p>"
  }'
```

**By name (RFC 5322)** — embed the display string inline. The
server normalises the match (case-insensitive, whitespace-
collapsed) and looks it up against your approved alternates.
Useful when you're migrating from another provider that already
emits this format. A typo or wording drift will fail the lookup
with `display_name_not_approved`.

```bash
curl https://api.zevsend.com/v1/emails \
  -H "Authorization: Bearer sk_live_..." \
  -d '{
    "from": "Acme Receipts <noreply@acme.com>",
    "to": "ada@example.com",
    "subject": "Receipt for your order",
    "html": "<p>Thanks for your purchase.</p>"
  }'
```

If neither path matches a registered + approved alternate (and
the string isn't an exact match for your primary brand), the
request is rejected. Send without any display string or id to
fall back to your primary brand automatically.

<Callout type="info">
Why review? The display name is what recipients perceive as the
sender. Pre-approval keeps a leaked API key from being able to
ship mail as "Acme Bank" from a verified Acme domain.
</Callout>

## Recipients — cc, bcc, reply-to

Pass `cc` and `bcc` as arrays of email addresses. The combined
count across `to` + `cc` + `bcc` must stay at or under 50.

```bash
curl https://api.zevsend.com/v1/emails \
  -H "Authorization: Bearer sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "from": "noreply@acme.com",
    "to": ["ada@example.com"],
    "cc": ["accounts@example.com"],
    "bcc": ["audit@acme.com"],
    "reply_to": "support@acme.com",
    "subject": "Your receipt for order 12345",
    "html": "<p>Hi Ada, your receipt is below.</p>"
  }'
```

**`to`** is the primary recipient. Pass a single string for
backward compatibility (`"to": "ada@example.com"`) or an array for
multiple addresses.

**`cc`** addresses appear in the visible Cc header alongside the
To list. Every recipient sees them.

**`bcc`** addresses receive the message but are not placed in any
visible header. To and Cc recipients can't see them; the bcc
address knows it was a bcc only because the message arrived. We
deliver bcc out-of-band per RFC 5322 §3.6.3.

**`reply_to`** sets the Reply-To header. When set, recipients
hitting Reply pre-fill this address instead of the From address.
Useful when you send from `noreply@` but want replies to land
with your support team.

<Callout type="info">
**Billing:** every recipient counts as one send against your plan
quota. An email to 1 To + 3 Cc + 2 Bcc is billed as 6 sends. This
mirrors how upstream networks charge us per recipient.
</Callout>

### Suppressed recipients

Each team carries a suppression list of addresses that previously
hard-bounced or complained.

- A suppressed address in **`to`** or **`cc`** rejects the whole
  send with `403 recipient_suppressed`. We name the blocked
  addresses in the error message — they're already visible to the
  caller anyway.
- A suppressed address in **`bcc`** is **silently dropped** and
  the send proceeds with the remaining recipients. Surfacing the
  block would leak the fact that the bcc address exists to the
  caller, which is the inverse of what bcc is for. The drop is
  logged in ops; the customer sees a normal `202` response.

To clear an address from the suppression list, use the dashboard
Suppressions page or the [`DELETE /v1/suppressions/:email`](/api/errors)
endpoint.

## Attachments

Send up to 20 files per message. Each attachment is an object
with `filename`, `content` (base64-encoded bytes), and
`content_type`.

```bash
curl https://api.zevsend.com/v1/emails \
  -H "Authorization: Bearer sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "from": "billing@acme.com",
    "to": "ada@example.com",
    "subject": "Your invoice",
    "html": "<p>Your invoice is attached.</p>",
    "attachments": [
      {
        "filename": "invoice-12345.pdf",
        "content_type": "application/pdf",
        "content": "JVBERi0xLjQKJ..."
      }
    ]
  }'
```

### Limits

| Limit                  | Value                  |
| ---------------------- | ---------------------- |
| Attachments per send   | 20                     |
| Per-file size          | 10 MB (after decode)   |
| Total per send         | 25 MB (sum)            |

### Allowed content types

We accept common business document and image types. Anything else
returns `400 unsupported_attachment_type`.

- **Documents:** `application/pdf`, `application/msword`,
  `application/vnd.openxmlformats-officedocument.wordprocessingml.document`,
  `application/vnd.ms-excel`,
  `application/vnd.openxmlformats-officedocument.spreadsheetml.sheet`,
  `application/vnd.ms-powerpoint`,
  `application/vnd.openxmlformats-officedocument.presentationml.presentation`
- **Data:** `application/json`, `text/csv`, `text/plain`,
  `text/calendar`
- **Archives:** `application/zip`,
  `application/x-zip-compressed`
- **Images:** `image/png`, `image/jpeg`, `image/gif`,
  `image/webp`, `image/svg+xml`

Executables, scripts, and other delivery-risky types are
deliberately blocked. If you have a legitimate need for a type
outside this list, contact support.

### Encoding the content

The `content` field expects standard base64 (RFC 4648). Most
languages and HTTP clients have a built-in encoder.

```js
// Node
const fs = require('fs');
const content = fs.readFileSync('./invoice.pdf').toString('base64');
```

```py
# Python
import base64
with open('invoice.pdf', 'rb') as f:
    content = base64.b64encode(f.read()).decode()
```

```ruby
# Ruby
require 'base64'
content = Base64.strict_encode64(File.binread('invoice.pdf'))
```

## Lifecycle

| State        | Meaning                                                                                |
| ------------ | -------------------------------------------------------------------------------------- |
| `queued`     | Persisted but not yet handed off.                                                       |
| `sending`    | Handed off to the upstream network; awaiting accept.                                    |
| `sent`       | Accepted by the upstream network and in transit.                                        |
| `delivered`  | Confirmed accepted by the recipient's inbox provider.                                   |
| `bounced`    | Hard bounce — the address is invalid or the inbox refused it permanently.               |
| `complained` | Recipient flagged the message as spam.                                                  |
| `failed`     | Rejected at submit or permanent failure before reaching the inbox.                       |

State changes fire as [webhook events](/webhooks/events). You can
also look up the current state from the dashboard.

## Retrying safely with an idempotency key

If your process crashes after calling us but before it records the
result, you cannot know whether the email went out. Retrying might send
it twice; not retrying might lose it.

Send an `Idempotency-Key` header and the retry is safe. The first
request sends the email and we remember the response. A retry with the
same key returns that same response without sending again.

```bash
curl -X POST https://api.zevsend.com/v1/emails \
  -H "Authorization: Bearer sk_live_..." \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: order-1042-receipt" \
  -d '{
    "from": "receipts@acme.com",
    "to": "ada@example.com",
    "subject": "Your receipt",
    "html": "<p>Thanks for your order.</p>"
  }'
```

The header is optional. Leave it off and the endpoint behaves exactly
as it always has.

**Choosing a key.** Use something your own system can derive again
after a restart, such as the id of the thing the email is about. A
random value generated fresh on each attempt defeats the purpose. Keys
are up to 255 characters and are scoped to the API key that sent them,
so two integrations on the same team never collide.

**How long it lasts.** A key is remembered for 24 hours. That is the
window in which a retry makes sense; after it, the same key is treated
as a new request.

**A replayed response** carries `Idempotency-Replayed: true` and is
byte-for-byte what the first call returned, including the original
message `id`. Nothing is sent.

| Status | Code | Cause |
| ------ | ---- | ----- |
| `409` | `idempotency_key_reused` | The same key was used with a different request body. Use a new key for a different email. |
| `409` | `idempotency_request_in_progress` | An earlier request with this key is still running. Retry in a few seconds. |

If a send fails, the key is released, so you can retry it with the same
key once you have fixed the cause.

## Pricing

Email is included in your plan up to the monthly quota. Past the
quota, each send drains the template's per-send price from Zev
Credit. Most email templates ship priced at zero, so customers
who stay within their plan quota never see a charge.

See the [Pricing guide](/guide/pricing#email) for the charge
cascade and how Zev Credit is debited.

## Errors

| Status | Code                       | Cause                                                            |
| ------ | -------------------------- | ---------------------------------------------------------------- |
| `400`  | `invalid_email`            | `to` isn't a valid email address.                                |
| `400`  | `missing_body`             | Direct mode requires `subject` and at least one of `html`/`text`. |
| `400`  | `mixed_send_mode`          | You passed both `template_id` and direct-mode fields.            |
| `400`  | `sandbox_from_locked`      | In sandbox, `from` must be the platform sender.                   |
| `403`  | `from_domain_unverified`   | The `from` address isn't on a verified domain on your team.      |
| `403`  | `key_domain_scope`         | The key is pinned to a different domain than the `from`.         |
| `400`  | `invalid_from_address`     | The `from` value is not a valid bare email or RFC 5322 name-addr. |
| `400`  | `display_name_not_approved`| The display name on `from` is not a registered + approved alternate for this domain. Submit it in the dashboard or omit it. |
| `400`  | `display_name_id_not_found`| `from_display_id` doesn't match any record. Check the value in the dashboard. |
| `400`  | `display_name_id_wrong_domain` | The `dn_…` belongs to a different domain than your `from` addr. |
| `400`  | `display_name_id_not_approved` | The display-name record exists but isn't admin-approved yet. |
| `403`  | `no_approved_brand_domain` | You're live but no verified domain on the team is approved yet. |
| `403`  | `recipient_not_allowed`    | Sandbox: recipient hasn't been verified on this team.            |
| `403`  | `recipient_suppressed`     | Recipient is on the suppression list (bounce/complaint).         |
| `404`  | `template_not_found`       | Template mode: no such template on this team.                    |
| `409`  | `wrong_template_channel`   | The template is for SMS or WhatsApp, not email.                  |
| `409`  | `idempotency_key_reused`   | The `Idempotency-Key` was already used with a different body.    |
| `409`  | `idempotency_request_in_progress` | An earlier request with this `Idempotency-Key` is still running. |
| `422`  | `missing_variable`         | Template mode: a required variable was missing.                  |
| `422`  | `unknown_variable`         | Template mode: you passed a variable the template doesn't declare. |

## Suppression list

When a recipient bounces or complains, we automatically add them
to your team's suppression list. Further sends to that address
return `403 recipient_suppressed` without consuming quota. Manage
suppressions from the dashboard under **Suppressions**.