Guide
How to Receive Inbound Email as JSON in Next.js
Next.js is great for building web apps, dashboards, internal tools, and SaaS products. But there is one thing it does not handle on its own: receiving email.
If you want users, vendors, devices, or internal systems to send email into your application, you usually need more than a normal API route. You need a way to receive the message, parse it, handle attachments, and send the result to your app in a format your code can work with.
That is where an inbound email webhook helps.
Instead of running mail servers or polling an inbox, you can give people an email address. When a message arrives, InboxBridge parses the email and sends it to your Next.js app as a normal HTTP POST request with structured JSON.
In this guide, we will build a simple inbound email endpoint in Next.js that can receive incoming email as JSON.
What we are building
We are going to create a Next.js route handler that receives an inbound email webhook. The flow looks like this:
- Someone sends an email to your InboxBridge address
- InboxBridge receives and parses the email
- InboxBridge sends a JSON webhook to your Next.js app
- Your app handles the email like any other API request
For example, your app could use the incoming email to:
- Create a support ticket
- Add a reply to an existing conversation
- Trigger an AI workflow
- Send a Slack notification
- Store an attachment
- Create a lead from a contact form fallback
- Process alerts from devices, vendors, or legacy systems
The nice part is that your application does not need to speak SMTP. It just needs to handle JSON.
Why receiving email is different from receiving an API request
Most web apps are built around HTTP. A user clicks a button, submits a form, or sends an API request. Your server receives that request and responds.
Email works differently.
Email is delivered through mail servers using protocols like SMTP. A raw email message can include headers, plain text, HTML, replies, forwards, attachments, inline images, spam metadata, and more.
If you try to build this yourself, you may need to deal with MX records, SMTP servers, MIME parsing, attachments, email headers, spam filtering, retry behavior, bounced messages, inbound routing, local development testing, and logging for failed deliveries.
For some products, building that infrastructure makes sense. For many SaaS apps, internal tools, side projects, and automation workflows, it is more work than the feature is worth.
A webhook keeps it simple. Email comes in. JSON goes out.
Create a Next.js route handler
In a Next.js app using the App Router, create this file:
// app/api/inbound-email/route.ts
import { NextResponse } from "next/server";
export async function POST(request: Request) {
const email = await request.json();
console.log("New inbound email received");
console.log("From:", email.from.email);
console.log("To:", email.to[0]?.email);
console.log("Subject:", email.subject);
console.log("Text:", email.textBody);
return NextResponse.json({ ok: true });
}This gives you a public endpoint at /api/inbound-email. If your app is deployed at https://example.com, the full webhook URL would be:
https://example.com/api/inbound-email
That is the URL you will use in InboxBridge.
Example inbound email payload
When an email is received, your endpoint gets a JSON payload. InboxBridge posts a parsed message that looks like this:
{
"id": "9b2c1f7a4e",
"receivedAt": "2026-06-27T15:04:22.000Z",
"from": { "email": "customer@example.com", "name": "Jordan Lee" },
"to": [{ "email": "abc123@webhook.inboxbridge.io", "name": "" }],
"cc": [],
"bcc": [],
"replyTo": null,
"subject": "Question about my order",
"date": "2026-06-27T15:04:00.000Z",
"textBody": "Hey, can someone check order #1234?",
"htmlBody": "<p>Hey, can someone check order #1234?</p>",
"attachments": []
}This is much easier to work with than a raw email message. The sender and recipients are objects with an email and a name, and the body is split into textBody and htmlBody.
You can read the values directly from JavaScript:
const sender = email.from.email; const subject = email.subject; const message = email.textBody; const attachments = email.attachments;
From there, you can store it in a database, pass it to a queue, send it to another API, or trigger whatever workflow your app needs.
Verify the request
Your webhook endpoint will be public, so you should confirm that a request really came from InboxBridge before you act on it.
A secret URL token
The simplest starting point is to include a secret token in your webhook URL:
https://example.com/api/inbound-email?secret=YOUR_LONG_RANDOM_SECRET
Then check that secret inside the route handler:
// app/api/inbound-email/route.ts
import { NextRequest, NextResponse } from "next/server";
export async function POST(request: NextRequest) {
const secret = request.nextUrl.searchParams.get("secret");
if (secret !== process.env.INBOUND_EMAIL_SECRET) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const email = await request.json();
console.log("Inbound email:", {
from: email.from.email,
to: email.to[0]?.email,
subject: email.subject,
});
return NextResponse.json({ ok: true });
}Use a long, random secret. Do not use something easy to guess.
Verify the signature InboxBridge sends
InboxBridge signs every delivery, so you can verify it instead of relying on a URL token alone. Each request includes an X-InboxBridge-Signature header in the form sha256=... and an X-InboxBridge-Timestampheader. The signature is an HMAC-SHA256 of the raw request body, using the signing secret InboxBridge generates for your bridge.
To verify it, compute the same HMAC over the raw body and compare:
// app/api/inbound-email/route.ts
import crypto from "crypto";
import { NextRequest, NextResponse } from "next/server";
export async function POST(request: NextRequest) {
const signature = request.headers.get("x-inboxbridge-signature") ?? "";
const body = await request.text();
const expected =
"sha256=" +
crypto
.createHmac("sha256", process.env.INBOXBRIDGE_SIGNING_SECRET!)
.update(body)
.digest("hex");
const valid =
signature.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
if (!valid) {
return NextResponse.json({ error: "Invalid signature" }, { status: 401 });
}
const email = JSON.parse(body);
// handle the email
return NextResponse.json({ ok: true });
}Read the body with request.text() and verify the signature before you call JSON.parse, since the HMAC is computed over the exact bytes that were sent.
Store the inbound email
Logging the email is enough for a first test, but most real applications will store the message. Here is a simple example using pseudo database code:
// app/api/inbound-email/route.ts
import { NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/db";
export async function POST(request: NextRequest) {
const secret = request.nextUrl.searchParams.get("secret");
if (secret !== process.env.INBOUND_EMAIL_SECRET) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const email = await request.json();
await db.inboundEmail.create({
data: {
messageId: email.id,
from: email.from.email,
to: email.to[0]?.email,
subject: email.subject,
text: email.textBody,
html: email.htmlBody,
rawPayload: email,
},
});
return NextResponse.json({ ok: true });
}The db import is just an example. Replace it with Prisma, Drizzle, Supabase, PlanetScale, Postgres, MySQL, or whatever you use in your app.
For production, it is often better to store the email quickly and process it in the background. Webhooks should respond fast. If your endpoint takes too long, the sending service may treat the delivery as failed.
Create a support ticket from an email
One of the most common inbound email use cases is support. A customer sends an email, your app receives the webhook, and then you create a ticket.
// app/api/inbound-email/route.ts
import { NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/db";
export async function POST(request: NextRequest) {
const secret = request.nextUrl.searchParams.get("secret");
if (secret !== process.env.INBOUND_EMAIL_SECRET) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const email = await request.json();
const ticket = await db.ticket.create({
data: {
requesterEmail: email.from.email,
title: email.subject || "New email request",
description: email.textBody || "",
source: "email",
status: "open",
rawEmail: email,
},
});
return NextResponse.json({
ok: true,
ticketId: ticket.id,
});
}This is the basic pattern behind many reply-by-email and helpdesk workflows. The email address becomes an input to your product.
Trigger an AI workflow from an inbound email
Another common use case is sending email into an AI workflow. For example, you may want to classify an incoming email as a sales lead, support request, billing question, bug report, vendor alert, or spam.
A simple version might look like this:
// app/api/inbound-email/route.ts
import { NextRequest, NextResponse } from "next/server";
import { classifyEmail } from "@/lib/classify-email";
import { db } from "@/lib/db";
export async function POST(request: NextRequest) {
const secret = request.nextUrl.searchParams.get("secret");
if (secret !== process.env.INBOUND_EMAIL_SECRET) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const email = await request.json();
const classification = await classifyEmail({
from: email.from.email,
subject: email.subject,
text: email.textBody,
});
await db.inboundEmail.create({
data: {
messageId: email.id,
from: email.from.email,
subject: email.subject,
text: email.textBody,
category: classification.category,
rawPayload: email,
},
});
return NextResponse.json({ ok: true });
}For a small app, this may be enough. For a larger app, you may want to enqueue the message and run the AI step in a background job. That keeps the webhook response fast and avoids timeouts.
Handle attachments
Inbound email often includes attachments. These might be PDFs, images, CSV files, invoices, signed forms, or screenshots. InboxBridge includes an attachments array that describes each file with its name, content type, and size.
{
"from": { "email": "vendor@example.com", "name": "Acme Billing" },
"to": [{ "email": "abc123@webhook.inboxbridge.io", "name": "" }],
"subject": "Invoice for June",
"textBody": "Invoice attached.",
"attachments": [
{
"name": "invoice-june.pdf",
"contentType": "application/pdf",
"contentLength": 482931,
"contentId": null
}
]
}Your app can loop through the attachments and store the metadata:
for (const attachment of email.attachments || []) {
await db.emailAttachment.create({
data: {
filename: attachment.name,
contentType: attachment.contentType,
size: attachment.contentLength,
},
});
}Be careful with attachments in production. You may want to scan files, limit file size, and restrict file types before you store them somewhere durable like S3 or another object storage service.
Return the right status code
Your route handler should return a 200 response when the email was accepted.
return NextResponse.json({ ok: true });If your endpoint returns a 500, times out, or fails to respond, the delivery may be treated as failed. That can be useful when something is truly broken. For example, if your database is down, returning an error tells InboxBridge to retry the message later. But avoid doing slow work inside the request if you can help it.
A good production pattern is:
- Validate the request
- Store the raw email payload
- Queue any slow processing
- Return
200 OK
Then your background worker can handle AI classification, Slack notifications, ticket creation, attachment processing, or other longer-running tasks.
Avoid duplicate processing
InboxBridge retries failed deliveries with exponential backoff, which is useful but introduces one thing you need to handle: the same message can arrive more than once. If your endpoint fails after saving the email but before responding, the same webhook could be delivered again.
The fix is to use an idempotency key. Every InboxBridge payload includes a stable id, so you can store it and enforce uniqueness in your database.
const messageId = email.id;
const existingEmail = await db.inboundEmail.findUnique({
where: { messageId },
});
if (existingEmail) {
return NextResponse.json({ ok: true, duplicate: true });
}
await db.inboundEmail.create({
data: {
messageId,
from: email.from.email,
subject: email.subject,
text: email.textBody,
rawPayload: email,
},
});This way, retrying the webhook does not create duplicate tickets, duplicate Slack messages, or duplicate AI runs.
Test locally
If your Next.js app is running on your machine, InboxBridge cannot reach localhost directly. You need a public URL that forwards traffic to your local app. Tools like ngrok, Cloudflare Tunnel, or similar tunneling tools can help with this.
For example, if your Next.js app is running on port 3000, your local endpoint is:
http://localhost:3000/api/inbound-email
Your public tunnel URL might look like:
https://your-tunnel-url.ngrok-free.app/api/inbound-email
Use the public tunnel URL as your InboxBridge webhook URL while testing. Once your app is deployed, you can replace it with your production URL.
Set up InboxBridge
After your route handler is ready, create a new bridge in InboxBridge. The setup is simple:
- Create a bridge
- Paste your webhook URL
- Copy your generated InboxBridge email address, which looks like
abc123@webhook.inboxbridge.io - Send a test email to that address
- Watch the webhook request arrive in your app
Your webhook URL should look something like this:
https://example.com/api/inbound-email?secret=YOUR_LONG_RANDOM_SECRET
When an email arrives, InboxBridge sends the parsed message to your Next.js route handler as JSON. Every delivery is recorded with its response code and duration, so you can inspect what happened, retry failures with exponential backoff, and replay any message from the bridge page when needed.
When to use an email webhook instead of polling an inbox
You can build inbound email features by connecting to Gmail or another inbox over IMAP. That approach can work, but it comes with tradeoffs. Polling an inbox usually means:
- Running a scheduled job
- Managing OAuth or mailbox credentials
- Waiting for the next polling interval
- Handling provider-specific behavior
- Tracking which messages have already been processed
- Dealing with folder state, labels, read status, and deletion
A webhook is simpler for application workflows. The email arrives, and your app gets an HTTP request. That makes webhooks a better fit for things like support ticket creation, reply-by-email features, AI agents, internal automations, device alerts, vendor notifications, lead intake, and form fallback addresses.
If the email is meant to become an event inside your app, a webhook is usually the cleaner model.
Common mistakes
Doing too much work inside the webhook
Keep the webhook fast. Store the email, then process it in the background if the work is slow.
Not storing the raw payload
Store the original JSON payload. It makes debugging much easier later.
Ignoring duplicate deliveries
Retries can happen. Use the payload id or another idempotency key.
Forgetting about attachments
Even if your first version only needs text, attachments may matter later. Store attachment metadata from the beginning.
Using a weak secret
Do not use test, secret, or your app name as the webhook secret. Use a long random value.
Not testing failed delivery
Test what happens when your endpoint returns an error. You should know how retries, logs, and replay work before you need them.
Full example
Here is a more complete route handler you can use as a starting point.
// app/api/inbound-email/route.ts
import { NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/db";
export async function POST(request: NextRequest) {
const secret = request.nextUrl.searchParams.get("secret");
if (secret !== process.env.INBOUND_EMAIL_SECRET) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
let email;
try {
email = await request.json();
} catch {
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
}
const messageId = email.id ?? null;
if (messageId) {
const existingEmail = await db.inboundEmail.findUnique({
where: { messageId },
});
if (existingEmail) {
return NextResponse.json({ ok: true, duplicate: true });
}
}
const savedEmail = await db.inboundEmail.create({
data: {
messageId,
from: email.from.email,
to: email.to[0]?.email ?? null,
subject: email.subject || "",
text: email.textBody || "",
html: email.htmlBody || "",
rawPayload: email,
},
});
for (const attachment of email.attachments || []) {
await db.emailAttachment.create({
data: {
inboundEmailId: savedEmail.id,
filename: attachment.name,
contentType: attachment.contentType,
size: attachment.contentLength,
},
});
}
return NextResponse.json({
ok: true,
inboundEmailId: savedEmail.id,
});
}This example does a few important things:
- Validates a secret token
- Parses the JSON payload
- Stores the raw email
- Avoids duplicate processing using the payload id
- Stores attachment metadata
- Returns a successful JSON response
You can extend this with ticket creation, Slack alerts, AI classification, CRM updates, or any other workflow your app needs.
Receive your first inbound email as JSON
Next.js does not need to know how email works. Your app just needs a webhook endpoint. InboxBridge gives you an inbound email address, parses incoming messages, and sends them to your application as structured JSON. You can use that to build support flows, reply-by-email features, AI inboxes, internal automations, and alert pipelines without running mail infrastructure.
Create a bridge, point it at your Next.js route handler, and send a test email. Your app can start receiving email as JSON in minutes.
Related
- Inbound email webhook tutorial. The five-minute version that is not tied to a framework.
- Email to webhook overview. What the service does and what payloads look like.
- CloudMailin alternative. Feature comparison if you are evaluating providers.
- InboxBridge home. An overview of turning inbound email into webhooks.