← Back to blog

Inbound Email API (2026): Receive Mail as Signed Webhooks

Turn on inbound for a verified domain, subscribe to email.received, verify the signature, then fetch the body—receive replies without running your own MX stack.

Updated November 4, 2026
Inbound Email API (2026): Receive Mail as Signed Webhooks

Most support products start with outbound mail: OTP codes, password resets, ticket updates. The next hard step is inbound—when a customer replies to support@yourdomain.com, or when you give each conversation a reply+ticket-381@… address and need that message inside your app within seconds.

You can run your own MX stack for that. Or you can turn inbound on for a verified domain, subscribe to a signed webhook, and fetch the parsed body when you need it. This article walks that path on Rovela, with the same shapes as the Receiving email example. For the product-level "send API plus mailboxes and inbound" framing, see Resend Alternative (2026): Transactional Email with Mailboxes and Inbound.

Why receive mail as a webhook

Inbound as an application input is different from "someone checks webmail." Your handler needs:

  • A stable event when mail is accepted (email.received)
  • Envelope metadata (from, to, subject) without downloading every body on every delivery
  • A way to pull text / html when you are ready to store or display the message
  • Cryptographic proof that the POST came from Rovela, not a stranger who guessed your URL

Rovela stores each accepted inbound message and announces it with email.received. The event carries the envelope, not the body. Your app verifies the signature, then GET /inbound/:id for the content. That split keeps webhook payloads small and lets you fail a fetch with 500 so Rovela retries delivery without losing the message.

Typical overseas SaaS shapes this covers: shared support@ inboxes that open tickets, per-thread reply+ticket-…@ addressing, and "email us this PDF" workflows—without operating Postfix yourself.

1. Enable inbound and publish MX

Inbound management needs a full_access API key (or a dashboard session). Use the id of a domain you have already verified:

curl -X POST https://api.rovela.dev/domains/<domain_id>/inbound \
  -H "Authorization: Bearer re_your_full_access_key" \
  -H "Content-Type: application/json" \
  -d '{"catch_all": false}'

The response looks like:

{
  "object": "domain_inbound",
  "domain_id": "<domain_id>",
  "domain": "mail.acme.com",
  "enabled": true,
  "catch_all": false,
  "ingress_url": "https://api.rovela.dev/inbound/<domain_id>/<token>",
  "mx": { "host": "<mx host>", "priority": 10 }
}

Publish the returned mx record exactly as given, on the domain name itself (here mail.acme.com). Until that MX resolves, senders keep delivering wherever the old MX pointed—so plan cutovers the same way you would for any mailbox provider.

The ingress_url is for forwarding from a mail server you already run (see below). It contains a secret: treat it like a key. Calling the enable endpoint again issues a new URL and the old one stops working.

Keep sending_access keys in the processes that call POST /emails. Keep full_access on servers that manage domains, webhooks, and inbound fetches—never in a browser or mobile app. See Authentication.

2. Catch-all vs an allowlist

With catch_all left at its default of true, anything at the domain is accepted. That is convenient for reply+…@ addressing, and noisy if random local parts start filling your event stream with spam.

With catch_all: false, only addresses you list are accepted. Mail to an unlisted local part is refused at the door, not stored:

curl -X POST https://api.rovela.dev/domains/<domain_id>/inbound/recipients \
  -H "Authorization: Bearer re_your_full_access_key" \
  -H "Content-Type: application/json" \
  -d '{"address": "support@mail.acme.com"}'

The address must belong to that domain; anything else is a 400. For ticket reply addressing, either enable catch-all on a dedicated inbound subdomain, or pre-list the patterns your product actually uses.

3. Subscribe to email.received

curl -X POST https://api.rovela.dev/webhooks \
  -H "Authorization: Bearer re_your_full_access_key" \
  -H "Content-Type: application/json" \
  -d '{
    "endpoint": "https://app.acme.com/webhooks/rovela",
    "events": ["email.received"]
  }'

Store the signing secret from the create response immediately—it is shown once. Prefer an explicit events list over an empty array (empty means "everything, including types added later"). Details: Webhooks overview and Events.

A typical email.received payload:

{
  "id": "evt_81d0...",
  "type": "email.received",
  "created_at": "2026-10-05T09:41:07.552+00:00",
  "data": {
    "inbound_message_id": "3b7e9a12-...",
    "domain_id": "<domain_id>",
    "to": "support@mail.acme.com",
    "from": "customer@example.com",
    "subject": "Re: Order #1042 arrived damaged",
    "provider": "smtp-in",
    "provider_event_id": "..."
  }
}

Use data.to to route (support@ vs reply+ticket-381@…). Deduplicate on event id—retries reuse the same id.

4. Verify the signature on every request

