Send an email

Send email from your own domain in one API call — text, HTML, React, or a stored template, plus attachments, scheduling, and safe retries.

Sending an email is one call. Pick the inbox it comes from, name the recipients, give it a body — Dairo authenticates the mail, delivers it, and records what happens next. Everything else on this page is optional.

The basic send

Call POST /v1/messages (scope messages:send) with an inboxId — the verified inbox the mail comes from — at least one recipient in to, and one body. That is a complete send.

curl -X POST https://api.dairo.app/v1/messages \
  -H "Authorization: Bearer $DAIRO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "inboxId": "inbox_7c9e6679",
    "to": "ada@example.com",
    "subject": "Welcome to Acme",
    "html": "<p>Thanks for signing up.</p>"
  }'

An immediate send is performed synchronously, so the response is the definitive outcome — you never get an acceptance that fails later on something knowable at submit time.

{
  "id": "msg_3fa85f64",
  "status": "sent",
  "channel": "email",
  "providerMessageId": "msg_pm_9f8c2a1b4e7d",
  "warnings": []
}

Keep the id — it keys delivery tracking and the webhook events Dairo emits as the mail progresses.

statusWhen you get it
sentThe email left Dairo. This is the normal result of an immediate send.
scheduledThe request carried a future sendAt; scheduledAt echoes the fire time.
queuedReturned only by audience broadcasts, which fan out in the background.
failedThe send could not be completed; error carries the reason.

Dairo picks the transport automatically: external addresses go out as email, and when every recipient is another Dairo inbox the message is delivered directly with channel: "a2a" — still returning sent. Pass channel to force one or the other; see Channels.

Provide exactly one body — text, html, react, or template. Two body fields in one request (even text plus html) is a 400, and so is a send with no body at all unless it carries at least one attachment (an attachment-only email is valid). A text or html body may be up to 1 MB.

Request fields

FieldDescription
inboxIdRequired. The verified inbox to send from.
toRequired. One address or an array. A contact reference works anywhere an address does.
cc, bccMore recipients, single or array.
subjectSubject line. Defaults to empty.
text / html / react / templateThe body — exactly one.
contactIdSend to a stored contact: its primary handle is added to to, so no raw address is needed.
attachmentsUp to 10 files, inline or by storage reference.
replyToA single address, set verbatim as the Reply-To header.
replyToMessageIdThe Dairo message this send answers: sets the reply headers and files the send in that message's thread. See Reply in a thread.
inReplyTo, referencesRaw reply headers (RFC 5322 Message-IDs) for answering mail that is not in Dairo.
headersUp to 25 custom MIME headers as { "name": "value" }. Protected header names, malformed names, and values containing CR or LF are rejected.
tagsUp to 25 name/value pairs recorded on the send. Names and values are 1–256 characters of ASCII letters, digits, _, and -; names starting with dairo_ are reserved for Dairo and refused with a 400.
idempotencyKeyDe-duplicates retries — up to 128 characters. The Idempotency-Key header does the same.
sendAtA future time, up to 30 days ahead, to schedule delivery.
channelemail or a2a; omit to route automatically. a2a requires every recipient to be a Dairo inbox and cannot combine with sendAt.
ignoreComplaintsDeliberately email suppressed recipients (spam complaints and hard bounces). Default false.
dryRunValidate the send without performing it. Returns a preview; nothing is created, sent, or billed. Default false.

A send takes at most 50 recipients across to, cc, and bcc combined.

Multiple recipients, CC and BCC

to, cc, and bcc each accept a single address or an array.

await dairo.messages.send({
  inboxId: "inbox_7c9e6679",
  to: ["ada@example.com", "grace@example.com"],
  cc: "manager@example.com",
  bcc: ["audit@example.com"],
  subject: "Quarterly report",
  text: "Attached.",
});

Send to a contact

