Back to blog
Next.jsInbound EmailWebhooksTutorialTypeScript

How to Receive Emails in Next.js (Inbound Parsing)

Receive incoming emails in your Next.js app using webhooks and inbound email parsing. Complete guide with App Router, signature verification, and TypeScript examples.

SendPigeon TeamApril 9, 20264 min read

To receive emails in Next.js, set up an inbound parsing service that converts incoming emails to webhook POST requests, then handle them in an API route. No mail server needed.

TL;DR

How it works:

  1. Add an MX record pointing to inbound.sendpigeon.dev
  2. Create an API route to handle webhook POSTs
  3. Verify the signature, process the email

Works on: Vercel, AWS, self-hosted — any Next.js deployment.


Prerequisites

  • A domain you control (for MX records)
  • A SendPigeon account with a paid plan
  • A deployed Next.js app (or a tunnel like ngrok for local dev)

Step 1: Add the MX Record

Point your domain (or a subdomain) to SendPigeon's inbound server:

TypePriorityValue
MX10inbound.sendpigeon.dev

Use a subdomain like inbound.yourdomain.com so your team's regular email stays unaffected.


Step 2: Configure the Webhook

In the SendPigeon dashboard, go to your domain's inbound settings and set the webhook URL to your Next.js API route:

https://yourapp.com/api/inbound

Save the webhook secret — you'll need it for verification.


Step 3: Create the Webhook Handler

App Router (Route Handler)

// app/api/inbound/route.ts
import { createHmac } from "crypto";
import { NextResponse } from "next/server";

const WEBHOOK_SECRET = process.env.SENDPIGEON_WEBHOOK_SECRET!;

function verifySignature(payload: string, signature: string, timestamp: string): boolean {
  const expected = createHmac("sha256", WEBHOOK_SECRET)
    .update(`${timestamp}.${payload}`)
    .digest("hex");
  return expected === signature;
}

export async function POST(request: Request) {
  const signature = request.headers.get("x-webhook-signature");
  const timestamp = request.headers.get("x-webhook-timestamp");
  const body = await request.text();

  if (!signature || !timestamp || !verifySignature(body, signature, timestamp)) {
    return NextResponse.json({ error: "Invalid signature" }, { status: 401 });
  }

  const { event, data } = JSON.parse(body);

  if (event === "email.received") {
    const { id, from, to, subject, text, html } = data;

    // Your logic here — create ticket, notify team, trigger workflow
    console.log(`Inbound email from ${from}: ${subject}`);

    // Example: save to database
    // await db.inboundEmail.create({ data: { from, to, subject, body: text } });
  }

  return NextResponse.json({ received: true });
}

Always verify the webhook signature before processing. Without it, anyone who discovers your webhook URL can inject fake emails.


Step 4: Test Locally with ngrok

During development, use ngrok to expose your local Next.js server:

ngrok http 3000

Set the ngrok URL as your webhook in the SendPigeon dashboard:

https://abc123.ngrok-free.app/api/inbound

Or use SendPigeon's built-in webhook tester in the dashboard to send a sample payload.


Handling Different Email Types

Route emails based on the recipient address:

export async function POST(request: Request) {
  // ... signature verification ...

  const { data } = JSON.parse(body);
  const { to, from, subject, text, html, id } = data;

  if (to.startsWith("support@")) {
    await createSupportTicket({ from, subject, body: text || html });
  } else if (to.startsWith("billing@")) {
    await forwardToBilling({ from, subject, body: text || html });
  } else if (to.match(/^reply\+(.+)@/)) {
    const token = to.match(/^reply\+(.+)@/)![1];
    await addReplyToThread(token, { from, body: text || html });
  }

  return NextResponse.json({ received: true });
}

Replying to Inbound Emails

Use the SendPigeon API to reply with proper threading:

async function replyToEmail(inboundId: string, html: string) {
  const res = await fetch(
    `https://api.sendpigeon.dev/v1/inbound/emails/${inboundId}/reply`,
    {
      method: "POST",
      headers: {
        Authorization: `Bearer ${process.env.SENDPIGEON_API_KEY}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ html }),
    }
  );
  return res.json();
}

The reply lands in the sender's inbox in the original email thread — In-Reply-To and References headers are handled for you.


Processing Attachments

The webhook payload includes a rawUrl — a presigned URL to the original .eml file. Use it to access attachments:

async function getAttachments(rawUrl: string) {
  const res = await fetch(rawUrl);
  const rawEmail = await res.text();
  // Parse with a library like mailparser
  // const { attachments } = await simpleParser(rawEmail);
  return rawEmail;
}

Production Checklist

  • Webhook URL is HTTPS (required)
  • Signature verification is in place
  • Handler returns 200 quickly (queue heavy work)
  • MX record is set on your domain/subdomain
  • Error handling doesn't leak internal details
  • Test webhook works from SendPigeon dashboard

SendPigeon retries failed webhook deliveries 3 times with exponential backoff. Always return 200 to acknowledge receipt, even if you process asynchronously.


Next Steps