---
title: Sending
description: Addresses, HTML and text bodies, attachments, custom headers, idempotency, and what the send response tells you.
sidebar:
  icon: send
---

`POST /emails` takes a message and returns it with an ID, a thread, and a status. Everything else on this page is a detail of that one call.

```ts TypeScript
const email = await aiinbx.emails.send({
  from: { name: "Ada", address: "ada@example.com" },
  to: "grace@example.com",
  subject: "Quick question",
  text: "Does Thursday still work for the review?",
})
```

```python Python
email = client.emails.send(
    {
        "from_": {"name": "Ada", "address": "ada@example.com"},
        "to": ["grace@example.com"],
        "subject": "Quick question",
        "text": "Does Thursday still work for the review?",
    }
)
```

```bash curl
curl https://api.aiinbx.com/api/v2/emails \
  -H "Authorization: Bearer $AI_INBX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "from": "Ada <ada@example.com>",
    "to": "grace@example.com",
    "subject": "Quick question",
    "text": "Does Thursday still work for the review?"
  }'
```

:::note
Sending requires a key with the `sending` or `full` [scope](/authentication#scopes), and a `from` address on a [verified domain](/guides/domains) or a [connected mailbox](/guides/mailboxes).
:::

## Addresses

`from` takes either a formatted string or an object — pick whichever your code already has:

```json
{ "from": "Ada Lovelace <ada@example.com>" }
{ "from": { "name": "Ada Lovelace", "address": "ada@example.com" } }
{ "from": { "address": "ada@example.com" } }
```

A display name is 1–200 characters; surrounding quotes are stripped.

`to`, `cc`, `bcc`, and `reply_to` each take a single address or an array, so the single-recipient case stays terse:

```json
{ "to": "grace@example.com" }
{ "to": ["grace@example.com", "charles@example.com"], "cc": "ops@example.com" }
```

Each field holds at most 100 addresses, and addresses are lowercased. A send with no deliverable recipient left — every address suppressed, for instance — fails with [`no_recipients`](/reference/errors#no_recipients).

## Content

`subject` is required (up to 998 characters, the RFC line limit). At least one of `html` or `text` must be present; sending both is the right default, because it gives every client something to render:

```ts
await aiinbx.emails.send({
  from: "Ada <ada@example.com>",
  to: "grace@example.com",
  subject: "Your invoice",
  html: "<p>Your invoice is <a href='https://example.com/i/42'>ready</a>.</p>",
  text: "Your invoice is ready: https://example.com/i/42",
})
```

Each body may be up to 2 MB.

## Attachments

Up to 20 attachments per message, each as base64 `content` with a `filename` and `content_type`:

```ts TypeScript
await aiinbx.emails.send({
  from: "Ada <ada@example.com>",
  to: "grace@example.com",
  subject: "The report",
  text: "Attached.",
  attachments: [
    {
      filename: "report.pdf",
      content_type: "application/pdf",
      content: pdfBuffer.toString("base64"),
    },
  ],
})
```

```python Python
import base64

client.emails.send(
    {
        "from_": "Ada <ada@example.com>",
        "to": ["grace@example.com"],
        "subject": "The report",
        "text": "Attached.",
        "attachments": [
            {
                "filename": "report.pdf",
                "content_type": "application/pdf",
                "content": base64.b64encode(pdf_bytes).decode(),
            }
        ],
    }
)
```

Set `cid` to reference an attachment from your HTML instead of listing it at the bottom of the message:

```json
{
  "html": "<img src=\"cid:logo\" alt=\"Acme\">",
  "attachments": [
    {
      "filename": "logo.png",
      "content_type": "image/png",
      "cid": "logo",
      "content": "iVBORw0KG..."
    }
  ]
}
```

Oversized payloads fail with [`attachments_too_large`](/reference/errors#attachments_too_large). Inbound attachments work differently — see [Attachments](/guides/attachments), which also covers the prepared-text extraction that turns a received PDF into Markdown.

## Custom headers

`headers` sets additional RFC 5322 headers — a campaign tag, a correlation ID, anything your downstream tooling reads:

```json
{
  "headers": {
    "X-Campaign-ID": "spring-2026",
    "X-Correlation-ID": "ord_8812"
  }
}
```

Names are limited to 200 characters and values to 8000.

## Idempotency

Network timeouts are the reason double-sends happen. Pass an `Idempotency-Key` and a retry with the same body returns the original email instead of sending a second one:

```ts TypeScript
await aiinbx.emails.send(payload, { idempotencyKey: "order-8812-receipt" })
```

```python Python
client.emails.send(payload, idempotency_key="order-8812-receipt")
```

```bash curl
curl https://api.aiinbx.com/api/v2/emails \
  -H "Authorization: Bearer $AI_INBX_API_KEY" \
  -H "Idempotency-Key: order-8812-receipt" \
  -H "Content-Type: application/json" \
  -d @email.json
```

A replay returns `200` with `Idempotent-Replayed: true`, where the original returned `201`. Reusing a key with a *different* body is a mistake rather than a retry, and fails with [`idempotency_key_reused`](/reference/errors#idempotency_key_reused). Derive keys from something stable in your domain — `order-8812-receipt`, not a fresh UUID per attempt. Full semantics in [Conventions](/reference/conventions#idempotency).

## Reading the response

The send response is the email plus two fields worth checking on every call:

| Prop | Type | Default | Description |
| - | - | - | - |
| `id` | `string` | - | The email's ID (eml_…). |
| `thread_id` | `string` | - | The thread this message opened or joined. |
| `status` | `string` | - | queued, sending, sent, or scheduled at this point — delivery outcomes arrive later, by webhook. |
| `suppressed` | `string[]` | - | Recipients dropped before sending because they're on a suppression list. Not an error — read it. |
| `pacing` | `object \| null` | - | Non-null when a pacing rule is holding the message: held_by names the rule, estimated_send_at is the projection. |

```ts
const email = await aiinbx.emails.send(payload)

if (email.suppressed.length) {
  console.warn("dropped:", email.suppressed)
}

if (email.pacing && email.pacing.held_by.kind !== "ready") {
  console.log("held until", email.pacing?.estimated_send_at)
}
```

`status` describes the send lifecycle. Delivery, bounce, and complaint outcomes appear as [webhooks](/webhooks/events) and in the email’s `events` array when you retrieve it. These are events, not values to expect in `status`; a complaint can arrive after delivery.

### Bypassing pacing

A password reset shouldn't wait behind a marketing queue. `pacing.skip` sends immediately; `pacing.count` controls whether the send still consumes rule budget:

```json
{ "pacing": { "skip": true, "count": false } }
```

See [Pacing](/guides/pacing) for what the rules do in the first place.

## Suppression and unsubscribe

`suppression_key` names the list this send is checked against — a campaign, a product, a tenant. Omit it and the send is checked against the workspace’s `*` list and, when sending from a space, that space’s `*` list. A named key adds the matching lists at both levels.

`unsubscribe: true` marks the message as optional mail: it adds one-click unsubscribe headers, and lets a recipient opt out of *this* list without blocking your transactional mail. [Suppressions](/guides/suppressions) covers both.

```json
{ "suppression_key": "product-updates", "unsubscribe": true }
```

## Scheduling

`scheduled_at` (RFC 3339, with an offset, up to 30 days out) defers the send. The message comes back with `status: "scheduled"` and can be rescheduled or canceled until it goes out — see [Scheduling](/guides/scheduling).

```json
{ "scheduled_at": "2026-09-15T09:00:00Z" }
```

## Which space a send is in

Nothing on the request names a [space](/guides/spaces). The email is in the space of the domain or mailbox `from` is on, and its thread with it — `space_id` on the response says which, `null` for the workspace itself. A workspace that has no spaces never sees anything else there.


Lists take `space` to narrow to one space:

```ts
for await (const email of aiinbx.emails.list({ space: "spc_...", direction: "inbound" })) {
  console.log(email.from.address, email.subject)
}
```

## Continuing a conversation

Pass `thread_id` to attach a new message to an existing thread. For an actual reply, use [`threads.reply`](/guides/threads#replying) instead — it infers the sender, recipients, subject, and reply headers, which is the part that's easy to get wrong by hand.

## Next

**[Threads](/guides/threads)**

Replying without rebuilding headers.

**[Scheduling](/guides/scheduling)**

Deferred sends, rescheduling, cancellation.

**[Suppressions](/guides/suppressions)**

Why a recipient came back in `suppressed`.

**[Pacing](/guides/pacing)**

Sending windows and rate ceilings.
