How to receive email with a webhook and parse inbound mail
Published Updated 19 min readBy the Rasket team
Written for AI automation builders, developers and IT admins

To receive email with a webhook, point an MX record at an inbound service: it accepts each message, parses it and POSTs a signed JSON event to your HTTPS endpoint. With Rasket that event is email.received. It names the sender, the recipients, the subject and the attachments, and the bodies are one API call away.
Say a customer answers your “Your order has shipped” email with a photo of a crushed box. You want that to open a ticket with the photo attached, not sit unread in a shared inbox. The first decision is which name the MX record goes on.
Where mail is received, and the decision that matters
An MX record says which servers accept mail for one exact name. Mail for anything@acme.example follows the MX of acme.example; mail for anything@inbound.acme.example follows the MX of that subdomain and nothing else.
A domain you add to Rasket receives at the domain itself by default, so a customer writes to support@acme.example rather than an address with a label in it that nobody asked for. That suits a domain that gets no mail today. If Google Workspace or Microsoft 365 already receives on the domain, use a receiving label instead: inbound is the one the domain page offers.
| Receiving host | Who gets mail at acme.example | Use it when |
|---|---|---|
| The domain itself (default) | Rasket, for every address on the domain | The domain receives no other mail, or you are moving all of it |
The inbound label | Whoever has it today. Only mail to the subdomain comes to Rasket | Anyone already receives mail on the domain |
Check your current MX before you publish anything
Pointing the MX of acme.example at us moves every mailbox on the domain: Google Workspace or Microsoft 365 stop receiving as senders’ cached answers for the old record expire, within its TTL, and mail is split between the two until then. If the command below prints anything, use the inbound label. The API refuses to turn receiving on for a name whose MX already points at another provider (409 receiving_mx_in_use) unless you pass confirm_replace_mx: true, so the default cannot take your mail by accident. If the domain publishes an MTA-STS policy in enforce mode, its mx: lines name the old hosts and senders that enforce it will not deliver to us. Add our host to the policy (or set it to testing), change the id in its _mta-sts TXT record so senders fetch it again, and only then move the MX.
$ dig +short MX acme.example
1 aspmx.l.google.com.
5 alt1.aspmx.l.google.com.Domains added before 18 September 2026 receive on the inbound label; the domain page shows each domain’s receiving host.
Setting it up
- Check where the domain receives mail today — Run dig +short MX on the domain. If it prints a record, another provider receives there, so receive on a label instead.
- Turn receiving on for a verified domain — Add and verify the domain in a region that receives mail, then turn receiving on and pick the receiving host: the domain itself, which is the default for new domains, or a label.
- Give the address a mailbox — In the Inbox settings, create a mailbox for the address you will receive at, such as support@, or mark one mailbox as the catch-all. Mail to an address with neither is not delivered.
- Publish the MX record — Publish the one MX record the domain page lists for receiving, at the receiving host you picked. Mail starts arriving once it resolves; deleting it is the rollback.
- Subscribe a webhook to email.received — Create an endpoint in the dashboard or over the API, subscribe it to email.received, and store the whsec_ signing secret it returns, which is shown only once.
- Verify each delivery, then fetch what you need — Store each verified delivery by its svix-id and answer at once, then process it from a queue: route on received_for and fetch the bodies by email_id.
Turning receiving on for the label over the API, which leaves the root MX alone:
curl -X PATCH https://api.rasket.com/domains/$DOMAIN_ID \
-H "Authorization: Bearer $RASKET_API_KEY" \
-H "User-Agent: acme-support/1.0" \
-H "Content-Type: application/json" \
-d '{ "receiving_host": "inbound", "capabilities": { "receiving": "enabled" } }'Then publish the receiving MX record the domain page lists, with the host it shows. A CNAME already at inbound must go first, because a name with a CNAME can hold no other record:
inbound.acme.example. MX 10 (the host the domain page shows)And subscribe your endpoint. The response carries the endpoint’s whsec_ signing secret, which is shown only once:
curl -X POST https://api.rasket.com/webhooks \
-H "Authorization: Bearer $RASKET_API_KEY" \
-H "User-Agent: acme-support/1.0" \
-H "Content-Type: application/json" \
-d '{ "endpoint": "https://support.acme.example/webhooks/rasket", "events": ["email.received"] }'An address needs somewhere to land
A message produces an event only when it reaches a mailbox: one created for that exact address, such as support@, or the mailbox you mark as the catch-all in the Inbox’s settings. Mail for any other address is dropped (see the limits below). Each open mailbox counts toward your plan.
Our receiving servers offer STARTTLS and accept mail without it, so a sender that cannot encrypt still delivers. Rasket does not host an MTA-STS policy for the receiving name.
To undo it on a label, delete that record. If you move the domain itself, lower the old MX’s TTL to 300 seconds at least one old TTL ahead, so the switch, and any switch back, takes minutes, and note the old records so you can put them back. Mail that meets a temporary failure during a change is retried for days, not lost (RFC 5321 §4.5.4.1), but a hard rejection bounces at once, so keep the old provider accepting the domain until the old TTL has passed. Mail that reached us stays with us. Receiving runs in two of our regions, United States (East) and Ireland, and the domain’s region is fixed when you add it.
What the event carries, and what it does not
{
"type": "email.received",
"created_at": "2026-09-09T10:16:44.902Z",
"data": {
"email_id": "4ef9a417-02e9-4d39-ad75-9611e0fcc33c",
"created_at": "2026-09-09T10:16:44.902Z",
"from": "Ronald Williams <ronald.williams@example.com>",
"to": [
"support@inbound.acme.example"
],
"subject": "Order #1042 arrived damaged",
"message_id": "<CAF1042.damaged@mail.example.com>",
"bcc": [],
"cc": [
"billing@example.com"
],
"received_for": [
"support@inbound.acme.example"
],
"attachments": [
{
"id": "9a3e6d21-4b7c-4f05-8e1a-6c2d0b9f7e48",
"filename": "photo.png",
"content_type": "image/png",
"content_disposition": "inline",
"content_id": "photo-1042@example.com"
},
{
"id": "660e8400-e29b-41d4-a716-446655440000",
"filename": "damage-report.pdf",
"content_type": "application/pdf",
"content_disposition": "attachment"
}
]
}
}| Field | What it is |
|---|---|
email_id | Our id for the message. Fetch the bodies and attachments with it. |
from | The sender as the message presented it. A claim, not an identity. |
to | What the sender typed. Display it; do not route on it. |
received_for | The addresses of yours the message was accepted for. |
subject | The subject, or an empty string when there was none. |
message_id | The sender's Message-ID, or an empty string. You thread replies on it. |
attachments | Filename, content type, disposition and id. No bytes, no link. |
The text and HTML bodies and the full headers are on GET /emails/receiving/{email_id}, so a delivery stays small. The handler below reads headers, an object whose values are a string, or a list of strings when a header appears more than once:
{
"object": "email",
"id": "4ef9a417-02e9-4d39-ad75-9611e0fcc33c",
"to": [
"support@inbound.acme.example"
],
"from": "Ronald Williams <ronald.williams@example.com>",
"subject": "Order #1042 arrived damaged",
"message_id": "<CAF1042.damaged@mail.example.com>",
"bcc": [],
"cc": [
"billing@example.com"
],
"reply_to": [],
"received_for": [
"support@inbound.acme.example"
],
"html": "<p>The box was crushed on one side.</p>",
"text": "The box was crushed on one side.",
"headers": {
"X-Mailer": "Apple Mail (2.3774)"
},
"dropped_reason": null,
"created_at": "2026-09-09T10:16:44.902Z",
"attachments": [
{
"id": "9a3e6d21-4b7c-4f05-8e1a-6c2d0b9f7e48",
"filename": "photo.png",
"content_type": "image/png",
"content_id": "photo-1042@example.com",
"content_disposition": "inline",
"size": 48213
},
{
"id": "660e8400-e29b-41d4-a716-446655440000",
"filename": "damage-report.pdf",
"content_type": "application/pdf",
"content_disposition": "attachment",
"size": 20481
}
]
}Sanitise html before you render it: it is stored as the sender wrote it.
Neither the event nor the API carries an SPF, DKIM or DMARC verdict, or a spam score. The Inbox shows the SPF, DKIM, DMARC and spam verdicts our servers recorded on each message. The stored headers can hold an Authentication-Results line, but any server upstream can write one, so do not gate on it. Treat from as something the sender wrote: an inbound message must never reset a password or approve a payment because of who it says it is from. Confirm such a request another way, by replying to the address you already have on file or by asking the person to approve it while signed in.
A receiver that opens one ticket and answers once
The transport is receiver.mjs from the webhooks post, repeated below so this page runs on its own. It caps the body at 1 MB and checks the signature against the raw bytes. It stores each event by its svix-id and answers within the 10-second limit, before any work. A worker then hands each stored event to handle(). The file below is that handle() for email.received, and it refuses to start without both secrets. Save both files side by side and run them on Node 22.13 or later, which has node:sqlite. The server speaks plain HTTP, so put it behind your HTTPS proxy, or a tunnel while you test:
RASKET_API_KEY=rk_... RASKET_WEBHOOK_SECRET=whsec_... node receiver.mjsreceiver.mjs: the webhooks post's receiver, with handle() imported
import { handle } from "./support.mjs";
// receiver.mjs: a Rasket webhook receiver with no dependencies. Node 22.13 or later.
// RASKET_WEBHOOK_SECRET=whsec_... node receiver.mjs
import { createHmac, timingSafeEqual } from "node:crypto";
import { createServer } from "node:http";
import { DatabaseSync } from "node:sqlite";
const TOLERANCE_SECONDS = 5 * 60;
const MAX_BODY_BYTES = 1_000_000;
const MAX_ATTEMPTS = 5;
const secret = process.env.RASKET_WEBHOOK_SECRET ?? "";
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
if (key.length === 0) throw new Error("set RASKET_WEBHOOK_SECRET"); // an empty key verifies forgeries
// One table is both the dedupe record and the work queue.
const db = new DatabaseSync(process.env.WEBHOOK_DB ?? "webhooks.db");
db.exec(`CREATE TABLE IF NOT EXISTS webhook_events (
id TEXT PRIMARY KEY,
type TEXT NOT NULL,
payload TEXT NOT NULL,
created_at TEXT,
received_at INTEGER NOT NULL,
attempts INTEGER NOT NULL DEFAULT 0,
next_attempt_at INTEGER NOT NULL DEFAULT 0,
processed_at INTEGER
)`);
const insert = db.prepare(
"INSERT INTO webhook_events (id, type, payload, created_at, received_at) VALUES (?, ?, ?, ?, ?) ON CONFLICT (id) DO NOTHING",
);
function verify(raw, headers, nowSeconds) {
const id = headers["svix-id"];
const timestamp = headers["svix-timestamp"];
const signatures = headers["svix-signature"];
if (!id || !timestamp || !signatures) return "missing svix headers";
if (!/^\d+$/.test(timestamp) || Math.abs(nowSeconds - Number(timestamp)) > TOLERANCE_SECONDS) {
return "timestamp outside the five-minute window";
}
const expected = createHmac("sha256", key).update(`${id}.${timestamp}.`).update(raw).digest();
let matched = false;
for (const part of signatures.split(" ")) {
const [version, value] = part.split(",");
if (version !== "v1" || !value) continue;
const candidate = Buffer.from(value, "base64");
if (candidate.length === expected.length && timingSafeEqual(candidate, expected)) {
matched = true;
}
}
return matched ? null : "no signature matches";
}
createServer((req, res) => {
if (Number(req.headers["content-length"] ?? 0) > MAX_BODY_BYTES) {
res.writeHead(413, { connection: "close" }).end("payload too large");
return req.resume(); // discard the body without buffering it
}
const chunks = [];
let size = 0;
req.on("data", (chunk) => {
size += chunk.length;
if (size <= MAX_BODY_BYTES) chunks.push(chunk);
});
req.on("end", () => {
if (size > MAX_BODY_BYTES) return res.writeHead(413).end("payload too large");
const raw = Buffer.concat(chunks); // the exact bytes that were signed
const problem = verify(raw, req.headers, Math.floor(Date.now() / 1000));
if (problem) return res.writeHead(400).end(problem);
const body = raw.toString("utf8");
let event;
try {
event = JSON.parse(body);
} catch {
event = null;
}
if (typeof event?.type !== "string") return res.writeHead(400).end("body is not an event");
try {
const id = req.headers["svix-id"];
const { changes } = insert.run(id, event.type, body, event.created_at ?? null, Date.now());
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify({ received: true, duplicate: changes === 0 }));
} catch (error) {
console.error("could not store event:", error);
res.writeHead(500).end(); // not stored, so let the sender retry
}
});
}).listen(Number(process.env.PORT ?? 3000));
// The worker drains the table outside the request, so slow work never delays the 2xx.
// A failure waits 30 s, 1, 2, then 4 min; after MAX_ATTEMPTS the row stays for you to inspect.
const pending = db.prepare(
"SELECT id, type, payload, attempts FROM webhook_events WHERE processed_at IS NULL AND attempts < ? AND next_attempt_at <= ? ORDER BY received_at LIMIT 10",
);
const markDone = db.prepare("UPDATE webhook_events SET processed_at = ? WHERE id = ?");
const markFailed = db.prepare(
"UPDATE webhook_events SET attempts = attempts + 1, next_attempt_at = ? WHERE id = ?",
);
async function work() {
try {
for (const row of pending.all(MAX_ATTEMPTS, Date.now())) {
try {
await handle(row.id, row.type, JSON.parse(row.payload));
markDone.run(Date.now(), row.id);
} catch (error) {
markFailed.run(Date.now() + 2 ** row.attempts * 30_000, row.id);
console.error(`event ${row.id} failed, attempt ${row.attempts + 1}: ${error.message}`);
}
}
} finally {
setTimeout(work, 1000); // the next pass starts only after this one has finished
}
}
work();// support.mjs: turns email.received into a ticket and one acknowledgement.
// receiver.mjs imports handle() from here, so this file loads first.
import { mkdir, writeFile } from "node:fs/promises";
import { basename } from "node:path";
import { DatabaseSync } from "node:sqlite";
const API = process.env.RASKET_API_URL ?? "https://api.rasket.com";
const KEY = process.env.RASKET_API_KEY ?? "";
const USER_AGENT = "acme-support/1.0"; // the API refuses a request without one (403)
// Refuse to start without both secrets: an empty signing key would accept forged deliveries.
if (!KEY || !process.env.RASKET_WEBHOOK_SECRET) {
throw new Error("Set RASKET_API_KEY and RASKET_WEBHOOK_SECRET");
}
const ACKS_PER_SENDER_PER_DAY = 1;
const ACKS_PER_DAY = 200;
const CUSTOMERS = new Set(["ronald.williams@example.com"]); // your user table goes here
const db = new DatabaseSync(process.env.WEBHOOK_DB ?? "webhooks.db");
db.exec(`CREATE TABLE IF NOT EXISTS tickets (
ticket_no INTEGER PRIMARY KEY, email_id TEXT NOT NULL UNIQUE, sender TEXT NOT NULL, subject TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS acks (email_id TEXT PRIMARY KEY, sent_to TEXT NOT NULL, day TEXT NOT NULL)`);
const openTicket = db.prepare(
"INSERT INTO tickets (email_id, sender, subject) VALUES (?, ?, ?) ON CONFLICT (email_id) DO NOTHING",
);
const ticketFor = db.prepare("SELECT ticket_no FROM tickets WHERE email_id = ?");
const acksToday = db.prepare(
"SELECT count(*) AS total, count(*) FILTER (WHERE sent_to = ?) AS mine FROM acks WHERE day = ?",
);
const recordAck = db.prepare(
"INSERT INTO acks (email_id, sent_to, day) VALUES (?, ?, ?) ON CONFLICT (email_id) DO NOTHING",
);
const releaseAck = db.prepare("DELETE FROM acks WHERE email_id = ?");
export async function handle(id, type, event) {
if (event.test || type !== "email.received") return;
const data = event.data;
// Route on the addresses the message was accepted for, never on the To header.
if (!data.received_for.some((address) => address.toLowerCase().startsWith("support@"))) return;
// The event has no bodies or headers; the message itself does.
const message = await api(`/emails/receiving/${data.email_id}`);
const header = (name) => {
const found = Object.entries(message.headers ?? {}).find(([key]) => key.toLowerCase() === name);
return Array.isArray(found?.[1]) ? found[1][0] : found?.[1];
};
// Automated mail (bounces, lists, auto-replies) gets no ticket and, per RFC 3834, no reply.
if (automated(data.from, header)) return;
// Keyed on email_id, so a retried or replayed event finds the ticket it opened before.
const sender = addressOf(data.from);
openTicket.run(data.email_id, sender, data.subject);
const ticketId = `T-${ticketFor.get(data.email_id).ticket_no}`;
await saveAttachments(data.email_id, ticketId);
// RFC 3834: an automatic reply goes to the envelope sender, not to From, and never to an
// empty one (<>).
const to = header("return-path")?.replace(/[<>\s]/g, "").toLowerCase();
if (!to) return;
// Acknowledge only people you already know, and cap how many you send.
if (!CUSTOMERS.has(to)) return;
if (!claimAck(data.email_id, to, new Date().toISOString().slice(0, 10))) return;
// Thread the reply: In-Reply-To names the original, References carries the chain (RFC 5322).
const inReplyTo = header("in-reply-to")?.trim();
const parent = header("references") ?? (inReplyTo && !/\s/.test(inReplyTo) ? inReplyTo : "");
const reply = api("/emails", {
method: "POST",
headers: { "idempotency-key": `ack-${data.email_id}` }, // a second try sends nothing new
body: JSON.stringify({
from: "Acme Support <support@acme.example>",
to: [to],
reply_to: data.received_for[0],
subject: /^re:/i.test(data.subject) ? data.subject : `Re: ${data.subject}`,
text: `Thanks, we have opened ticket ${ticketId} and will reply here.`,
headers: {
"Auto-Submitted": "auto-replied",
...(data.message_id
? { "In-Reply-To": data.message_id, References: `${parent} ${data.message_id}`.trim() }
: {}),
},
}),
});
// A failed send gives the claim back, so the worker's retry can try again.
await reply.catch((error) => {
releaseAck.run(data.email_id);
throw error;
});
}
// Check the caps and claim the acknowledgement in one transaction. A second worker, or a replay
// days after the idempotency key has expired, finds the claim and sends nothing.
function claimAck(emailId, to, day) {
db.exec("BEGIN IMMEDIATE");
try {
const sent = acksToday.get(to, day);
const claimed =
sent.total < ACKS_PER_DAY &&
sent.mine < ACKS_PER_SENDER_PER_DAY &&
recordAck.run(emailId, to, day).changes === 1;
db.exec("COMMIT");
return claimed;
} catch (error) {
db.exec("ROLLBACK");
throw error;
}
}
// The photo of the crushed box: a download_url works for 15 minutes, so fetch it now.
async function saveAttachments(emailId, ticketId) {
const { data: files } = await api(`/emails/receiving/${emailId}/attachments?limit=100`);
await mkdir(`attachments/${ticketId}`, { recursive: true });
for (const file of files) {
if (!file.download_url) continue; // a part we did not store has no link
const response = await fetch(file.download_url, { headers: { "user-agent": USER_AGENT } });
if (!response.ok) throw new Error(`attachment ${file.id}: ${response.status}`);
const name = `${file.id}-${basename(file.filename ?? "attachment")}`; // no ../ from a stranger
await writeFile(`attachments/${ticketId}/${name}`, Buffer.from(await response.arrayBuffer()));
}
}
function automated(from, header) {
return (
(header("auto-submitted") ?? "no").trim().split(/[\s;(]/)[0].toLowerCase() !== "no" ||
/^(bulk|list|junk)$/i.test(header("precedence") ?? "") ||
header("list-id") !== undefined ||
/mailer-daemon|postmaster|no-?reply/i.test(from)
);
}
function addressOf(from) {
return (/<([^>]+)>/.exec(from)?.[1] ?? from).trim().toLowerCase();
}
async function api(path, init = {}) {
const response = await fetch(`${API}${path}`, {
...init,
headers: {
authorization: `Bearer ${KEY}`,
"user-agent": USER_AGENT,
"content-type": "application/json",
...init.headers,
},
});
if (!response.ok) throw new Error(`${path}: ${response.status} ${await response.text()}`);
return response.json();
}Why it cannot open two tickets
A failed or slow delivery is retried for about sixteen hours, and you can replay one later, so the same message can reach handle() more than once. Three things make that harmless. The receiver stores a delivery once per svix-id. The ticket is keyed on email_id, which a replay keeps, so a second run finds the ticket the first opened. And the acknowledgement is claimed in a transaction before it is sent, so a replay finds the claim; its idempotency key, ack-<email_id>, covers a retry within 24 hours. After a throw, the receiver’s worker tries again, up to five attempts in all. If your tickets live in a helpdesk rather than this table, pass email_id as its external id.
Replying in the same thread
Mail clients group a reply with the original when the reply’s In-Reply-To holds the original’s Message-ID and its References holds the chain so far (RFC 5322 §3.6.4). The handler sets both through the headers field of POST /emails and keeps a single Re:. The reply is sent from a domain you verified for sending, and its reply_to is the address that received the message, so the customer’s answer comes back through the MX to your webhook.
Automatic replies, robots and backscatter
RFC 3834 asks an automatic responder to skip mail marked Auto-Submitted with anything but no, list mail and bounces, to answer the envelope sender rather than Reply-To or From, and to mark its own reply Auto-Submitted: auto-replied. The handler does all four, sends nothing when there is no Return-Path, and also skips Precedence: bulk, list and junk, which is common practice rather than the RFC.
That stops loops, not forgery. From and Return-Path are whatever the sender wrote, and spam is delivered to you, not dropped. An acknowledgement to every message therefore sends mail from your domain to addresses a spammer chose. That is backscatter, and it earns complaints and blocklist entries. So the handler acknowledges only addresses in your own user table, at most once per address per day and 200 times a day in all. Every other person still gets a ticket, and someone on your team answers it.
Routing on received_for, not on To
To is a header the sender writes. A message can reach you as a Bcc, through a mailing list, or from a forwarding rule, and in all three the To header names somebody else. received_for lists the addresses of yours the message was actually accepted for. A Bcc to support@ therefore shows support@ in received_for and nothing of yours in To.
For reply-to-comment features, put the record id in the address itself, for example c-8f2a@inbound.acme.example. With a catch-all mailbox marked, every address on the receiving host reaches you, so there is nothing to create per thread and routing becomes a lookup on the local part.
Attachments, storage and limits
- Attachments.
GET /emails/receiving/{email_id}/attachmentsreturns each one with adownload_urlthat works for 15 minutes and needs no API key, so fetch it when you need the file rather than storing the link. An attachment with acontent_idand aninlinedisposition is usually an image inside the HTML body, such as a signature logo, not a file the sender meant to attach. - Size. A message over 40 MB as sent is refused at SMTP, so the sender gets a bounce and there is no event or record. Attachments travel encoded, at about a third more than their size on disk, so that is roughly 30 MB of files.
- Dropped mail gets no bounce. Our servers accept a message before Rasket decides where it goes. So a message with no mailbox, one that fails the virus check, and one that arrives after your plan’s daily cap or storage is used up are all accepted at SMTP. Each is recorded without its content, with a
dropped_reasonofno_mailbox,virus,daily_caporstorage_quota. No event is sent and the sender sees a normal delivery. List received mail through the API and alert on anydropped_reason. The same list finds what your endpoint missed while it was down for longer than the retries last. - Retention. Received mail is kept until you delete it.
DELETE /emails/receiving/{email_id}removes it for good and frees the storage, so a product that only needs the parsed data should delete after processing.
The raw message is kept as well, and served as a file through a signed download link rather than in the JSON. The receiving guide has the plan numbers and the regions, and the receiving reference documents every route above.
Can an AI agent read the mail you receive?
Yes. Add https://api.rasket.com/mcp as a remote MCP server in Claude, Claude Code or another MCP client; it signs in with OAuth in a browser, never with an API key (connecting an agent has the steps). Two of its tools read received mail. Both need the emails:read scope, and both sit in the send toolset beside send_email.
| Tool | What it returns |
|---|---|
list_received_emails | Inbound mail, newest first, with any dropped_reason. No bodies. Searches the subject, the sender and the recipients. |
get_received_email | One message: sender, recipients, subject, attachments and the text part capped at 4 KB. Never the HTML or the raw headers. |
The consent screen approves a request whole, so a connection that loads those tools is asked for emails:send as well. For a model that reads mail and can never send it, call GET /emails/receiving from your own code and give the model only the text. Leaving out the HTML and headers removes hidden text a model would read as instructions, but the text is still a stranger’s. The AI email assistant post shows how to classify and draft from it, with a person sending the reply.
When a webhook is the wrong tool
If people are going to read and answer the mail, they want a mailbox, not an event stream: keep your existing provider on the root domain, or use a shared inbox. If you get a few messages a week, polling an existing mailbox over IMAP is simpler than running an endpoint. A webhook earns its place when software, not a person, reads first. If that is you, start with the inbound label and the receiving guide.
Frequently asked questions
Can I test inbound email parsing before changing any DNS?
Yes. Every team is given a managed receiving address on our own domain, shown on the receiving page while none of your domains receives mail. Give it a mailbox or a catch-all, subscribe your endpoint, and send it a message: the email.received event is the same one your own domain will produce later.
Is a webhook better than polling a mailbox over IMAP?
For software, usually. A webhook arrives seconds after the message, already parsed into fields, signed, and retried for hours if your endpoint does not answer, with no connection to keep open and no MIME to decode. IMAP polling is simpler when the volume is a few messages a week or when the mail lives at a provider you cannot change.
Does a receiving subdomain need its own SPF or DMARC record?
Not to receive: once the parent domain is verified, the subdomain needs only its MX record. Whether mail claiming to be from the subdomain is trusted is decided by the parent domain's DMARC record (RFC 9989): its sp tag, or p when there is no sp, covers subdomains that publish no DMARC record of their own. If it never sends, publish v=spf1 -all on it as well, so mail using it as the envelope sender fails SPF.
Can several services consume the same inbound mail?
Yes. Every endpoint subscribed to email.received gets every event, so either run one endpoint that dispatches on received_for, or subscribe one endpoint per service and have each ignore the addresses that are not its own. One dispatcher is easier to keep idempotent.
What does receiving mail cost?
Received messages count against your plan's daily cap on received mail and its inbound storage, not against your sending allowance, and every address that receives needs a mailbox, which counts toward your plan's mailboxes. The receiving guide lists the numbers per plan. Deleting messages you have processed frees the storage.
Sources
- RFC 5321: Simple Mail Transfer Protocol, sections 4.5.4.1 and 5.1 — IETF, read 2026-10-16
- RFC 3834: Recommendations for Automatic Responses to Electronic Mail — IETF, read 2026-10-17
- RFC 5322: Internet Message Format, section 3.6.4 (identification fields) — IETF, read 2026-10-16
- RFC 2045: Multipurpose Internet Mail Extensions (MIME) Part One — IETF, read 2026-10-16
- Rasket docs: Receiving (guide) — Rasket, read 2026-10-17
- Rasket docs: Receiving API reference — Rasket, read 2026-10-17
- Rasket docs: Webhooks API reference — Rasket, read 2026-10-17
Related
- Receiving — Inbound mail, and the Inbox: a webhook fires, you read it, you answer it.
- Inbound email / receiving — Receiving is the other half of an email API. Mail addressed to your domain arrives, is parsed into headers, text, HTML and attachments, and is handed to your…
- Webhooks — Payloads, signature verification, retries and replay.
- Email webhooks explained: events and signatures — Email webhooks explained: the delivery events, the payload, HMAC signature verification, retries, and complete tested receivers in Node and Python.
- MX record — An MX record is the DNS record naming the servers that accept mail for a domain, each with a priority number, where lower numbers are tried first.
- Webhooks — Every delivery, bounce, complaint, open and click posted to your endpoint, signed with a timestamp, retried on failure and replayable from the dashboard.
- Build an AI email assistant with MCP, safely — How to build an AI email assistant that reads inbound mail as untrusted data, writes drafts a person approves, and resists email prompt injection.