Your endpoint is a public URL. Verify x-resend-signature before you act. The header format is:

x-resend-signature: t=<unix_timestamp>,v1=<hmac_sha256_hex>

HMAC-SHA256 is computed over `${timestamp}.${rawBody}` with the endpoint signing secret, hex-encoded. Use the raw bytes you received—not JSON.stringify(JSON.parse(body)). Reject deliveries older than your replay window (docs use five minutes). Full walkthrough: Webhook security and the blog post Verifying Webhook Signatures.

Return 2xx quickly once you have accepted custody of the event; do heavy work in your own queue. Status codes matter for retries:

Your responseRovela behavior
2xxDelivery succeeded
5xx, 408, 429Retried (up to five attempts, backoff 1s → 2s → 4s → 8s)
Other 4xx (including 400)Permanent failure—that event is not sent again

So: 400 for a bad signature is correct (do not retry forgeries). 500 when your DB or the inbound fetch is temporarily down is correct (spend a retry). Do not return 400 for "I do not understand this field yet"—store and ignore unknown fields; events are additive.

5. Fetch the body with GET /inbound/:id

Reading inbound content also needs full_access:

curl https://api.rovela.dev/inbound/<inbound_message_id> \
  -H "Authorization: Bearer re_your_full_access_key"

The response includes the same envelope fields as the event, plus text and html (either may be null if the sender omitted that part).

Minimal handler shape (signature verify omitted—use the function from the security docs):

export async function POST(request) {
  const rawBody = Buffer.from(await request.arrayBuffer())
  const sig = request.headers.get("x-resend-signature") ?? ""
  if (!verify(process.env.ROVELA_WEBHOOK_SECRET, sig, rawBody)) {
    return new Response("invalid signature", { status: 400 })
  }

  const event = JSON.parse(rawBody.toString("utf8"))
  if (event.type !== "email.received") {
    return new Response(null, { status: 200 })
  }

  // Deduplicate on event.id, then:
  const res = await fetch(
    "https://api.rovela.dev/inbound/" + event.data.inbound_message_id,
    { headers: { Authorization: "Bearer " + process.env.ROVELA_FULL_ACCESS_KEY } }
  )
  // A 500 makes Rovela retry; the message is not lost.
  if (!res.ok) return new Response("fetch failed", { status: 500 })
  const message = await res.json()

  // reply+ticket-381@mail.acme.com -> ticket 381
  const ticket = /^reply\+ticket-(\d+)@/.exec(message.to)?.[1]

  // persist message.from, message.subject, message.text, message.html
  return new Response(null, { status: 200 })
}

Security notes that matter in production:

  • Inbound HTML was written by a stranger. Never insert it into your own pages raw. Prefer text for parsing; show HTML in a sandboxed iframe or after an HTML sanitizer.
  • from is whatever the sender claimed. It is not proof of identity.
  • List recent mail with GET /inbound?limit=50&offset=0 for backfills after an outage (Receiving email).

6. Already running a mail server?

Skip the MX change and forward the raw message to ingress_url. The body is the message exactly as received; put the envelope in headers. Requests over 2 MB are refused with 413:

curl -X POST "$INGRESS_URL" \
  -H "X-Envelope-To: support@mail.acme.com" \
  -H "X-Envelope-From: customer@example.com" \
  -H "X-Provider-Event-Id: <your-stable-id>" \
  --data-binary @message.eml
  • X-Envelope-To is required and goes through the same recipient rules as MX delivery.
  • Send a stable X-Provider-Event-Id. A retried forward with the same id is treated as a duplicate and does not fire email.received twice.

This path is useful when corporate MX must stay on an existing MTA, but ticket replies still need to hit your SaaS.

Ops checklist

  1. Verify the domain for sending first; enable inbound on the hostname that should receive mail.
  2. Decide catch-all vs recipients before you announce support@ publicly.
  3. Subscribe only to email.received (plus bounce/complaint types if you also send).
  4. Verify signatures; dedupe on event.id; return 2xx fast.
  5. Fetch bodies with full_access server-side; sanitize HTML.
  6. If you regenerate inbound, update any MTA forwarders—the old ingress_url dies immediately.
  7. Events are not ordered; sort on created_at if sequence matters (overview).

Next steps

  1. Docs: Receiving email · Webhooks overview · Security · Events
  2. Sibling: Resend alternative with mailboxes and inbound
  3. Signature deep-dive: Verifying webhook signatures
  4. Outbound auth mail that often sits next to inbound support: Transactional email for AI apps

Bottom line: inbound on Rovela is enable → MX (or ingress forward) → email.received → verify → GET /inbound/:id. You get signed, retryable deliveries without operating the full receive stack yourself—and your support replies and ticket addresses land in the same product that already sends your transactional mail.