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 heading](#what-you-need-before-you-start)What you need before you start

- A Resend account and an API key. Resend gives you [3,000 emails a month on the free tier](https://resend.com/pricing).

- A Next.js app using the App Router, with the SDK installed: `npm install resend`.

- A receiving address. Either the `.resend.app` address 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](https://resend.com/blog/inbound-emails). The reference material lives in the [Receiving Emails documentation](https://resend.com/docs/dashboard/receiving/introduction).

## [Copy link to heading](#set-up-a-receiving-address)Set 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 heading](#using-the-.resend.app-address)Using the .resend.app address

Every team gets an address on a `<id>.resend.app` domain with no DNS work. To find it:

1.  Go to the [Receiving tab](https://resend.com/emails/receiving) on the emails page.

2.  Click the three dots button.

3.  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 heading](#using-your-own-domain-with-an-mx-record)Using 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](https://resend.com/webhooks), click "Add Webhook", enter your endpoint URL, select the `email.received` event, and click "Add".

## [Copy link to heading](#handle-the-email.received-webhook-in-a-route-handler)Handle 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:

app/api/inbound/route.ts

```
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 heading](#why-the-webhook-doesn't-include-the-email-body)Why 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 heading](#get-the-email-content)Get 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:

app/api/inbound/route.ts

```
import { NextResponse } from 'next/server';
import { Resend } from 'resend';

const resend = new Resend(process.env.RESEND_API_KEY);

  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);

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](https://resend.com/docs/api-reference/emails/retrieve-received-email).

## [Copy link to heading](#download-attachments)Download 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](https://resend.com/docs/api-reference/emails/list-received-email-attachments) for pagination.

## [Copy link to heading](#reply-in-the-same-thread)Reply 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](https://resend.com/docs/send-with-nextjs).

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 heading](#verify-the-webhook-signature)Verify 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.

app/api/inbound/route.ts

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 });
  }

  // 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](https://resend.com/docs/webhooks/verify-webhooks-requests).

## [Copy link to heading](#test-it-locally)Test it locally

Resend needs a public HTTPS URL, and `localhost:3000` is not one. Three ways around that.

**A tunnel.** [ngrok](https://ngrok.com/download) or [VS Code port forwarding](https://code.visualstudio.com/docs/debugtest/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](https://resend.com/docs/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 listen
```

That 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 heading](#faq)FAQ

**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 heading](#what-to-build-next)What 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](https://resend.com/docs/dashboard/receiving/introduction) for the parts this post skipped, including forwarding.