BLOGS

Inbound Email Notification Webhooks (Next.js, Vercel)

March 14, 2026

Build a serverless email processing pipeline in Next.js on Vercel. Receive inbound emails as webhooks, parse content and attachments, and handle Vercel-specific limits.

Inbound Email Notification Webhooks (Next.js, Vercel)

Motivation

You’re building a SaaS app on Next.js and Vercel. A customer replies to a notification email, a support ticket comes in, or an AI agent needs to read incoming messages. You need that email turned into structured data your app can act on.

The traditional route of spinning up an SMTP server, configuring MX records, parsing raw MIME doesn’t fit the modern development workflow and serverless architecture.

An elegant solution is an Email to JSON/Webhook service: emails arrive, get parsed, and hit your Next.js route handler as a clean JSON webhook.

This guide covers two approaches: using Pingram’s managed inbound webhooks (minutes to set up) and building your own pipeline (full control, more work). Both deploy to Vercel with zero infrastructure to manage.

Quick Comparison

AspectPingramDIY (SES + S3 + Next.js)
Setup TimeMinutesDays
MX RecordsOptionalRequired
Email ParsingManagedYou build it
AttachmentsIncluded in payloadManual extraction
Reply ThreadingAutomaticManual implementation
CostGenerous free tierSNS, S3, SES costs
Other ChannelsEmail + SMS + VoiceEmail only

Option 1: Pingram (Ship in Minutes)

  1. Create a Pingram account and add your domain
  2. Go to Settings → Webhooks, enable the EMAIL_INBOUND event, and set the URL to https://your-app.vercel.app/api/inbound-email
  3. Create app/api/inbound-email/route.ts to receive the webhook:
import { NextResponse } from 'next/server';

export async function POST(request: Request) {
  const payload = await request.json();

  if (payload.eventType === 'EMAIL_INBOUND') {
    console.log(payload.from);
    console.log(payload.subject);
    console.log(payload.bodyText);
    console.log(payload.attachments);
  }

  return NextResponse.json({ received: true });
}
  1. Pingram gives you a test inbox that you can send emails to, to test your webhook immediately. Optionally, set up your own inbound address from Dashboard -> Inbound.

As you can see, using Pingram you can receive inbound emails in under 30 lines of code.


Option 2: Build Your Own Pipeline

If you need full control over the email reception layer, you can wire AWS SES to your Next.js API route.

Architecture

Incoming Email → MX Records → AWS SES → S3 Bucket → SNS Topic → Next.js API Route

1. Configure AWS SES for Inbound

Verify your domain in SES and point your MX records to the SES inbound endpoint:

MX 10 inbound-smtp.us-east-1.amazonaws.com

Create an SES receipt rule that stores incoming emails in an S3 bucket and publishes a notification to an SNS topic.

2. Create an SNS-to-Webhook Bridge

Set up an SNS HTTP/S subscription pointing to your Vercel endpoint. SNS will POST a notification whenever a new email lands in S3.

3. Parse the Email in Your Route Handler

import { NextResponse } from 'next/server';
import { S3Client, GetObjectCommand } from '@aws-sdk/client-s3';
import { simpleParser, type ParsedMail } from 'mailparser';

const s3 = new S3Client({ region: 'us-east-1' });

export async function POST(request: Request) {
  const snsMessage = await request.json();

  // SNS subscription confirmation
  if (snsMessage.Type === 'SubscriptionConfirmation') {
    await fetch(snsMessage.SubscribeURL);
    return NextResponse.json({ confirmed: true });
  }

  const notification = JSON.parse(snsMessage.Message);
  const bucket = notification.receipt.action.bucketName;
  const key = notification.receipt.action.objectKey;

  // Fetch raw email from S3
  const { Body } = await s3.send(
    new GetObjectCommand({ Bucket: bucket, Key: key })
  );
  const rawEmail = await Body!.transformToString();

  // Parse MIME content
  const parsed: ParsedMail = await simpleParser(rawEmail);

  console.log(`From: ${parsed.from?.text}`);
  console.log(`Subject: ${parsed.subject}`);
  console.log(`Body: ${parsed.text}`);
  console.log(`Attachments: ${parsed.attachments.length}`);

  // Process attachments
  for (const attachment of parsed.attachments) {
    // attachment.content is a Buffer
    // attachment.filename, attachment.contentType, attachment.size
  }

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

4. Install Dependencies

npm install @aws-sdk/client-s3 mailparser
npm install -D @types/mailparser

Trade-offs of DIY:


Deployment Considerations & Gotchas

There are platform-specific constraints on Vercel to plan for:

Request Body Size: 4.5 MB Limit

Vercel serverless functions cap request bodies at 4.5 MB. Most text-only emails are well under this, but emails with large attachments can exceed it. Strategies:

Function Timeout: Up to 300s (Hobby) / 800s (Pro)

Email processing should respond quickly: return 200 immediately, then process asynchronously if needed. For heavy processing (AI extraction, database writes, external API calls), consider the waitUntil vercel functionality that lets the function continue running after the response is sent, avoiding webhook timeout retries.

Idempotency

Email providers may retry webhook delivery. Use trackingId (Pingram) or messageId (DIY) to deduplicate.


Common Challenges

Email Threading

When a user replies to an email you sent, you need to match the reply to the original conversation. Pingram includes a trackingId that links to the original notification, making this trivial. With DIY, you’d parse In-Reply-To and References headers from the raw MIME content.

Character Encoding

Emails arrive in every encoding imaginable—UTF-8, ISO-8859-1, Windows-1252, base64, quoted-printable. Pingram normalizes everything to UTF-8 in the webhook payload. With DIY, mailparser handles most cases, but edge cases exist with non-standard Asian encodings and legacy clients.

Bounce and Spam Handling

Not every inbound email is legitimate. Expect bounce notifications, auto-replies, spam, and out-of-office messages hitting your webhook. Filter these early:

const autoReplyPatterns = [
  /^(auto|out.of.office|automatic.reply)/i,
  /^(noreply|no-reply|mailer-daemon)/i
];

function isAutoReply(payload: InboundEmailPayload): boolean {
  return autoReplyPatterns.some(
    (pattern) => pattern.test(payload.subject) || pattern.test(payload.from)
  );
}

TLDR

Both approaches work on Vercel. Watch the 4.5 MB body limit for attachments and use waitUntil for heavy processing.