> ## Documentation Index
> Fetch the complete documentation index at: https://docs.dairo.app/llms.txt
> Use this file to discover all available pages before exploring further.

# Send a transactional email

> Build a retry-safe sendReceipt function that sends an HTML receipt in one call and confirms delivery with webhooks.

Transactional email is the mail your product sends one person at a time —
receipts, password resets, order confirmations. This guide builds a single
`sendReceipt(order)` function you call from checkout. It sends an HTML receipt,
never double-sends when the request is retried, and reports delivery back so you
can mark the order fulfilled.

## Verify a domain and create the inbox

Verify the domain you send from, then create the inbox the receipts come from.
Both are one-time steps.

```bash theme={null}
dairo domain add yourapp.com
dairo domain recheck yourapp.com          # repeat until the domain verifies
dairo inbox create receipts --domain yourapp.com
# → inbox_123  receipts@yourapp.com
```

The DNS records to publish are covered in
[Add and verify a domain](/domains/domains). The code below expects an API key
with the `messages:send` scope in the `DAIRO_API_KEY` environment variable.

## Send the receipt

One call sends the receipt. Two details keep it safe to run from checkout:

* **An idempotency key tied to the order.** A retry with the same key returns
  the original send instead of mailing a second receipt. See
  [Retries and idempotency](/concepts/idempotency).
* **A complaint guard.** If the recipient previously reported your mail as spam,
  Dairo refuses the send with a `400` instead of letting it damage your sending
  reputation. Treat that as "stop emailing this person," not an error to retry.

<CodeGroup>
  ```ts title="TypeScript" theme={null}
  import { Dairo, DairoError } from "dairo";

  const dairo = new Dairo({ apiKey: process.env.DAIRO_API_KEY! });
  const RECEIPTS_INBOX = "inbox_123";

  export async function sendReceipt(order: {
    id: string;
    email: string;
    total: string;
  }): Promise<string | null> {
    try {
      const result = await dairo.messages.send(
        {
          inboxId: RECEIPTS_INBOX,
          to: order.email,
          subject: `Your receipt for order #${order.id}`,
          html: `<h1>Thanks for your order</h1>
                 <p>Order <strong>#${order.id}</strong></p>
                 <p>Total: <strong>${order.total}</strong></p>`,
        },
        // Same order, same key: a retry returns the original send.
        { idempotencyKey: `receipt-${order.id}` },
      );
      return result.id; // the message id — store it on the order
    } catch (err) {
      if (
        err instanceof DairoError &&
        err.status === 400 &&
        err.message.includes("complained")
      ) {
        await markUnmailable(order.email); // recipient reported spam — stop contacting them
        return null;
      }
      throw err;
    }
  }
  ```

  ```python title="Python" theme={null}
  from dairo import Dairo, DairoError

  dairo = Dairo()  # reads DAIRO_API_KEY
  RECEIPTS_INBOX = "inbox_123"

  def send_receipt(order: dict) -> str | None:
      try:
          result = dairo.messages.send(
              inbox_id=RECEIPTS_INBOX,
              to=order["email"],
              subject=f"Your receipt for order #{order['id']}",
              html=(
                  "<h1>Thanks for your order</h1>"
                  f"<p>Order <strong>#{order['id']}</strong></p>"
                  f"<p>Total: <strong>{order['total']}</strong></p>"
              ),
              # Same order, same key: a retry returns the original send.
              idempotency_key=f"receipt-{order['id']}",
          )
          return result.id  # the message id — store it on the order
      except DairoError as err:
          if err.status_code == 400 and "complained" in err.message:
              mark_unmailable(order["email"])  # recipient reported spam
              return None
          raise
  ```

  ```bash title="cURL" theme={null}
  curl -X POST https://api.dairo.app/v1/messages \
    -H "Authorization: Bearer $DAIRO_API_KEY" \
    -H "Content-Type: application/json" \
    -H "Idempotency-Key: receipt-1042" \
    -d '{
      "inboxId": "inbox_123",
      "to": "ada@example.com",
      "subject": "Your receipt for order #1042",
      "html": "<h1>Thanks for your order</h1><p>Order <strong>#1042</strong> — Total: <strong>$48.00</strong></p>"
    }'
  ```
</CodeGroup>

An immediate send completes while the request is open, so the response is the
final outcome — `sent` with an id, or a precise error, never a "queued, maybe
later":

```json theme={null}
{
  "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "status": "sent",
  "providerMessageId": "msg_pm_9f8c2a1b4e7d",
  "warnings": [],
  "channel": "email"
}
```

Store the returned `id` on the order record. It is how you match the delivery,
bounce, and complaint events that arrive later.

When the recipient has previously complained, the send is refused instead:

```json theme={null}
{
  "error": {
    "type": "invalid_request_error",
    "code": "invalid_request",
    "message": "Could not send email: recipient complained (ada@example.com). Recipient complained — do not contact again. To override deliberately, set ignoreComplaints=true in the API/MCP request or pass --ignore-complaints in the CLI."
  }
}
```

<Note>
  A complaint refusal means stop, not retry. Overriding it with `ignoreComplaints`
  is a deliberate human decision, never an automation default —
  [Land in the inbox](/webhooks/deliverability) covers when that is appropriate.
</Note>

## Rich receipts with React

For anything beyond simple markup, send a
[React Email](https://react.email) component and Dairo renders it — no build
step or render dependency in your app. `source` accepts up to 64 KiB of
component code and `props` up to 32 KiB of JSON.

```ts theme={null}
await dairo.messages.send(
  {
    inboxId: RECEIPTS_INBOX,
    to: order.email,
    subject: `Your receipt for order #${order.id}`,
    react: {
      source: `export default function Receipt({ id, total }) {
        return (
          <div>
            <h1>Thanks for your order</h1>
            <p>Order #{id} — Total: {total}</p>
          </div>
        );
      }`,
      props: { id: order.id, total: order.total },
    },
  },
  { idempotencyKey: `receipt-${order.id}` },
);
```

One send carries exactly one body — `text`, `html`, `react`, or a stored
`template`. Combining two in a single request returns a `400`. If every receipt
shares one layout, save it as a [template](/templates/templates) and send it by
reference instead of inlining the source each time.

## Confirm delivery

Subscribe once to the delivery events and match each one back to its order by
`messageId` — no polling.

```bash theme={null}
dairo webhook create \
  --url https://yourapp.com/dairo/webhook \
  --event message.delivered \
  --event message.bounced \
  --event message.complained
# Store the printed signing secret — it is shown once.
```

Handle the events inside a signature-verified endpoint.
[Build a webhook receiver](/examples/webhook-receiver) is the complete endpoint
this handler slots into:

```ts theme={null}
switch (event.type) {
  case "message.delivered":
    await markReceiptDelivered(event.data.messageId);
    break;
  case "message.bounced":
    // bounceType "Permanent" means the address is dead — stop sending to it.
    await flagBadAddress(event.data.recipient, event.data.bounceType);
    break;
  case "message.complained":
    // Dairo already blocks future sends to this address; mirror it in your data.
    await markUnmailable(event.data.recipient);
    break;
}
```

If you would rather not run a receiver, poll instead:
`dairo.messages.get(messageId)` returns the send with its current status, and
`dairo.messages.listEvents(messageId)` returns its delivery timeline.

## Next steps

* [Send an email](/sending/sending-email) — every request field, plus
  scheduling, attachments, and contacts.
* [Track delivery](/sending/outbound-tracking) — the status lifecycle behind
  the `message.*` events.
* [Webhooks](/webhooks/webhooks) — every event type, header, and payload.
