Receiving email
Accept mail at your own domain and hand each message to your app as a webhook.
Replies to a support address, documents mailed in by customers, a reply+ticket-381@ address per conversation: once inbound is on for a domain, every message it accepts is stored and announced with an email.received event. Your app fetches the parsed text and HTML when it needs them.
1. Turn inbound on
This needs a full_access key. 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}'{
"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 mx record exactly as returned, on the domain name itself, here mail.acme.com. Until it resolves, senders keep delivering wherever the old MX pointed. The ingress_url is for forwarding from a mail server you already run (below); it contains a secret, so treat it like a key. Calling this endpoint again issues a new one and the old one stops working.
Which addresses are accepted
With catch_all left at its default of true, anything at the domain is accepted. With false, only addresses you list are, which keeps a stream of spam to random local parts out of your app:
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 be at that domain; anything else is a 400. Mail to an address that is not listed is refused at the door rather than stored.
2. 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"]}'The event carries the envelope and subject, not the body:
{
"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": "..."
}
}3. The handler
Verify the signature exactly as in Handling bounces, then fetch the message. Reading it needs a full_access key, so keep that key on the server.
// verify() is the function from "Handling bounces".
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 })
const fresh = await db.query(
"INSERT INTO processed_events (id) VALUES ($1) ON CONFLICT DO NOTHING",
[event.id]
)
if (fresh.rowCount === 0) return new Response(null, { status: 200 })
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 us retry the delivery; 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]
await db.query(
"INSERT INTO ticket_messages (ticket_id, sender, subject, body_text, body_html) VALUES ($1, $2, $3, $4, $5)",
[ticket ?? null, message.from, message.subject, message.text, message.html]
)
return new Response(null, { status: 200 })
}GET /inbound/:id returns the same fields as the event plus text and html, either of which can be null if the sender did not include that part.
<iframe sandbox=""> or run it through an HTML sanitiser first, and prefer text for anything you parse. The from address is whatever the sender claimed; do not use it as proof of identity.Listing what arrived
Useful for a backfill after an outage, or for checking that mail is landing at all. Newest first, up to 100 per page:
curl "https://api.rovela.dev/inbound?limit=50&offset=0" \
-H "Authorization: Bearer re_your_full_access_key"Already running a mail server?
Skip the MX change and have your server forward the raw message to the ingress_url. The body is the message exactly as received, with the envelope in headers. A request over 2 MB is 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 queue id>" \
--data-binary @message.emlX-Envelope-Tois required and goes through the same recipient rules as mail arriving by MX.- Send a stable
X-Provider-Event-Id. A retried forward with the same id is recognised as a duplicate and does not fireemail.receiveda second time.