← Webhook localhost by framework

Next.js webhook localhost

Next.js App Router webhooks belong in app/api/webhooks/route.ts. request.json() is the usual reason Stripe signatures fail on localhost — the Route Handler must hash request.text().

Local run command

next dev binds 3000 unless another Next.js app already owns it. Mercur’s agent dials 127.0.0.1, so pass the hostname explicitly:

Run
npx next dev --hostname 127.0.0.1 --port 3000

Create an HTTP Endpoint whose Local port is 3000. If the terminal prints http://localhost:3001 because 3000 was taken, change the Endpoint to 3001 or stop the other process. A mismatch is webhook 502 localhost.

The first POST after a cold next dev can compile the Route Handler. GitHub only waits 10 seconds — see webhook timeout. Hit {findFrameworkArticle("nextjs").webhookPath} once with curl before pointing Stripe at the public URL.

Webhook route

The App Router looks for app/api/webhooks/route.ts with an exported POST. A page.tsx at the same path is a UI route and will not accept Stripe’s POST — that becomes webhook 404.

Provider URL: https://ep-webhooks.mercur.sh/api/webhooks (origin from Mercur, path {findFrameworkArticle("nextjs").webhookPath}).

app/api/webhooks/route.ts
import { NextRequest, NextResponse } from "next/server";
import Stripe from "stripe";

export const runtime = "nodejs";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

export async function POST(request: NextRequest) {
  // request.json() consumes the stream and reserializes. Stripe signs the raw bytes.
  const rawBody = await request.text();
  const signature = request.headers.get("stripe-signature");
  if (!signature) {
    return NextResponse.json({ error: "missing Stripe-Signature" }, { status: 400 });
  }

  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(
      rawBody,
      signature,
      process.env.STRIPE_WEBHOOK_SECRET!
    );
  } catch {
    return NextResponse.json({ error: "invalid signature" }, { status: 400 });
  }

  // Persist event.id here, then return 2xx. Do not await email/ERP in this handler.
  return NextResponse.json({ received: true, id: event.id, type: event.type });
}

Pages Router is a different file: pages/api/webhooks.ts with export const config = { api: { bodyParser: false } } and await buffer(req). Do not mix that config into the App Router snippet above.

Raw body and signatures

Stripe’s constructEvent needs the exact bytes in Stripe-Signature. In the App Router, request.json() reads the Request stream and then you cannot hash what Stripe signed. Call request.text() (or arrayBuffer()) once, pass that string into constructEvent, and only then JSON.parse if you must.

export const runtime = "nodejs" keeps the Stripe Node SDK off the Edge runtime, where node:crypto is not the same module.

Mercur does not verify Stripe signatures. Keep STRIPE_WEBHOOK_SECRET in local env. Full HMAC notes: webhook signature verification failed.

trailingSlash: true in next.config can 308 a POST to {findFrameworkArticle("nextjs").webhookPath}/ and drop the body. Keep the Route Handler path identical to the Dashboard URL, with no redirect.

Connect localhost

  1. Create a Mercur HTTP Endpoint aimed at {findFrameworkArticle("nextjs").localPort}.
  2. Authenticate the npm CLI (mercur auth) or sign in to the macOS app.
  3. Start forwarding:
Run
mercur start ep-webhooks
  1. Enable Log and Proxy (CLI start already forwards and logs). Point Stripe Developers → Webhooks at https://ep-webhooks.mercur.sh/api/webhooks.

Forwarding needs a connected agent — the macOS app or the npm package @mercur_dev/cli.

The CLI does not take a port flag. Port lives on the Endpoint. Walkthrough: Expose a local service.

Expected incoming request

After Connect, a Stripe payment_intent.succeeded (or the Dashboard “Send test webhook”) should show:

  • POST {findFrameworkArticle("nextjs").webhookPath}
  • Header Stripe-Signature: t=…,v1=…
  • Header Host equal to the public Mercur host (ep-webhooks.mercur.sh), not localhost:3000. Next.js does not reject that Host the way Django does.
  • JSON body with id starting evt_ and type

Guest inspector still answers 204 and never runs route.ts. Use it to confirm Stripe can POST; switch to the Endpoint URL when you need constructEvent to run.

Inspect and replay

Open console request history. Confirm path {findFrameworkArticle("nextjs").webhookPath} and that Stripe-Signature is present. Capture is capped at 256 KB; omitted bodies cannot be hashed or replayed.

After you stop calling request.json() first, use Retry. Replay needs proxy mode, a connected agent, and a complete stored body — not the guest inspector. Stripe’s timestamp window can still reject an old captured event even when the HMAC of the stored body is correct.

Inspect the request Connect localhost

Provider examples

Framework troubleshooting

  • 405 / 400 on POSTroute.ts exports only GET. Add POST.
  • 404 — file is app/webhooks/page.tsx or the Stripe URL omitted /api/webhooks.
  • Signature mismatchrequest.json() or a middleware.ts matcher that reads the body first. Narrow matcher so it does not wrap {findFrameworkArticle("nextjs").webhookPath}.
  • 502 ECONNREFUSED 127.0.0.1:3000next dev is on 3001, or you ran next start without a build. Align Local port.
  • Timeout on the first Stripe event — compile the route with a local curl, then send the Dashboard test.
  • Host / URL helpersheaders().get("host") is the Mercur hostname. Do not build localhost links from it.

Related