You don't have to know a recipient's raw address. If you keep an address book of contacts, reference one and Dairo resolves it against the sending inbox's channel — for an email inbox, to the contact's primary email address. Raw addresses in the same list pass through untouched, so you can mix them freely.

ReferenceResolves to
@meThe contact bound to your own account.
@aliasThe contact with that alias.
contact:<id>The contact with that id.
contactId (top-level field)The contact's primary handle, added to to.

A contact with no handle for the sending inbox's channel is a 422 — resolution never guesses an address. The resolved contact is stamped on the outbound message, so the send joins that contact's cross-channel history.

# A saved contact by alias, plus a raw address
curl -X POST https://api.dairo.app/v1/messages \
  -H "Authorization: Bearer $DAIRO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "inboxId": "inbox_7c9e6679",
    "to": ["@ada", "grace@example.com"],
    "subject": "Your account is ready",
    "text": "You are all set — sign in any time."
  }'

# Or address the send with a top-level contactId — no `to` needed
curl -X POST https://api.dairo.app/v1/messages \
  -H "Authorization: Bearer $DAIRO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "inboxId": "inbox_7c9e6679",
    "contactId": "contact:3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "subject": "Your account is ready",
    "text": "You are all set — sign in any time."
  }'

Send a React email

Pass a react object and Dairo renders your React Email component into HTML — no build step or render service on your side. Send the component source plus any props; imports are limited to React Email components. source may be up to 64 KiB and props up to 32 KiB of JSON.

await dairo.messages.send({
  inboxId: "inbox_7c9e6679",
  to: "ada@example.com",
  subject: "Your receipt",
  react: {
    source: `export default function Receipt({ total }) {
      return <p>Your total is {total}.</p>;
    }`,
    props: { total: "$42.00" },
  },
});

To reuse a design across many sends, save it once as a template and send it by id instead.

Attach files

Each attachment is an object, and you attach it one of two ways — never both:

  • Inline bytes — base64-encode the file into contentBase64. Requires a filename; contentType is recommended. Best for small files.
  • Storage reference — pass the objectId of a Dairo storage object you own (the id a bucket upload returns on finalize). Dairo fetches the bytes and attaches them natively, deriving filename and contentType from the object; override either in the same attachment object.

Limits: up to 10 attachments per send. Inline bytes are capped at 8 MiB per file and 8 MiB total across all inline attachments — they ride the API request itself. A storage reference attaches up to 24 MiB. Over these limits the send returns 413; Dairo never silently drops an attachment or edits your body.

import { readFileSync } from "node:fs";

await dairo.messages.send({
  inboxId: "inbox_7c9e6679",
  to: "ada@example.com",
  subject: "Your invoice",
  text: "Invoice attached.",
  attachments: [
    {
      filename: "invoice.pdf",
      contentType: "application/pdf",
      contentBase64: readFileSync("invoice.pdf").toString("base64"),
    },
  ],
});

For a file larger than 24 MiB, share a link instead: create a share link for the stored object and place it in your text or html yourself. Dairo never rewrites your message or inserts links on its own — an attachment with delivery: "link" is rejected for exactly this reason.

Test a send without sending it

Set dryRun: true and Dairo validates the request exactly as it would a real send — the body rules, the recipients, the sending inbox, complaint suppression, your attachments — resolves which channel it would go out on, and then stops.

cURL
curl -X POST https://api.dairo.app/v1/messages \
  -H "Authorization: Bearer $DAIRO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "inboxId": "inbox_7c9e6679",
    "to": "ada@example.com",
    "subject": "Order confirmation",
    "text": "Order #1001 confirmed.",
    "dryRun": true
  }'
Response
{
  "object": "message",
  "status": "preview",
  "dryRun": true,
  "persisted": false,
  "from": "orders@yourdomain.com",
  "channel": "email",
  "recipientCount": 1,
  "agentHint": "Dry run: this message was validated but NOT created, queued, sent, or billed."
}

