By language

Receive email in Next.js

Receive and process inbound email in a Next.js App Router route handler: verify Inboundr's signed webhook and process each message after responding.

This guide adds a route handler to a Next.js app (App Router) that receives every email sent to your domain and verifies it came from Inboundr. It works on Vercel or any Node host, with no SDK.

Before you start#

  • An address to receive on. Your account comes with an instant domain such as amber-falcon-4821.inboundr.email, so any address on it works without DNS setup. A verified domain of your own works the same way.
  • An API key, from API keys in the console:
    bash
    export INBOUNDR_API_KEY="inb_xxxxxxxxxxxxxxxxxxxxxxxx"
  • A public HTTPS URL for your handler. Inboundr only delivers to public addresses, so while developing, expose your local port with a tunnel:
    bash
    ngrok http 3000
    # or: cloudflared tunnel --url http://localhost:3000

1. Create an endpoint and route mail to it#

An endpoint is the URL Inboundr POSTs each email to. Create one pointing at your tunnel:

bash
curl https://inboundr.net/api/v1/endpoints \
  -H "Authorization: Bearer $INBOUNDR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "name": "Local handler", "url": "https://<your-tunnel>/api/inboundr" }'

The response includes a secret (whsec_…). It's shown only once, so store it next to your API key:

bash
export INBOUNDR_WEBHOOK_SECRET="whsec_xxxxxxxxxxxxxxxxxxxxxxxx"

Then send the domain's mail to the endpoint. Find the domain's ID (the instant domain is the one with "managed": true) and set it as the catch-all:

bash
curl https://inboundr.net/api/v1/domains \
  -H "Authorization: Bearer $INBOUNDR_API_KEY"

curl -X PATCH https://inboundr.net/api/v1/domains/dom_123 \
  -H "Authorization: Bearer $INBOUNDR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "catchAllEndpointIds": ["ep_123"] }'

To route a single address instead, see addresses.

2. Write the route handler#

The handler gets the parsed message as JSON: from, to, recipients, subject, text, html, attachment metadata and the spam, SPF and DKIM verdicts. See the full payload.

app/api/inboundr/route.ts
import { createHmac, timingSafeEqual } from "node:crypto";
import { after } from "next/server";

// node:crypto needs the Node.js runtime, not Edge.
export const runtime = "nodejs";

type InboundEmail = {
  id: string;
  threadId: string;
  from: string;
  to: string[];
  recipients: string[];
  subject: string;
  text: string | null;
  html: string | null;
  attachments: {
    filename: string | null; // inline images often have none
    contentType: string;
    size: number;
    downloadUrl: string | null;
  }[];
  receivedAt: string;
};

/** Checks X-Inboundr-Signature: t=<unix>,v1=<hex HMAC-SHA256 of "t.body">. */
function verifySignature(rawBody: string, header: string | null): boolean {
  if (!header) return false;
  const parts = Object.fromEntries(
    header.split(",").map((kv) => {
      const i = kv.indexOf("=");
      return [kv.slice(0, i), kv.slice(i + 1)];
    }),
  );
  const t = Number(parts.t);
  // Reject old deliveries so a captured request can't be replayed.
  if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > 300) return false;
  const expected = createHmac("sha256", process.env.INBOUNDR_WEBHOOK_SECRET!)
    .update(`${parts.t}.${rawBody}`)
    .digest("hex");
  const a = Buffer.from(expected);
  const b = Buffer.from(parts.v1 ?? "");
  return a.length === b.length && timingSafeEqual(a, b);
}

export async function POST(req: Request) {
  // The raw text, not req.json(): the signature covers the exact bytes.
  const rawBody = await req.text();
  if (!verifySignature(rawBody, req.headers.get("x-inboundr-signature"))) {
    return new Response("invalid signature", { status: 401 });
  }

  const deliveryId = req.headers.get("x-inboundr-delivery-id")!;
  const { email } = JSON.parse(rawBody) as { email: InboundEmail };

  // Runs after the response is sent, so slow work can't time the delivery out.
  after(() => processEmail(deliveryId, email));
  return new Response("ok");
}

async function processEmail(deliveryId: string, email: InboundEmail) {
  // Retries keep their delivery ID: record it in your database and return
  // early if it's already there, so a retried delivery isn't handled twice.
  console.log(`${email.from} → ${email.recipients.join(", ")}: ${email.subject}`);

  for (const attachment of email.attachments) {
    if (!attachment.downloadUrl) continue; // not stored on this plan
    const res = await fetch(attachment.downloadUrl, {
      headers: { Authorization: `Bearer ${process.env.INBOUNDR_API_KEY}` },
    });
    if (!res.ok) throw new Error(`download failed: ${res.status}`);
    const bytes = new Uint8Array(await res.arrayBuffer());
    // Upload to your own storage, parse it, hand it to a model…
    console.log(`${attachment.filename ?? "(unnamed)"}: ${bytes.byteLength} bytes`);
  }
}

Add both values to .env.local (and to your host's environment variables):

.env.local
INBOUNDR_API_KEY=inb_xxxxxxxxxxxxxxxxxxxxxxxx
INBOUNDR_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxxxxxxxxxxxxx

Make sure nothing in front of the route asks for a login. If middleware.ts (proxy.ts in Next.js 16) protects /api, exclude /api/inboundr from its matcher. On Vercel, preview deployments sit behind Deployment Protection by default, so point the endpoint at production or a protection-bypass URL.

3. Test it#

Send a sample delivery. It's signed with your endpoint's real secret, so it also tests your signature check:

bash
curl -X POST https://inboundr.net/api/v1/endpoints/ep_123/test \
  -H "Authorization: Bearer $INBOUNDR_API_KEY"

"ok": true means your handler answered with a 2xx. A 401 in status means the signature didn't match: see troubleshooting.

Then send a real email, with an attachment, from your own mailbox to any address on your domain, such as test@amber-falcon-4821.inboundr.email. It reaches your handler within seconds. Locally, run next dev and point the endpoint at your tunnel.

Read mail without a webhook#

To show an inbox in your app, read it from a server component or server action instead. Keep the API key on the server:

app/inbox/page.tsx
export default async function InboxPage() {
  const res = await fetch(
    "https://inboundr.net/api/v1/emails?direction=inbound&limit=20",
    {
      headers: { Authorization: `Bearer ${process.env.INBOUNDR_API_KEY}` },
      cache: "no-store",
    },
  );
  const { data } = (await res.json()) as {
    data: { id: string; fromAddress: string; subject: string; receivedAt: string }[];
  };
  return (
    <ul>
      {data.map((email) => (
        <li key={email.id}>
          {email.fromAddress}: {email.subject}
        </li>
      ))}
    </ul>
  );
}

See the emails API for filters and pagination.

Troubleshooting#

  • Every delivery fails the signature check. The HMAC is computed over the exact bytes sent. Read the raw body before any JSON parsing, and don't re-serialize it. Also check you're using the endpoint's whsec_… secret, not your inb_ API key.
  • Deliveries time out. Your handler has 30 seconds by default. Acknowledge first, then do slow work (AI calls, downloads, database writes) in the background.
  • The same email arrives twice. A delivery is retried when your handler doesn't answer 2xx in time, and a retry keeps its X-Inboundr-Delivery-Id. Store handled IDs and skip repeats.
  • The body is empty. Some hosts drop request bodies. Every delivery also carries the email's ID with its own signature, so you can fetch it instead: see when the body is missing.
  • Nothing arrives. Check the email under Emails in the console. If it's there, open its delivery to see your handler's response. If it isn't, the message never reached Inboundr: check the address and, for your own domain, its MX record.

Next steps#