> ## 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.

# One inbound email, end to end

> Follow a single support email from the MX record to the signed parse POST, the inbox ticket, and the reply — so you know exactly which pieces (route, webhook receiver, Flow) your setup needs.

# One inbound email, end to end

Inbound email on Orbit has two reference guides: [Inbound email and SMS routing](/guides/inbound-email-parse) documents the parse webhook anatomy and the DNS/console steps, and [Route inbound support mail to the right team and ticket](/guides/inbound-email-to-ticket-route-recipes) documents the route-to-ticket patterns. This guide is the missing piece between them: one concrete email, followed through every surface in order, so you leave knowing whether your setup needs a route, a webhook receiver, a Flow, or all three.

**You will:**

1. [Pick your guide](#1-pick-your-guide)
2. [Meet the email you will follow](#2-meet-the-email-you-will-follow)
3. [Point the domain at Orbit](#3-point-the-domain-at-orbit)
4. [Watch SMTP receive become a signed parse POST](#4-watch-smtp-receive-become-a-signed-parse-post)
5. [Verify the signature](#5-verify-the-signature)
6. [Read the message in the dashboard](#6-read-the-message-in-the-dashboard)
7. [Send the reply](#7-send-the-reply)
8. [Recognize the failure paths](#8-recognize-the-failure-paths)

## 1. Pick your guide

| You are trying to | Read |
| - | - |
| Look up what the parse webhook carries, every `recipientPattern` shape, and the DNS/console steps | [Inbound email and SMS routing](/guides/inbound-email-parse) |
| Compose a shared mailbox with subject-branched Flows, per-queue tickets, and per-route acks | [Route inbound support mail to the right team and ticket](/guides/inbound-email-to-ticket-route-recipes) |
| Follow one message through every surface once, in order | This guide |

The question every new email customer asks is whether they need a route, a webhook, a Flow, or all three. The answer, in one paragraph: the **route** is the only required piece — no route match means no parse and no ticket. The **webhook receiver** is where the signed parse POST goes; it is optional if the dashboard alone is enough for your team, because the inbox ticket opens whether or not your endpoint answers. A **Flow** is optional automation on top — an auto-reply or a branch — and is only needed when something must happen without a human. This walkthrough wires all three surfaces minimally so you can see each one do its job.

## 2. Meet the email you will follow

Every example in this guide follows the same message. A customer, Maya, writes to your support mailbox with a DKIM-signed reply, one PDF attachment, and one inline image:

```text theme={null}
Return-Path: <maya@brightline.co>
Received: from mail.brightline.co (mail.brightline.co [203.0.113.21])
        by orbit-ingest (Postfix) with ESMTPS id 4XyZ1a2b3c;
        Tue, 6 Oct 2026 09:14:32 +0000 (UTC)
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=brightline.co;
        s=mail; h=from:to:subject:date:message-id; bh=…; b=…
From: Maya Okafor <maya@brightline.co>
To: support@acme.example
Subject: Invoice question — order #1234
Message-ID: <95b1f744e4e2@brightline.co>
MIME-Version: 1.0
Content-Type: multipart/mixed; boundary="orbit-demo"

--orbit-demo
Content-Type: multipart/related; boundary="orbit-related"

--orbit-related
Content-Type: text/html; charset=utf-8

<p>Hi — quick question about the attachment on order #1234.</p>
<p><img src="cid:graph-001@brightline.co" alt="Usage graph"></p>

--orbit-related--
--orbit-demo
Content-Type: application/pdf; name="order-1234.pdf"
Content-Disposition: attachment; filename="order-1234.pdf"
Content-Transfer-Encoding: base64

JVBERi0xLjQKJeLjz9MKMSAwIG9iago8…(bytes trimmed)
--orbit-demo--
```

That raw MIME is the **input** to Orbit's inbound pipeline. Everything after this section is what Orbit does with it, and what you see at each surface. The DKIM signature above is the *sender's* — it proves the mail came from `brightline.co`. The signature *you* verify is the one Orbit adds to the webhook forward in [step 5](#5-verify-the-signature); the two solve different problems.

## 3. Point the domain at Orbit

Open **Email → Inbound routes → Add route** in the dashboard. The surface shows the ingress hostname your MX records must point at for the domain you register. At your DNS provider, set the MX record for the receiving domain (a dedicated domain or subdomain such as `acme.example` keeps inbound parse separate from your corporate mail):

```text theme={null}
; DNS provider zone editor
acme.example.    300    IN    MX    10    <ingress hostname shown in Add route>
```

Confirm propagation from your side before sending test mail:

```bash theme={null}
dig MX acme.example +short
# 10 <ingress hostname shown in Add route>
```

While the MX does not resolve, no mail arrives at all — there is nothing to debug in Orbit yet, because the message never reached ingress. Once `dig` returns the hostname and the console shows the domain as verified, send Maya's message from [step 2](#2-meet-the-email-you-will-follow) (or forward a real one to `support@acme.example`).

Next, register the route that catches the mailbox. `POST /email/inbound-routes` takes the recipient pattern and your webhook URL:

```bash theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/email/inbound-routes \
  -H "X-API-Key: dv_live_sk_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "recipientPattern": "support@acme.example",
    "destinationUrl": "https://api.acme.example/hooks/orbit-inbound",
    "includeRawMime": false,
    "description": "Support mailbox → helpdesk webhook"
  }'
```

The `201` response carries the route's `signingSecret` exactly once — store it now, because list and get responses mask it. You use it in [step 5](#5-verify-the-signature).

```json theme={null}
{
  "data": {
    "id": "einr_a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6",
    "recipientPattern": "support@acme.example",
    "destinationUrl": "https://api.acme.example/hooks/orbit-inbound",
    "signingSecret": "einrsk_c0ffee…",
    "active": true
  }
}
```

`recipientPattern` accepts the exact address (used here), a catch-all (`*@acme.example`), or a bare local-part (`support`). Matching runs against the SMTP envelope recipient first, then the `To` and `Cc` headers, so BCC and forwarded mail route correctly. Registering the same pattern twice returns `409 CONFLICT`. The full pattern reference lives in [Inbound email and SMS routing](/guides/inbound-email-parse).

## 4. Watch SMTP receive become a signed parse POST

Ingress accepts Maya's message over SMTP, matches the route from [step 3](#3-point-the-domain-at-orbit), and parses the raw MIME from [step 2](#2-meet-the-email-you-will-follow) into normalized fields. Your `destinationUrl` then receives a signed JSON POST:

```json theme={null}
{
  "from": "Maya Okafor <maya@brightline.co>",
  "to": "support@acme.example",
  "toAddress": "support@acme.example",
  "matchedRecipient": "support@acme.example",
  "subject": "Invoice question — order #1234",
  "text": "Hi — quick question about the attachment on order #1234. …",
  "html": "<p>Hi — quick question about the attachment on order #1234.</p><p><img src=\"cid:graph-001@brightline.co\" alt=\"Usage graph\"></p>",
  "attachments": [
    {
      "filename": "order-1234.pdf",
      "contentType": "application/pdf",
      "size": 84321,
      "contentId": null
    },
    {
      "filename": "usage-graph.png",
      "contentType": "image/png",
      "size": 18204,
      "contentId": "graph-001@brightline.co"
    }
  ],
  "headers": {
    "from": "Maya Okafor <maya@brightline.co>",
    "to": "support@acme.example",
    "subject": "Invoice question — order #1234",
    "message-id": "<95b1f744e4e2@brightline.co>",
    "dkim-signature": "v=1; a=rsa-sha256; d=brightline.co; … (trimmed)"
  }
}
```

Read it against the raw MIME:

* `text` and `html` are the decoded body parts (either can be `null`); long bodies are shown trimmed here but arrive complete.
* `attachments` is **metadata only**: filename, MIME type, decoded byte size, and the inline image's `Content-ID` in `contentId`. Attachment bytes are never inlined into the JSON — set `includeRawMime: true` on the route when you need the full original message alongside the normalized fields.
* `headers` carries every top-level header as a lowercased key map, including `message-id` and the sender's `dkim-signature`.
* `matchedRecipient` tells you which registered pattern caught the mail — useful when a catch-all serves several mailboxes.

The same parse that produced this POST also opens (or threads onto) an inbox ticket, in parallel with the forward. You will read that ticket in [step 6](#6-read-the-message-in-the-dashboard); the two surfaces work side by side and neither depends on the other succeeding.

## 5. Verify the signature

The forward arrives with an `X-Orbit-Signature` header: `sha256=<hex>`, an HMAC over the raw request body keyed by the route's `signingSecret` from [step 3](#3-point-the-domain-at-orbit). Verify it before trusting anything in the body. A minimal Node receiver:

```typescript theme={null}
import crypto from "node:crypto";
import express from "express";

const app = express();
app.post(
  "/hooks/orbit-inbound",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const sig = req.get("X-Orbit-Signature") ?? "";
    const expected =
      "sha256=" +
      crypto
        .createHmac("sha256", process.env.ORBIT_INBOUND_ROUTE_SECRET)
        .update(req.body)
        .digest("hex");
    if (
      sig.length !== expected.length ||
      !crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))
    ) {
      res.sendStatus(401);
      return;
    }
    const inbound = JSON.parse(req.body.toString());
    res.sendStatus(200); // answer 2xx first; only a 2xx counts as a delivered forward
    handleInbound(inbound); // your ticketing/bookkeeping work, async
  },
);
```

The parse handshake is the same shape in every language — Python, Go, Ruby, and PHP variants are in [Verify webhook signatures](/guides/verify-webhook-signatures). Two rules make the receiver honest: verify before reading the body, and return a 2xx promptly. Redirects are not followed, non-2xx responses (including your own 401s) count as forward failures, and the route's `failureCount` / `lastFailureAt` on `GET /email/inbound-routes` tell you when forwards are failing.

## 6. Read the message in the dashboard

While your webhook was being forwarded, the same message opened an inbox ticket. Open the inbox and find the thread by subject or sender — for Maya's mail, a ticket titled **Invoice question — order #1234** from `maya@brightline.co`. The ticket carries the parsed body as its transcript and the sender's address as the contact, so a second mail from Maya threads onto the same ticket instead of opening a new one.

The attachment story in the dashboard is one step behind the webhook payload: each decoded part is scanned before it is archived to your media store. A clean part (Maya's `order-1234.pdf`) appears in the message detail with a fresh download URL; the inline image is recorded by its `Content-ID`. A part the scan drops never reaches the store — the payload's `attachments` entry still lists it (metadata only), and the message record notes the drop with finding codes under `metadata.email_attachments_quarantined`. The [inbound attachment threat scan](/concepts/inbound-attachment-threat-scan) concept page covers the clean / suspicious / malicious verdicts; [Operate the Inbox Tickets queue](/guides/inbox-tickets-workflow) covers working the queue.

The conversation also appears as a row under Maya's contact on the [Interactions](/interactions/overview) page, which merges conversations and calls into one recency-ordered list — the place to look when you want the contact's whole history rather than one ticket.

## 7. Send the reply

Two surfaces can answer Maya, and both keep the story coherent:

* **From the ticket.** The inbox reply composer answers `maya@brightline.co` and threads the exchange onto the same ticket — the designated path for agent-handled mail, and the one that cannot accidentally loop back into your own parse domain.
* **From the API.** `POST /messages/email` sends the outbound half. Send from the same verified sender address the conversation started with, and keep the subject threaded with `Re:` so Maya's mail client files the reply under the original message:

```bash theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/messages/email \
  -H "X-API-Key: dv_live_sk_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "to": "Maya Okafor <maya@brightline.co>",
    "from": "support@acme.example",
    "reply_to": "support@acme.example",
    "subject": "Re: Invoice question — order #1234",
    "html": "<p>Hi Maya — the attachment on order #1234 is the usage graph you asked about. Details below.</p>"
  }'
```

`from` must resolve to a verified sender on your account, and at least one of `html` / `text` must be non-empty; the full field table is in the [email channel reference](/channels/email). When Maya writes back, her reply matches the route from [step 3](#3-point-the-domain-at-orbit) again and threads onto the existing ticket, closing the loop.

<Note>
  If you automate the reply with a Flow instead of a human or a direct API call, guard it: an unguarded send back at your own parse domain closes the loop on itself and re-triggers the Flow. The [troubleshooting guide](/guides/inbound-email-troubleshooting) covers the reply-to / subject guards.
</Note>

## 8. Recognize the failure paths

The four failures every new setup hits once, and how each one looks from your side:

| Failure | What you see | First check |
| - | - | - |
| Unsigned or tampered parse POST | Your receiver rejects it with 401; Orbit records a forward failure (`failureCount` / `lastFailureAt` on `GET /email/inbound-routes`) while the inbox ticket still opens | Verify `sha256=<hex>` over the raw body with the route's `signingSecret`, and return 2xx only after the check passes |
| Attachment over the ingest limit or flagged by the threat scan | The payload still lists the part (metadata only), but it never reaches the media store; the message record carries the drop with finding codes | Read `metadata.email_attachments_quarantined`; the [threat scan model](/concepts/inbound-attachment-threat-scan) explains the verdicts and the [attachments guide](/guides/email-attachments) lists the limits |
| DNS not verified | Nothing arrives anywhere — no webhook forward, no ticket, no trace in Orbit | `dig MX acme.example +short` must return the ingress hostname; until it does, the message never reaches ingress |
| No route matches the recipient | The message reaches ingress but is not parsed — no conversation, no forward | Check `recipientPattern` coverage against the envelope recipient (matched first, then `To` and `Cc`), in exact, catch-all, or local-part syntax, and confirm `active: true` on the route |

When none of the four explains it, work the [inbound email troubleshooting guide](/guides/inbound-email-troubleshooting) top to bottom — it isolates DNS, route matching, normalization, and trigger issues one stage at a time, and lists exactly what to collect before escalating.

## Next steps

* [Inbound email and SMS routing](/guides/inbound-email-parse) — the full webhook anatomy and route reference this tutorial walked
* [Route inbound support mail to the right team and ticket](/guides/inbound-email-to-ticket-route-recipes) — the shared-mailbox pattern built on top of this loop
* [Close the email loop: inbound parse, Flow reply, ticket](/guides/first-email-loop) — the outbound-then-inbound acknowledgement loop
* [Verify webhook signatures](/guides/verify-webhook-signatures) — the polyglot verify snippets for the parse handshake
* [Troubleshooting: inbound email never routes](/guides/inbound-email-troubleshooting) — when a message stops somewhere in this page's pipeline


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.