Nothing is written, queued, handed to the provider, metered, or billed, and there is no message id — a preview has no row to fetch and will never emit delivery events. A bad payload still fails the way it normally would: a dry run validates, it does not wave anything through.

dryRun is a per-request control. The dairo_test_ / dairo_live_ prefix on an API key is a cosmetic label with no effect on delivery — a dairo_test_ key sends real mail to real people and is billed identically. If you want to send nothing, say so on the request.

Prevent duplicate sends

Pass an idempotencyKey (or the Idempotency-Key header) and a retried request won't send a duplicate — Dairo returns the result of the original send instead.

curl -X POST https://api.dairo.app/v1/messages \
  -H "Authorization: Bearer $DAIRO_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: order-1001-confirmation" \
  -d '{
    "inboxId": "inbox_7c9e6679",
    "to": "ada@example.com",
    "subject": "Order confirmation",
    "text": "Order #1001 confirmed."
  }'

Build the key from the thing you're emailing about — an order ID, an event ID, a row's primary key — so the same logical email always carries the same key. See Retries & idempotency.

Schedule for later

Add sendAt to any send and Dairo holds the email until then — useful for reminders, drip sequences, and time-zoned announcements.

sendAt is an RFC 3339 timestamp with an explicit timezone offset (for example 2026-07-01T09:00:00-04:00, or …Z for UTC — a bare local time is a 400). It must be in the future, at most 30 days ahead.

curl -X POST https://api.dairo.app/v1/messages \
  -H "Authorization: Bearer $DAIRO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "inboxId": "inbox_7c9e6679",
    "to": "ada@example.com",
    "subject": "Your trial ends tomorrow",
    "text": "Reminder: your trial ends in 24 hours.",
    "sendAt": "2026-07-01T09:00:00-04:00"
  }'

The response comes back with status: "scheduled" and the exact scheduledAt time the email will go out, normalized to UTC.

{
  "id": "msg_3fa85f64",
  "channel": "email",
  "status": "scheduled",
  "scheduledAt": "2026-07-01T13:00:00Z",
  "warnings": []
}

At fire time Dairo re-checks the send — inbox, domain, complaint suppression, and quota — and either delivers it or marks it failed. A recipient who reported spam after you scheduled the email is still protected.

Cancel a scheduled send

While an email is still scheduled, POST /v1/messages/{messageId}/cancel stops it for good and sets its status to canceled. A send that is no longer scheduled — already queued, sent, failed, or canceled — returns a 409.

curl -X POST https://api.dairo.app/v1/messages/msg_3fa85f64/cancel \
  -H "Authorization: Bearer $DAIRO_API_KEY"

Suppressed recipients

Dairo remembers every recipient who marked your mail as spam and every address that hard-bounced, and refuses to email them again: a send to such a recipient fails with a 400 naming the address and the reason. Set ignoreComplaints: true to override deliberately — the send then proceeds and the response's warnings array lists each affected recipient, with reason complaint or bounce:

{
  "id": "msg_3fa85f64",
  "status": "sent",
  "channel": "email",
  "warnings": [
    {
      "recipient": "ada@example.com",
      "reason": "complaint",
      "message": "Recipient previously complained; do not contact again unless you are sure."
    }
  ]
}

Emailing people who reported you as spam, or addresses that do not exist, damages your domain's reputation for every inbox you send from and can push future mail into spam folders. Override only when you are certain — and note that the override never applies to scheduled sends: a suppression on file at fire time marks the send failed. See Land in the inbox.

List and read your sends

List outbound messages (most recent first) with GET /v1/messages filtered on direction: "outbound". To follow one send, use the id the send call returned: fetch it for its current status, or list its per-recipient delivery events.

const sends = await dairo.messages.list({ direction: "outbound", limit: 25 });
const detail = await dairo.messages.get("msg_3fa85f64");
const { data: events } = await dairo.messages.listEvents("msg_3fa85f64");

Next steps

On this page