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

# Send your first transactional email

> A first-send walkthrough — verify your sending domain, create a sender profile, send a rendered template, subscribe to bounce and complaint webhooks, and handle the suppression list.

# Send your first transactional email

This is the one-shot path to a first real send: verify your domain, create a
sender, render a template, dispatch, and watch the result arrive on a
webhook. Everything below is runnable — substitute your own API key,
domain, and recipient.

For the full day-one-to-at-scale plan (warm-up ramps, bulk patterns,
deliverability posture), see the
[Email channel lifecycle guide](/guides/email-lifecycle-guide). This page
gets you to one delivered email; that one takes you to volume.

**You will:**

1. [Verify your sending domain](#1-verify-your-sending-domain)
2. [Create a sender profile](#2-create-a-sender-profile)
3. [Send a templated message](#3-send-a-templated-message)
4. [Subscribe to delivery and bounce events](#4-subscribe-to-delivery-and-bounce-events)
5. [Handle the suppression list](#5-handle-the-suppression-list)
6. [Debug the common first-send errors](#6-common-first-send-errors)
7. [Hand off to the at-scale plan](#7-at-scale)

<Tip>Prefer to skip DNS work while you learn the API? Finish the round trip first against the sandbox — a test key (`dv_test_sk_...`) or the `X-Test-Mode: true` header runs the same request shapes with nothing delivered and no real recipient touched. The sandbox guide is [Sandbox and test mode](/guides/sandbox-test-mode); register the real domain in step 1 before your first live send.</Tip>

Every example below assumes a verified domain and a registered sender. If
you draft them against a sandbox key (`dv_test_sk_...`), they short-circuit
before delivery but still exercise validation, so the integration code you
write in the sandbox runs unchanged on live keys. Do the domain
verification in step 1 before you flip to live traffic.

## 1. Verify your sending domain

Everything downstream — sender validation, tracking, deliverability — hangs
off a verified domain. Add your domain under **Channels → Email →
Domains**, which lists the exact DNS records (SPF, DKIM, DMARC, and the
Return-Path CNAME) to publish at your DNS provider.

Once the records propagate, run **Verify DNS** on the domain row and
confirm the per-record traffic-light view grades green:

```bash theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/settings/channels/email/verify-dns \
  -H "X-API-Key: dv_live_sk_..."
```

```bash theme={null}
curl https://api.orbit.devotel.io/api/v1/email/domains/default/dns-status \
  -H "X-API-Key: dv_live_sk_..."
```

Every record you intend to sign with must grade `valid`. If your DNS lives
in a Cloudflare zone, the
[Cloudflare auto-configure guide](/guides/email-cloudflare-dns-auto-configure)
can create the missing records for you from the same domain row; either
way, re-run Verify DNS after any manual change.

## 2. Create a sender profile

Sends need a registered sender row so the platform can bind your `from`
address to a verified domain. Register one under **Channels → Email →
Senders**, or over the API:

```bash theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/email/senders \
  -H "X-API-Key: dv_live_sk_..." \
  -H "Content-Type: application/json" \
  -d '{
    "domain": "yourdomain.com",
    "default_from_email": "hello@yourdomain.com",
    "default_from_name": "Acme"
  }'
```

The response carries your sender id (`esend_...`). When you later omit
`from` on a send, the sender marked `isDefault` resolves the address; set
that flag on the one you want as the fallback. One sender row is enough
for a first send — add aliases later.

## 3. Send a templated message

The send endpoint takes a finished `html` (or `text`) body — it does not
accept a `template_id`. Either `html` or `text` must be non-empty, or the
request is rejected before anything is queued.

The one-shot send — inline body, curl, done:

```bash theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/messages/email \
  -H "X-API-Key: dv_live_sk_..." \
  -H "Content-Type: application/json" \
  -d '{
    "to": "user@example.com",
    "from": "hello@yourdomain.com",
    "subject": "Your Acme code",
    "html": "<p>Your one-time code: 123456</p>"
  }'
```

A clean dispatch returns **202** with `data.succeeded` carrying one row
per recipient, and the envelope `meta.request_id` to correlate with the
webhook events below.

For template variables (`{{...}}` merge tags in a saved block layout) you
render first, then send the rendered HTML — two passes:

1. Render with `POST /api/v1/messages/email-builder/render`. The response
   carries the rendered `html`, an AMP-for-Email alternative, and a
   `templateId` to correlate with a later save.
2. Resolve every `{{...}}` tag in the rendered HTML, then dispatch it
   through the same send endpoint. An unresolved tag lands on the wire as
   the literal string, so resolve them all before sending.

```python theme={null}
import os, requests

key = os.environ["ORBIT_API_KEY"]
base = "https://api.orbit.devotel.io"
headers = {"X-API-Key": key, "Content-Type": "application/json"}

rendered = requests.post(
    f"{base}/api/v1/messages/email-builder/render",
    headers=headers,
    json={"name": "welcome_email",
          "blocks": [{"type": "text", "content": {"text": "Your one-time code: {{code}}"}}]},
).json()["data"]["html"]

html = rendered.replace("{{code}}", "123456")

resp = requests.post(
    f"{base}/api/v1/messages/email",
    headers=headers,
    json={"to": "user@example.com", "subject": "Your Acme code", "html": html},
)
print(resp.status_code, resp.json()["data"]["succeeded"])
```

Save the template for reuse with
`POST /api/v1/messages/email-builder/save` — the full mechanics are in
[Using Templates](/channels/email#using-templates).

## 4. Subscribe to delivery and bounce events

Register one endpoint that subscribes the delivery family and the
engagement pair, and filter on `channel: "email"`:

* `message.delivered` — accepted by the recipient server
* `message.failed` — bounced (hard or soft) or marked as spam
* `email.opened` / `email.clicked` — engagement (first-open deduped;
  clicks fire on every distinct click)

```bash theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/webhooks \
  -H "X-API-Key: dv_live_sk_..." \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://yourapp.example.com/webhooks/orbit",
    "events": ["message.delivered", "message.failed", "email.opened", "email.clicked"]
  }'
```

Verify the `X-Orbit-Signature` header on every delivery and return `2xx`
within the delivery window — the walkthrough is in
[Receive your first webhook](/guides/first-webhook-quickstart). In sandbox
mode the send is short-circuited, so you can exercise your verification
and retry handling without a live recipient; the
[sandbox guide](/guides/sandbox-test-mode) covers the canned outcomes.

## 5. Handle the suppression list

Hard bounces, exhausted soft bounces, complaints, and unsubscribes are
held out of future sends automatically, and the served quota slot is
refunded at the pre-send gate. Manage the list yourself under **Channels →
Email → Suppressions** — search it, export, add manual entries, or
bulk-import from a previous ESP.

Removal is a deliberate action: a re-bounce or a re-complaint re-suppresses
the address. For a per-contact reachability check before you route a
send, use the
[contact deliverability health](/guides/contact-deliverability-health)
guide.

## 6. Common first-send errors

* **Domain unverified (403).** A provider-side `403` returns as
  `403 VALIDATION_ERROR` with `details.provider_message`. Most often this
  is a sending domain whose verification regressed or never completed. Re-run
  Verify DNS, then check
  `GET /api/v1/email/domains/:domainId/dns-status` until every record
  grades `valid`.
* **`CHANNEL_NOT_CONFIGURED` (503).** The email provider was never set up
  for the tenant. Finish step 1, then your sender in step 2.
* **Suppressed recipient (`RECIPIENT_OPTED_OUT`, 422).** The recipient is
  on your suppression list and the quota slot is refunded. Route the flow
  to an alternate channel instead of un-suppressing by reflex.
* **Invalid `from` (`NO_SENDER_CONFIGURED`, 422).** No sender resolved —
  either no row owns the address and no default applies, or the defaulted
  address never got registered. Re-check step 2.
* **`VALIDATION_ERROR` (422) on the body.** Malformed addresses, an empty
  body, or a disallowed attachment. Either `html` or `text` must be
  non-empty; unresolved merge tags would ship the literal `{{...}}` string
  to the recipient, so resolve them all before dispatch.
* **`RATE_LIMITED` (429).** You crossed the per-tenant channel pool
  (roughly 200 sends per minute). Back off and retry.

The full catalog with remediations is in
[Error Catalog](/channels/email#error-catalog).

## 7. At scale

This guide got you one clean send. The
[Email channel lifecycle guide](/guides/email-lifecycle-guide) is the
day-one-to-at-scale plan — warm-up ramps before dedicated-IP volume, bulk
sends up to 10,000 recipients per call, per-domain open and click
tracking, and per-contact deliverability health. The
[Channels → Email reference](/channels/email) indexes the full channel
surface.
