Receive email with Node.js

Receive email with Node.js: point an MX record at api.email, create a route and handle signed JSON webhooks in Express, with replies, threading and attachments.

Updated 2026-10-25

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

  1. Someone sends a message to [email protected].
  2. Their mail server looks up the MX record for in.acme.com and delivers the message to mx.api.email.
  3. api.email checks SPF, DKIM and DMARC, parses the MIME structure, strips the quoted thread from replies and stores attachments.
  4. Your route matches the recipient and api.email sends an email.received event to your webhook URL, signed with your secret.
  5. 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.

FAQ

Do I need to run a mail server to receive email with Node.js?

No. api.email receives the message on its own mail servers, parses it and sends your Node.js app an HTTPS POST with JSON. Your app only needs a public webhook URL.

Can I receive email on my main domain?

Only if nothing else receives mail there. If your team's mailboxes live on acme.com, use a subdomain such as in.acme.com for inbound processing so you don't redirect your own mail.

What happens if my webhook is down?

Deliveries that fail or time out are retried with exponential backoff for up to 24 hours, and you can replay any delivery from the dashboard, so a deploy or short outage loses no email.

How large can incoming attachments be?

Messages up to 10 MB are accepted. Attachments are not embedded in the webhook; each one has a URL you download with your API key, which keeps webhook bodies small.

Get API key

Create your api.email account

What do you need?

Early access: accounts open in batches. By continuing you agree to the Terms and Privacy Policy.