This guide shows how to receive email with Node.js. Instead of running a mail server, you point your domain’s MX record at api.email; every message is parsed and delivered to your Express app as a signed JSON webhook. You will set up DNS and a route, verify signatures, read the stripped reply text, thread replies to the emails you sent and download attachments.
How inbound email works
- Someone sends a message to
[email protected]. - Their mail server looks up the MX record for
in.acme.comand delivers the message tomx.api.email. - api.email checks SPF, DKIM and DMARC, parses the MIME structure, strips the quoted thread from replies and stores attachments.
- Your route matches the recipient and api.email sends an
email.receivedevent to your webhook URL, signed with your secret. - Your app answers with a 2xx status. If it does not, delivery is retried with backoff for up to 24 hours.
Add the MX record
Choose a domain or subdomain that receives nothing else. A dedicated subdomain such as in.acme.com is the safe default, because your team’s mailboxes on acme.com keep working. Then add one record at your DNS provider:
in.acme.com. MX 10 mx.api.email.
Keep the TTL low, for example 300 seconds, while you set things up, so a typo is quick to correct. DNS changes usually show up within minutes. The dashboard shows when the record resolves; the receiving documentation covers the details.
Create a route
A route connects addresses to a webhook URL. match takes a single address or a catch-all such as *@in.acme.com.
curl https://api.email/v1/routes \
-H "Authorization: Bearer $API_EMAIL_KEY" \
-H "Content-Type: application/json" \
-d '{"match": "*@in.acme.com", "forward": "https://acme.com/hooks/inbound"}'
The response contains the route id and its signing secret, for example {"id": "rt_3k9d", "secret": "whsec_…"}. Store the secret as API_EMAIL_ROUTE_SECRET; every delivery from this route is signed with it. Catch-all routes are useful for reply addresses that carry an id, as shown below.
Handle the webhook in Express
The signature is computed over the exact request bytes, so read the raw body first and parse the JSON only after verification. express.raw gives you a Buffer; register it on this route before any global express.json().
import crypto from 'node:crypto';
import express from 'express';
// Same helper as in the webhooks docs: rejects replays, forged and malformed signatures,
// and accepts both signatures during a secret rotation.
function verify(rawBody, header, secret, toleranceSeconds = 300) {
const parts = String(header ?? '').split(',').map((p) => p.trim().split('='));
const t = parts.find(([k]) => k === 't')?.[1];
const sigs = parts.filter(([k, v]) => k === 'v1' && /^[0-9a-f]{64}$/.test(v ?? '')).map(([, v]) => v);
if (!/^\d+$/.test(t ?? '') || sigs.length === 0) return false;
if (Math.abs(Date.now() / 1000 - Number(t)) > toleranceSeconds) return false;
const expected = crypto.createHmac('sha256', secret).update(`${t}.`).update(rawBody).digest();
return sigs.some((s) => crypto.timingSafeEqual(Buffer.from(s, 'hex'), expected));
}
const app = express();
app.post('/hooks/inbound', express.raw({ type: 'application/json', limit: '12mb' }), async (req, res) => {
if (!verify(req.body, req.get('api-email-signature'), process.env.API_EMAIL_ROUTE_SECRET)) {
return res.sendStatus(400);
}
try {
await queue.add('inbound-email', JSON.parse(req.body)); // hand off, then acknowledge
res.sendStatus(200);
} catch (err) {
console.error('could not enqueue inbound email', err);
res.sendStatus(503); // api.email retries, so nothing is lost
}
});
app.listen(3000);
Acknowledge only after the message is safely in your queue: if enqueueing fails, answering with an error makes api.email retry the delivery instead of dropping it. The timestamp check in verify() stops anyone from replaying a captured request later, and the body limit leaves room for the largest messages api.email accepts.
The email.received payload
{
"type": "email.received",
"id": "in_5d1e7a",
"from": "[email protected]",
"to": "[email protected]",
"subject": "Re: Your ticket #482",
"text": "Thanks, that worked!\n\nOn Mon, Acme wrote:\n> Try clearing the cache…",
"html": "<p>Thanks, that worked!</p>…",
"reply_text": "Thanks, that worked!",
"in_reply_to": "em_8f2c1a",
"headers": { "message-id": "<…@example.com>" },
"attachments": [
{ "filename": "screenshot.png", "content_type": "image/png", "size": 48213, "url": "https://api.email/v1/inbound/in_5d1e7a/attachments/0" }
],
"auth": { "spf": "pass", "dkim": "pass", "dmarc": "pass" },
"received_at": "2026-10-25T09:14:03Z"
}
text and html are the full bodies. reply_text is the new part of a reply with the quoted thread and the signature removed, which is usually what you want to store. auth tells you whether the sender’s domain authenticated the message; treat a failing DMARC result with suspicion before acting on the content.
Replies and threading
When you send an email through api.email and the recipient replies, in_reply_to contains the id of your original email, such as em_8f2c1a. Store that id when you send, and threading becomes a lookup instead of guesswork with subject lines.
You can also encode your own id in the reply address with a catch-all route. Never use a bare sequential id such as ticket-482@…: anyone could guess it and post into someone else’s ticket. Sign it instead, and check that the sender is plausible before you accept the message.
const TOKEN_SECRET = process.env.REPLY_TOKEN_SECRET;
const sign = (id) => crypto.createHmac('sha256', TOKEN_SECRET).update(String(id)).digest('hex').slice(0, 16);
// Use this as the From or Reply-To address when you email the customer about a ticket.
export const replyAddress = (ticketId) => `ticket-${ticketId}.${sign(ticketId)}@in.acme.com`;
function ticketIdFrom(to) {
const m = /^ticket-(\d+)\.([0-9a-f]{16})@in\.acme\.com$/i.exec(to);
return m && crypto.timingSafeEqual(Buffer.from(m[2].toLowerCase()), Buffer.from(sign(m[1]))) ? m[1] : null;
}
async function handleInbound(event) {
if (await inbound.seen(event.id)) return; // retries and replays are safe
const ticket = (await tickets.findBySentEmail(event.in_reply_to)) ?? (await tickets.find(ticketIdFrom(event.to)));
if (!ticket) return;
const trusted = event.auth.dmarc === 'pass' || event.from === ticket.customerEmail;
await ticket.addReply({ from: event.from, body: event.reply_text, inboundId: event.id, needsReview: !trusted });
}
Attachments
Attachments are not embedded in the webhook body. Each one has a url that you download with your API key, which keeps webhooks small and fast even for large files.
import path from 'node:path';
for (const file of event.attachments) {
const res = await fetch(file.url, { headers: { Authorization: `Bearer ${process.env.API_EMAIL_KEY}` } });
if (!res.ok) throw new Error(`attachment download failed: ${res.status}`); // the job retries
// The filename comes from the sender: never use it as a path as-is.
const safeName = path.basename(file.filename).replace(/[^\w.-]/g, '_').slice(0, 100);
await storage.put(`${event.id}/${crypto.randomUUID()}-${safeName}`, Buffer.from(await res.arrayBuffer()));
}
Check content_type and size before you process a file, and never execute or render untrusted attachments without sanitising them.
Make processing idempotent
Retries mean your handler can see the same event more than once, for example if your app processed it but crashed before answering. Use the event id as a unique key: record it when you start processing and skip events you have already seen. That one check makes retries and manual replays safe.
Ignore auto-replies and avoid loops
Inbound addresses attract automatic mail: out-of-office replies, delivery reports and other systems’ notifications. If your app answers every message, two automated systems can end up replying to each other forever. Check the headers before acting: skip messages with auto-submitted set to anything other than no, messages with precedence: bulk or auto_reply, and messages from your own sending domain. It also helps to look at auth: if DMARC fails for a domain that publishes a strict policy, the sender is probably not who they claim to be, so file the message for review instead of acting on it.
Reply to the sender
Answering an inbound message is just sending an email. Use POST /v1/emails with the original sender as to, keep the subject with a Re: prefix and set the In-Reply-To and References headers to the original message-id, so mail clients show your answer in the same conversation. Send it from an address that routes back to your app, such as [email protected], and the customer’s next reply arrives at your webhook with the ticket id already in the to field.
Test locally
Webhooks need a public URL. During development, expose your local port with a tunnel such as cloudflared tunnel --url http://localhost:3000, create a route that forwards to the tunnel URL and send a message from your own mail client. The dashboard shows every delivery attempt with its response code, and you can replay one while you fix your handler.