Skip to content
AI Inbx
Esc
navigateopen⌘Jpreview
On this page

Sending

Addresses, HTML and text bodies, attachments, custom headers, idempotency, and what the send response tells you.

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.

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?",
})
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?",
    }
)
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?"
  }'

Addresses

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

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

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

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:

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:

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"),
    },
  ],
})
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:

{
  "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. Inbound attachments work differently — see 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:

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

await aiinbx.emails.send(payload, { idempotencyKey: "order-8812-receipt" })
client.emails.send(payload, idempotency_key="order-8812-receipt")
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. Derive keys from something stable in your domain — order-8812-receipt, not a fresh UUID per attempt. Full semantics in Conventions.

Reading the response

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

PropType
idstring

The email's ID (eml_…).

Typestring
thread_idstring

The thread this message opened or joined.

Typestring
statusstring

queued, sending, sent, or scheduled at this point — delivery outcomes arrive later, by webhook.

Typestring
suppressedstring[]

Recipients dropped before sending because they're on a suppression list. Not an error — read it.

Typestring[]
pacingobject | null

Non-null when a pacing rule is holding the message: held_by names the rule, estimated_send_at is the projection.

Typeobject | null
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 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:

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

See 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 covers both.

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

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

Which space a send is in

Nothing on the request names a space. 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:

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 instead — it infers the sender, recipients, subject, and reply headers, which is the part that’s easy to get wrong by hand.

Next

Last updated on September 9, 2026

Was this page helpful?