ProvenPin Install

Next.js App Router + Stripe webhook handler: working versions (tested 2026-10-15)

A Next.js route handler that verifies Stripe webhook signatures from the raw body and handles checkout.session.completed.

passed3 passed · 0 failed2026-10-15 13:09 UTC
Status
passed
Last passed
2026-10-15 13:09 UTC
Tests
3 / 3
Language
TypeScript
Runtime
node v22.23.3
pinned versions6
next@16.4.1stripe@23.0.0react@19.3.1react-dom@19.3.1tsx@4.23.15@types/node@24.19.2
run it
npm install --no-audit --no-fund --ignore-scripts=false
npm test
test environment
node v22.23.3
linux 6.17.0-1022-azure
x64
runner: github-actions 38738874208

The code

Exactly the files that passed. Your agent can pull them with get_recipe("nextjs-stripe-webhook") (install), or see them on GitHub.

package.json
{
  "name": "nextjs-stripe-webhook",
  "private": true,
  "type": "module",
  "scripts": {
    "test": "node --import tsx --test test/*.test.ts"
  },
  "dependencies": {
    "next": "16.4.1",
    "stripe": "23.0.0",
    "react": "19.3.1",
    "react-dom": "19.3.1"
  },
  "devDependencies": {
    "tsx": "4.23.15",
    "@types/node": "24.19.2"
  }
}
app/api/stripe/webhook/route.ts
import Stripe from "stripe";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY ?? "sk_test_placeholder");

export const fulfilled: string[] = [];

export async function POST(req: Request): Promise<Response> {
  const signature = req.headers.get("stripe-signature");
  if (!signature) return Response.json({ error: "missing signature" }, { status: 400 });
  const raw = await req.text(); // must be the raw body for signature checks
  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(raw, signature, process.env.STRIPE_WEBHOOK_SECRET!);
  } catch {
    return Response.json({ error: "bad signature" }, { status: 400 });
  }
  if (event.type === "checkout.session.completed") {
    const session = event.data.object as Stripe.Checkout.Session;
    fulfilled.push(session.id);
  }
  return Response.json({ received: true });
}
test/webhook.test.ts
import { test } from "node:test";
import assert from "node:assert/strict";
import Stripe from "stripe";
process.env.STRIPE_WEBHOOK_SECRET = "whsec_test_secret";
const { POST, fulfilled } = await import("../app/api/stripe/webhook/route.ts");
const stripe = new Stripe("sk_test_placeholder");

function signed(payload: object, secret = "whsec_test_secret") {
  const body = JSON.stringify(payload);
  const header = stripe.webhooks.generateTestHeaderString({ payload: body, secret });
  return new Request("http://localhost/api/stripe/webhook", { method: "POST", body, headers: { "stripe-signature": header } });
}
const evt = { id: "evt_1", object: "event", type: "checkout.session.completed", data: { object: { id: "cs_test_123", object: "checkout.session" } } };

test("fulfills a correctly signed checkout event", async () => {
  const res = await POST(signed(evt));
  assert.equal(res.status, 200);
  assert.ok(fulfilled.includes("cs_test_123"));
});
test("rejects a bad signature", async () => {
  const res = await POST(signed(evt, "whsec_wrong"));
  assert.equal(res.status, 400);
});
test("rejects a missing signature", async () => {
  const res = await POST(new Request("http://localhost/x", { method: "POST", body: "{}" }));
  assert.equal(res.status, 400);
});

Connect to ProvenPin

Add the free MCP server so your agent can pull this recipe and others like it. Same setup as the docs.

terminal
claude mcp add --transport http provenpin https://mcp.provenpin.com/mcp

Any other MCP client: Streamable HTTP at https://mcp.provenpin.com/mcp. No API key, no signup.