---
title: "Sending Mail"
description: "Outbound send, multiple recipients, cc/bcc, reply-to, tags, recipient gates, idempotency, delivery waits, attachments, inline images, scheduled sends, and rate limits."
canonical: "https://docs.primitive.dev/docs/sending"
last-updated: "2026-05-17T00:00:00.000Z"
---

# Sending Mail

Primitive sends outbound mail from verified identities. Use the CLI for operational workflows, SDKs for application code, and REST for runtimes where an SDK is not available.

## Endpoint

All sends — with or without attachments — go to a single endpoint:


```
POST https://api.primitive.dev/v1/send-mail
```


Attachments up to the inline cap are included inline (base64-encoded) in the same JSON body; larger attachments are uploaded first to `/v1/payloads` and delivered by reference via `payload_attachments`. See [Attachments](#attachments) for the size limits.

## Send with CLI or cURL


```
primitive send \
  --to alice@example.com \
  --subject "Hello" \
  --body "Hi from Primitive" \
  --wait
```

```
curl -X POST https://api.primitive.dev/v1/send-mail \
  -H "Authorization: Bearer $PRIMITIVE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "from": "support@yourdomain.com",
    "to": "alice@example.com",
    "subject": "Order confirmed",
    "body_text": "Your order is on its way.",
    "wait": true
  }'
```


The CLI can auto-resolve a verified `from` address for the organization. Use `primitive send --help` for all flags.

REST fields use `snake_case`. SDKs expose idiomatic names for each language, such as `bodyText` in TypeScript and `BodyText` in Go.

### Recipient and Body Limits

`to` accepts a single address string or an array of addresses. Each entry must be exactly one address; a comma-separated list inside one entry is rejected with a `validation_error`, so use the array form to address multiple recipients. The combined recipient count across `to`, `cc`, and `bcc` is capped at **100** per send. At least one of `body_text` or `body_html` is required.

`body_text` and `body_html` together are capped at **256 KB** (combined UTF-8 byte length). A request over the cap is rejected with a `validation_error` before the message is accepted. For larger payloads, send a link or an attachment instead.

## SDK Send


```
import primitive from '@primitivedotdev/sdk';


const client = primitive.client({
  apiKey: process.env.PRIMITIVE_API_KEY!,
});


const result = await client.send({
  from: 'support@yourdomain.com',
  to: 'alice@example.com',
  subject: 'Order confirmed',
  bodyText: 'Your order is on its way.',
  wait: true,
});


console.log(result.id, result.deliveryStatus, result.smtpResponseCode);
```

```
import os
import primitive


client = primitive.client(api_key=os.environ['PRIMITIVE_API_KEY'])


result = client.send(
    from_email='support@yourdomain.com',
    to='alice@example.com',
    subject='Order confirmed',
    body_text='Your order is on its way.',
    wait=True,
)


print(result.id, result.delivery_status, result.smtp_response_code)
```

```
wait := true
result, err := client.Send(context.Background(), primitive.SendParams{
    From:     "support@yourdomain.com",
    To:       "alice@example.com",
    Subject:  "Order confirmed",
    BodyText: "Your order is on its way.",
    Wait:     &wait,
})
if err != nil {
    log.Fatal(err)
}
fmt.Println(result.ID)
```


## Multiple Recipients, Cc, and Bcc

A send with several recipients goes out as one message, not a fan-out into separate sends: every address in `to`, `cc`, and `bcc` receives the same SMTP message, and a single send record tracks the delivery.


```
{
  "from": "support@yourdomain.com",
  "to": ["alice@example.com", "bob@example.com"],
  "cc": "manager@example.com",
  "bcc": ["audit@yourdomain.com"],
  "subject": "Order confirmed",
  "body_text": "Your order is on its way."
}
```


- `cc` and `bcc` take the same shape as `to`: a single address string or an array, with exactly one address per entry.

- The combined `to` + `cc` + `bcc` count is capped at 100 recipients per send.

- Every recipient, including `cc` and `bcc`, must pass the same [recipient gates](#who-you-can-send-to); one denied address rejects the whole send with `403 recipient_not_allowed` naming that address.

- `bcc` recipients receive the message, but the `Bcc` header is never included in the transmitted message, so other recipients cannot see them. The bcc list is stored on your own send record and read back on `GET /v1/sent-emails/{id}`.

- The first `to` entry is the primary recipient: it fills the scalar `to_address` field in list responses, while the detail read-back also carries the full `to_addresses`, `cc`, and `bcc` arrays.

## Who You Can Send From

You can send from any verified domain on your organization with an active DKIM key. Managed `*.primitive.email` subdomains are verified by construction. Custom domains need outbound DNS records published and verified first.

## Who You Can Send To

Primitive deliberately gates new outbound traffic. This protects deliverability and prevents a new account from becoming an unauthenticated bulk sender.

Allowed recipient categories include:

- any address on a Primitive-managed zone such as `*.primitive.email`;

- addresses on a custom domain verified by your organization;

- email addresses of members of your Primitive organization;

- addresses that previously sent you authenticated inbound mail, when replying to known addresses is allowed;

- any domain after `send_to_any_domain` is enabled for your organization.

To inspect the active rules, call:


```
primitive sending:get-send-permissions
```

```
curl https://api.primitive.dev/v1/send-permissions \
  -H "Authorization: Bearer $PRIMITIVE_API_KEY"
```


If a send is denied, Primitive returns `403 recipient_not_allowed` with a structured `gates` array and per-gate `fix.action` hints. Branch on the error code, not on the human-readable message.


```
{
  "success": false,
  "error": {
    "code": "recipient_not_allowed",
    "message": "cannot send to alice@external.com",
    "gates": [
      {
        "name": "send_to_confirmed_domains",
        "reason": "domain_not_confirmed",
        "message": "external.com is not on this account's confirmed-domain list.",
        "subject": "external.com"
      },
      {
        "name": "send_to_known_addresses",
        "reason": "recipient_not_known",
        "message": "You have not exchanged authenticated mail with alice@external.com yet.",
        "subject": "alice@external.com",
        "fix": { "action": "wait_for_inbound", "subject": "alice@external.com" }
      }
    ],
    "request_id": "req_..."
  }
}
```


Each gate carries `name`, `reason`, `message`, and `subject`; only some gates carry a `fix` (an object of `action` + `subject`) — for example, `send_to_known_addresses` suggests `wait_for_inbound`, while `send_to_confirmed_domains` has no automatic fix. If **no** recipient gate is granted to your org at all, the response instead omits `gates` and carries `details.required_entitlements` listing the gates that would unlock sending.

Sending to your own verified domains never needs an entitlement. Any address on a domain your organization has added and completed ownership verification for is a permitted recipient, on the same ownership evidence that lets you send *from* that domain. It appears as the `send_to_own_verified_domains` gate on `/v1/sendability` and as a `your_domain` rule on `/v1/send-permissions`.

One further allowance needs no entitlement and is not something you can invoke: when you run a Function test invocation, Primitive itself sends the test message to the generated address on your inbound domain. That single delivery is authorized by the pending test run recorded for it, matched on the exact recipient AND sender addresses and on your organization's current, verified ownership of the recipient domain, and it stops authorizing anything the moment the message is sent. If ownership verification on that domain has lapsed, the test invocation is refused before it is sent, the same way inbound mail to that domain is refused. It appears as the `send_to_function_test_recipient` gate on `/v1/sendability`. It is deliberately absent from `/v1/send-permissions`, which lists standing rules, because it is neither standing nor yours.

The remaining gates are entitlement-backed:

GateAllows`send_to_primitive_managed_domains`Active domains hosted on Primitive, including `*.primitive.email`.`send_to_confirmed_domains`Addresses at any active domain on your organization, including ones whose ownership is not verified. Verified domains do not need it.`send_to_org_member_emails`Email addresses owned by organization members.`send_to_known_addresses`External addresses that previously sent you authenticated mail.`send_to_opted_in_domains`Domains that published an [_agents opt-in record](/docs/agent-authorization) authorizing agents on Primitive.`send_to_any_domain`Any recipient domain after broader sending is enabled.

## Replying

Replying derives the recipient and threading headers from an inbound email.


```
await client.reply(email, {
  text: 'Got it.',
});
```

```
client.reply(email, {'text': 'Got it.'})
```

```
_, err := client.Reply(context.Background(), email, primitive.ReplyParams{
    BodyText: "Got it.",
})
if err != nil {
    log.Fatal(err)
}
```


Use the CLI or raw API from terminal workflows:


```
primitive sending:reply-to-email \
  --id <inbound-email-id> \
  --body-text "Got it."
```

```
curl -X POST https://api.primitive.dev/v1/emails/<inbound-email-id>/reply \
  -H "Authorization: Bearer $PRIMITIVE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"body_text":"Got it."}'
```


Primitive sets the `Re:` subject, `In-Reply-To`, and `References` headers from the parent message when available.

If the parent is not repliable, the API returns `422 inbound_not_repliable` with a stable reason: `inbound_rejected`, `content_discarded`, or `missing_recipient`. A parent without a `message_id` is still repliable; the threading headers are simply omitted.

## Forwarding

Forwarding sends a new outbound message that carries the context of an inbound email.


```
await client.forward(email, {
  to: 'oncall@example.com',
  bodyText: 'FYI, new bug report.',
});
```

```
client.forward(email, to='oncall@example.com', body_text='FYI, new bug report.')
```

```
_, err := client.Forward(context.Background(), email, primitive.ForwardParams{
    To:       "oncall@example.com",
    BodyText: "FYI, new bug report.",
})
if err != nil {
    log.Fatal(err)
}
```


Use forwarding for escalation, triage, and workflows where the final recipient is not the original sender.

## Manual Threading

The reply and forward helpers set threading headers for you. When you build a send by hand and want it to thread into an existing conversation, pass the threading fields directly:

- `in_reply_to`: the `Message-ID` of the message you are replying to;

- `references`: the ordered chain of prior `Message-ID`s in the thread.


```
{
  "from": "support@yourdomain.com",
  "to": "alice@example.com",
  "subject": "Re: Order confirmed",
  "body_text": "Following up on your order.",
  "in_reply_to": "<abc123@example.com>",
  "references": ["<root@example.com>", "<abc123@example.com>"]
}
```


`in_reply_to` is a single Message-ID (at most 998 characters). `references` is an array of up to **100** Message-IDs whose combined length is at most about **8,000 characters**. Values may not contain control characters. When you reply through `/v1/emails/{id}/reply`, Primitive derives these headers from the parent automatically, so manual threading is for sends you assemble yourself.

## Reply-To

`reply_to` sets the `Reply-To` header, directing replies somewhere other than the `from` address. It accepts a single address string or an array of up to **10** addresses. Each entry must be exactly one RFC 5322 mailbox; a display name is allowed.


```
{
  "from": "noreply@yourdomain.com",
  "to": "alice@example.com",
  "reply_to": ["Support <support@yourdomain.com>"],
  "subject": "Receipt",
  "body_text": "Thanks for your order."
}
```


Reply-To is a header, not a recipient: it adds no delivery, is not checked against recipient gates, and does not count toward the 100-recipient cap. The normalized addresses are read back as `reply_to` on `GET /v1/sent-emails/{id}`.

## Custom Headers

Most message headers are derived by Primitive and cannot be overridden. A narrow allowlist of headers can be set per send through the `headers` map:

- `Auto-Submitted` — mark auto-generated or auto-reply mail (RFC 3834);

- `X-Auto-Response-Suppress` — suppress the recipient's own auto-replies to your message;

- `Precedence` — legacy bulk/list/junk hint some receivers use for loop prevention.


```
{
  "from": "support@yourdomain.com",
  "to": "alice@example.com",
  "subject": "Receipt",
  "body_text": "Thanks for your order.",
  "headers": {
    "Auto-Submitted": "auto-generated",
    "X-Auto-Response-Suppress": "All"
  }
}
```


Header lookup is case-insensitive. You may set at most **25** entries; each key is at most 64 characters and each value is printable ASCII (no control characters) and at most 200 characters. Headers not on the allowlist — for example `Return-Path`, `Sender`, or `List-Unsubscribe` — are rejected with a `validation_error` naming the offending header. Threading headers are set with `in_reply_to` and `references` (above), not through this map.

## Idempotency

Pass an idempotency key when retrying a send from your own infrastructure.


```
curl -X POST https://api.primitive.dev/v1/send-mail \
  -H "Authorization: Bearer $PRIMITIVE_API_KEY" \
  -H "Idempotency-Key: order-123-email-confirmation" \
  -H "Content-Type: application/json" \
  -d '{"from":"support@yourdomain.com","to":"alice@example.com","subject":"Confirmed","body_text":"Done"}'
```


The same key and canonical payload return the cached response instead of creating a second send. Reusing the same key with a different payload also replays the original outcome (no new send fires); the replayed response carries `idempotent_replay: true` and echoes the original `content_hash`, so compare it against your payload to detect the mismatch.

## Tags

Attach up to **10** `{ "name": ..., "value": ... }` tags to a send to correlate and filter your send history:


```
{
  "from": "support@yourdomain.com",
  "to": "alice@example.com",
  "subject": "Order confirmed",
  "body_text": "Your order is on its way.",
  "tags": [
    { "name": "category", "value": "order_confirmation" },
    { "name": "order_id", "value": "order_12345" }
  ]
}
```


Tags are metadata only. They never affect delivery, are never rendered into headers, and never appear on the transmitted message. They are stored on the send record and read back verbatim as `tags` on `GET /v1/sent-emails/{id}`.

Rules:

- at most 10 tags per send;

- names are capped at 64 characters and values at 256;

- names and values both use an ASCII token charset: letters, digits, underscores, and dashes only, and neither may be empty (for a bare marker tag, supply an explicit value such as `"true"`);

- duplicate names are rejected, case-sensitively, so `Env` and `env` are two distinct names.

## Wait Mode

`wait: true` makes the call block until Primitive has a delivery outcome from the receiving side. This is useful for agents, tests, and transactional workflows that need immediate status.

Without `wait`, the API returns after accepting the outbound message. Read later state from `/v1/sent-emails` or the SDK equivalent.

Wait responses can include `status`, `delivery_status`, `smtp_response_code`, and `smtp_response_text` (`smtp_enhanced_status_code` appears only on the stored row read back from `/v1/sent-emails`). `delivery_status` values are `delivered`, `bounced`, `deferred`, or `wait_timeout`; top-level `status` can also report states such as `gate_denied` or `agent_failed`.

The wait timeout defaults to 30000 ms. If you override it with `wait_timeout_ms`, use a value from 1000 to 30000 ms.

### Response vs Stored Row

The SDK/API response and the durable `sent_emails` row can briefly disagree. With `wait: true`, Primitive returns the outbound agent's terminal `delivery_status` as soon as the SMTP response is known or the wait timeout elapses. The follow-up update that flips the stored row from `queued` to the same terminal status happens immediately after, but a transient write failure can push that catch-up onto a retry path.

Trust the send response for the per-call outcome. Treat `GET /v1/sent-emails/{id}` as the durable record that may converge a few seconds later.


```
const result = await client.send({
  from: 'support@example.com',
  to: 'alice@customer.com',
  subject: 'Order confirmed',
  bodyText: 'Your order is on its way.',
  wait: true,
});


console.log(result.deliveryStatus, result.smtpResponseCode);


// The stored row may briefly still show "queued" at this exact moment.
```


To watch the stored row catch up, poll `GET /v1/sent-emails/{id}` or use `primitive sending:get-sent-email --id <send-id>` until `status` reaches a terminal value.

## Attachments

Attachments are sent inline in the JSON body. Each entry has a `filename`, an optional `content_type` (inferred from the filename when omitted), and a base64-encoded `content_base64`:


```
curl -X POST https://api.primitive.dev/v1/send-mail \
  -H "Authorization: Bearer $PRIMITIVE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "from": "support@yourdomain.com",
    "to": "alice@example.com",
    "subject": "Report",
    "body_text": "Attached.",
    "attachments": [
      {
        "filename": "report.txt",
        "content_type": "text/plain",
        "content_base64": "cmVwb3J0Cg=="
      }
    ]
  }'
```


Limits per send:

- up to 100 attachments;

- 30 MiB total raw (decoded) across all attachments;

- each `content_base64` field is capped at ~42 MiB of base64 text;

- `filename` and `content_type` reject control characters (header-injection guard).

### Read sent attachments

`GET /v1/sent-emails/{id}` includes submitted inline attachment metadata in `attachments`,
the original decoded byte total in `attachments_size_bytes`, and
`attachments_download_available`. Each metadata entry contains `filename`,
`content_type`, `size_bytes`, `sha256`, `part_index`, and `tar_path`.

Metadata may be present even when the archive is unavailable. Check
`attachments_download_available` before offering a download.

Download retained inline attachments with
`GET /v1/sent-emails/{id}/attachments.tar.gz`. The response is a gzip-compressed
tar archive; member names match the metadata's `tar_path`. Authenticate using your
organization API key or an authorized OAuth token. A signed sent-attachment URL
also works. Address-bound agent connection keys and Function credentials cannot download this archive.

Legacy sends may not have a retained archive. Offloaded payload files are not
included in this inline archive, so its contents may be smaller than the original
attachment byte total. A missing archive returns `404`; discarded email content
returns `410`. Archive availability does not prove the email was delivered.

### Inline Images (content_id)

Give an attachment a `content_id` to embed it inline and reference it from `body_html` with a `cid:` URL:


```
{
  "from": "support@yourdomain.com",
  "to": "alice@example.com",
  "subject": "Welcome",
  "body_html": "<p>Hello</p><img src=\"cid:logo\" alt=\"Logo\">",
  "attachments": [
    {
      "filename": "logo.png",
      "content_type": "image/png",
      "content_id": "logo",
      "content_base64": "iVBORw0KGgo..."
    }
  ]
}
```


With `content_id` set, the part is composed inline (`multipart/related`, with a `Content-ID` header) so HTML-capable clients render it in place. Without it, the attachment behaves exactly as before. `content_id` must be 1-128 printable ASCII characters with no whitespace and no `<` or `>` (the composer adds the angle brackets); the `cid:` reference in your HTML must match it exactly.

## Scheduled Sends

Pass `scheduled_at` to accept a send now and dispatch it later. The value is an ISO 8601 UTC datetime (`Z` suffix), strictly in the future, and at most **30 days** out.


```
curl -X POST https://api.primitive.dev/v1/send-mail \
  -H "Authorization: Bearer $PRIMITIVE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "from": "support@yourdomain.com",
    "to": "alice@example.com",
    "subject": "Renewal reminder",
    "body_text": "Your plan renews tomorrow.",
    "scheduled_at": "2026-08-01T09:00:00Z"
  }'
```


The response mirrors an async send with `status: "scheduled"` and the requested `scheduled_at` echoed. Nothing is dispatched yet: `queue_id` is `null` and `accepted`/`rejected` are empty. Shortly after the scheduled time, Primitive dispatches the stored payload and the record moves through the normal statuses (`queued`, `submitted_to_agent`, then a delivery outcome).

`scheduled_at` cannot be combined with:

- `wait` (a connection cannot be held open for a future send);

- `attachments` (not supported on scheduled sends yet; send them at delivery time or use an immediate send).

### Checks at Schedule Time vs Execution Time

Creating a scheduled send runs the same validation and permission checks as an immediate send, but as a preflight for early feedback only. Gates and quota are evaluated, and quota is consumed, at execution time: passing the preflight is not permission to send later. If, when the send comes due, the from-domain has lost sendability, a recipient is no longer allowed, the credential the send was scheduled with has been revoked, or a usage limit is exhausted, the record lands at `gate_denied` with the denial recorded in `error_code` and `error_message`, and is not retried. Send rate limits are the exception: creating the scheduled send consumes your send budget at schedule time, not at execution.

### Rescheduling and Canceling

Move a still-scheduled send with `PATCH /v1/sent-emails/{id}` (same bounds: strictly future, at most 30 days out), or cancel it with `POST /v1/sent-emails/{id}/cancel`. Both return the updated sent-email record. Cancellation is terminal (`status: "canceled"`); a canceled send never dispatches.


```
curl -X PATCH https://api.primitive.dev/v1/sent-emails/<send-id> \
  -H "Authorization: Bearer $PRIMITIVE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"scheduled_at": "2026-08-02T09:00:00Z"}'


curl -X POST https://api.primitive.dev/v1/sent-emails/<send-id>/cancel \
  -H "Authorization: Bearer $PRIMITIVE_API_KEY"
```


Both operations succeed only while the record is still `scheduled`. Once execution has claimed it, or it was already canceled or executed, they return `409` with the `not_scheduled` error code and a message naming the current status. Exactly one of reschedule, cancel, or execution wins on any given record.

Scheduled and canceled sends are visible on `GET /v1/sent-emails` (filter with `status=scheduled` or `status=canceled`). Records keep `scheduled_at` after execution as the historical schedule, and canceled records carry `canceled_at`.

Two idempotency notes specific to scheduling: `scheduled_at` is part of the request's content hash, so the same payload scheduled for two different times creates two distinct sends, while a retry of an identical scheduled request replays the original. Rescheduling does not recompute that hash, so a fresh `POST /v1/send-mail` with the same payload and the record's new time creates a second, separate send rather than replaying the rescheduled one.

## Rate Limits

Outbound limits are plan and account dependent. When rate limited, `/v1/send-mail` returns `429` with the `rate_limited` error code and a structured error envelope. Branch on the `429` status and honor `Retry-After`. See [Errors](/docs/errors#rate-limit-responses).

## Batch sending

Send many independent messages in one request with `POST /v1/send-mail/batch`. Each message runs through the exact same path as a single `POST /v1/send-mail` — same auth, validation, recipient gates, rate limiting, and idempotency — so the per-message semantics are identical. A batch is a convenience for fan-out, not a different send mode.

The body is a `messages` array, where each entry is a normal send payload (`from`, `to`, `subject`, `body_text`/`body_html`, attachments, headers, and so on):


```
curl -X POST https://api.primitive.dev/v1/send-mail/batch \
  -H "Authorization: Bearer $PRIMITIVE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "messages": [
      {
        "from": "support@yourdomain.com",
        "to": "alice@example.com",
        "subject": "Order confirmed",
        "body_text": "Your order is on its way."
      },
      {
        "from": "support@yourdomain.com",
        "to": "bob@example.com",
        "subject": "Order confirmed",
        "body_text": "Your order is on its way."
      }
    ]
  }'
```


A batch must contain between **1** and **100** messages; an empty `messages` array or one over the cap is rejected with a `validation_error` before any message is sent.

The response is a `200` envelope whose `data` carries `count` and a `results` array with one entry per input message, in order. **One message failing does not fail the others** — each entry independently reports `success` with either its own `data` (the per-send result) or an `error`:


```
{
  "success": true,
  "data": {
    "count": 2,
    "results": [
      { "index": 0, "success": true, "data": { "id": "..." } },
      { "index": 1, "success": false, "error": { "code": "recipient_not_allowed", "message": "..." } }
    ]
  }
}
```


Because the top-level status is `200` even when individual messages fail, always inspect each `results` entry's `success` flag rather than relying on the HTTP status. A batch-level `Idempotency-Key` header is not applied per message; each message instead derives its own idempotency from its payload, so set per-message keys by retrying individual sends if you need exactly-once guarantees.

## Related Pages

- [Domains](/docs/domains): configure a custom outbound identity.

- [REST API](/docs/api): endpoint list and envelope shape.

- [SDKs](/docs/sdks): high-level send, reply, and forward helpers.

- [Simple Email](/docs/simple-email): a drop-in send surface compatible with a widely used JavaScript email SDK's wire format.

Maintained by the [primitive.dev team](/about) · [Trust & security](/trust)Last updated: May 17, 2026
