To receive emails in Next.js, you need three things: an address that accepts mail, a route handler Resend can POST to, and one API call to fetch the body. This post builds all three in the App Router with TypeScript.
One detail will break your first attempt if you've used another inbound provider. Resend's email.received webhook does not include the email body, the headers, or the attachment contents. It sends metadata only. That is a deliberate design choice, and working around it is one extra call.
Copy link to headingWhat you need before you start
A Resend account and an API key. Resend gives you 3,000 emails a month on the free tier.
A Next.js app using the App Router, with the SDK installed:
npm install resend.A receiving address. Either the
.resend.appaddress Resend creates for your team, or your own domain with an MX record.A public URL for your endpoint. Resend delivers webhooks over HTTPS, so localhost needs a tunnel. The "Test it locally" section covers three ways to get one.
The product side was covered when Resend launched Inbound. The reference material lives in the Receiving Emails documentation.
Copy link to headingSet up a receiving address
Resend processes mail for your receiving domain, parses it, stores it, and POSTs an email.received event to an endpoint you register. Start with a domain Resend accepts mail for.
Copy link to headingUsing the .resend.app address
Every team gets an address on a <id>.resend.app domain with no DNS work. To find it:
Go to the Receiving tab on the emails page.
Click the three dots button.
Select "Receiving address."
Any address at that domain reaches you. If your domain is cool-hedgehog.resend.app, mail to anything@cool-hedgehog.resend.app gets processed.
Copy link to headingUsing your own domain with an MX record
For a branded address, verify your domain, enable receiving on the domain details page, then add the MX record Resend shows you.
Use a subdomain if the root domain already has MX records. Mail is delivered to the MX record with the lowest priority value, so adding Resend's record next to your existing ones either does nothing or takes over your real inbox. If both records share a priority, delivery is unpredictable and may hit either service.
A subdomain like inbound.yourdomain.tld keeps the two apart. Everything below works the same either way.
Then register the webhook: go to Webhooks, click "Add Webhook", enter your endpoint URL, select the email.received event, and click "Add".
Copy link to headingHandle the email.received webhook in a route handler
Create app/api/inbound/route.ts. Resend sends a POST with a JSON body that looks like this:
{ "type": "email.received", "created_at": "2026-02-22T23:41:12.126Z", "data": { "email_id": "56761188-7520-42d8-8898-ff6fc54ce618", "created_at": "2026-02-22T23:41:11.894719+00:00", "from": "onboarding@resend.dev", "to": ["delivered@resend.dev"], "bcc": [], "cc": [], "received_for": ["forwarded@example.com"], "message_id": "<111-222-333@email.example.com>", "subject": "Sending this example", "attachments": [ { "id": "2a0c9ce0-3112-4728-976e-47ddcd16a318", "filename": "avatar.png", "content_type": "image/png", "content_disposition": "inline", "content_id": "img001" } ] }}Here is a handler that types that payload and routes on the to address:
import { NextResponse } from 'next/server';
type ReceivedAttachment = { id: string; filename: string; content_type: string; content_disposition: string | null; content_id: string | null;};
type EmailReceivedEvent = { type: 'email.received'; created_at: string; data: { email_id: string; created_at: string; from: string; to: string[]; cc: string[]; bcc: string[]; received_for: string[]; message_id: string; subject: string; attachments: ReceivedAttachment[]; };};
export async function POST(request: Request) { const event = (await request.json()) as EmailReceivedEvent;
if (event.type !== 'email.received') { return NextResponse.json({ ok: true }); }
const { email_id, from, to, subject, attachments } = event.data;
// Every address at your domain arrives here. Route on `to`. if (to.some((address) => address.startsWith('support@'))) { console.log(`Support email ${email_id} from ${from}: ${subject}`); console.log(`${attachments.length} attachment(s)`); }
return NextResponse.json({ ok: true });}Return a 2xx quickly. Resend retries failed deliveries, so a handler that blocks on slow work gets called again. Queue the work and respond.
Copy link to headingWhy the webhook doesn't include the email body
Because the payload carries metadata only. The docs state it directly: "Webhooks do not include the email body, headers, or attachments, only their metadata."
The reason is size. Inbound mail carries attachments, and serverless platforms cap how large a request body they accept. A message with a large PDF on it would produce a webhook POST over that cap, and the platform rejects the request before your code runs. Resend stores the content instead and hands you an email_id to fetch it with.
So the payload gives you from, to, cc, bcc, subject, message_id, received_for, and attachment metadata. It has no html, no text, no headers, and no attachment bytes.
Coming from a provider that posts the full parsed body inline, this is the step that catches you out. Your first handler reads event.data.html and logs undefined. One call fixes it:
const { data: email } = await resend.emails.receiving.get(event.data.email_id);Copy link to headingGet the email content
resend.emails.receiving.get() returns the body and the headers. Here it is in the full handler, with the types from the block above:
import { NextResponse } from 'next/server';import { Resend } from 'resend';
const resend = new Resend(process.env.RESEND_API_KEY);
export async function POST(request: Request) { const event = (await request.json()) as EmailReceivedEvent;
if (event.type !== 'email.received') { return NextResponse.json({ ok: true }); }
const { data: email, error } = await resend.emails.receiving.get( event.data.email_id, );
if (error || !email) { console.error('Could not fetch email', error); return NextResponse.json({ ok: false }, { status: 500 }); }
console.log(email.subject); console.log(email.text); // null when the message has no plain text part console.log(email.html); console.log(email.headers);
return NextResponse.json({ ok: true });}Two things about html. Inline images arrive as base64 data: URIs by default. Pass html_format=cid and you get the original <img src="cid:..." /> references instead, where each cid matches the content_id of an attachment. Use that if you plan to store the images yourself.
The response also carries raw.download_url, a signed URL for the original message file including attachments, with an expires_at timestamp next to it. Reach for that when you need the full MIME source. Field-by-field detail is in the Received emails API.
Copy link to headingDownload attachments
The webhook gives you attachment IDs and filenames. For the bytes, list the attachments and use the download_url on each one:
// Inside your POST handler, after checking event.type.const { data: list, error } = await resend.emails.receiving.attachments.list({ emailId: event.data.email_id,});
if (error || !list) { console.error('Could not list attachments', error); return NextResponse.json({ ok: false }, { status: 500 });}
for (const attachment of list.data) { console.log(attachment.filename, attachment.content_type, attachment.size);
const response = await fetch(attachment.download_url); const bytes = Buffer.from(await response.arrayBuffer());
// Upload `bytes` to your own storage here.}Each entry adds size, a signed download_url, and an expires_at timestamp to the metadata you already had. Those URLs expire, so download the file during the request or copy it to your own storage. Saving the URL in your database can leave you with a dead link later. See the Attachments API for pagination.
Copy link to headingReply in the same thread
Email clients thread on message IDs. To land your reply in the same thread, set the In-Reply-To header to the message_id from the webhook and prefix the subject with Re::
// Inside your POST handler, after checking event.type.const { data, error } = await resend.emails.send({ from: 'Support <support@yourdomain.tld>', to: [event.data.from], subject: `Re: ${event.data.subject}`, html: '<p>Thanks, we got your message.</p>', headers: { 'In-Reply-To': event.data.message_id, },});The from address has to be on a domain you have verified for sending. If receiving and sending run on different domains, use the sending one here. The sending side has its own walkthrough if you need it: send emails with Next.js.
Replying more than once in a thread needs the References header as well: every previous message_id, space separated, in order.
headers: { 'In-Reply-To': event.data.message_id, References: [...previousMessageIds, event.data.message_id].join(' '),},The webhook hands you one message ID at a time, so store the ones you have seen per thread as you go.
Copy link to headingVerify the webhook signature
Your endpoint is a public URL that accepts JSON, so anyone can POST to it. Resend signs every webhook with Svix headers, and resend.webhooks.verify() checks them.
Verification runs against the raw request body. Read it with request.text(), not request.json(). Re-serializing parsed JSON changes bytes and breaks the signature.
import { NextResponse } from 'next/server';import { Resend } from 'resend';
const resend = new Resend(process.env.RESEND_API_KEY);
export async function POST(request: Request) { const payload = await request.text();
let event: EmailReceivedEvent;
try { event = resend.webhooks.verify({ payload, headers: { id: request.headers.get('svix-id') ?? '', timestamp: request.headers.get('svix-timestamp') ?? '', signature: request.headers.get('svix-signature') ?? '', }, webhookSecret: process.env.RESEND_WEBHOOK_SECRET ?? '', }) as EmailReceivedEvent; } catch { return new NextResponse('Invalid webhook', { status: 400 }); }
if (event.type !== 'email.received') { return NextResponse.json({ ok: true }); }
// Handle the verified event. return NextResponse.json({ ok: true });}verify() throws on a bad signature and returns the parsed payload otherwise. The signing secret is on the webhook details page in the dashboard. Copy it into RESEND_WEBHOOK_SECRET.
If you would rather not use the SDK, the svix package does the same job. Both approaches are in the docs on how to verify webhook requests.
Copy link to headingTest it locally
Resend needs a public HTTPS URL, and localhost:3000 is not one. Three ways around that.
A tunnel. ngrok or VS Code port forwarding put your dev server on a public URL. Register that URL plus your route path as the webhook endpoint, for example https://example123.ngrok.io/api/inbound. With port forwarding, set the port visibility to public, or Resend gets an auth page instead of your handler.
The CLI, forwarding to your machine. The Resend CLI registers a temporary webhook, streams events, and cleans up when you quit:
resend webhooks listen \ --url <https://your-public-url> \ --events email.received \ --forward-to <http://localhost:3000/api/inbound>--forward-to passes the original Svix headers through, so signature verification behaves the way it will in production.
The CLI, watching only. To confirm mail is arriving without involving your handler:
resend emails receiving listenThat polls for new inbound emails and prints them as they land. It separates "is my DNS right" from "is my code right".
Then send a real email to your receiving address and watch it hit the route.
Copy link to headingFAQ
Will I receive email for any address at my domain?
Yes. Once the MX record is in place, every address at that domain routes to your webhook, including ones you never created. There is no per-address configuration. Filter on the to field in your handler and ignore the rest.
What happens if my endpoint is down?
You keep the mail. Resend stores inbound email on arrival, before your webhook is called, so messages stay visible in the dashboard and retrievable through the Receiving API. Failed webhook deliveries are retried on the schedule in the webhooks documentation, and you can replay individual events from the webhooks page in the dashboard.
Root domain or subdomain?
Subdomain, if the root already has MX records for a real inbox. Mail goes to the lowest priority MX record, so two sets of records on one domain means one of them loses. A subdomain like inbound.yourdomain.tld avoids the conflict. Use the root domain when nothing else receives mail there.
Copy link to headingWhat to build next
The shape is the same for most inbound features: match on the to address, fetch the content, do the work, reply in thread. A support inbox that opens tickets. A receipts@ address that parses PDFs. A reply-by-email comment system. Each one is this handler with different code in the middle.
Start with the Receiving Emails documentation for the parts this post skipped, including forwarding.