Tillping
Menu

Fix guides

Stripe webhook returns 404 in Next.js (App Router)

Short answer: A 404 means "nothing lives at this address." Stripe sent its event, but your app has no route at that exact path. Your webhook code never ran. In the App Router, the file must be named route.ts and sit in the folder that matches the URL. For example, app/api/webhooks/route.ts answers at /api/webhooks. It must also export a POST function. Then the URL in Stripe must match that path exactly.

Signs you have this problem

  • Stripe's Event deliveries shows 404 (or 405) for your endpoint.
  • Your logs show nothing for the webhook. Not even an error.
  • The payment went through, but your app did not unlock anything.

Step 1: Test the address yourself

Run this with the exact URL you gave Stripe:

curl -i -X POST https://yourapp.com/api/webhooks

Look at the first line:

  • 404: no route at this path. Go to Step 2.
  • 405: the route exists, but it does not take POST. Go to Step 3.
  • 400: good news. Your code ran and said no, because this test has no signature. The path is fine.
  • 301, 307 or 308: a redirect. See our redirect guide.
  • 401 or 403: something asks for a login first. See the guides below.

Step 2: Fix the file name and folder

In the App Router, the folders make the URL. A route only goes live when the folder has a page.tsx or a route.ts file in it.

Your file Does it work for a webhook?
app/api/webhooks/route.ts Yes. It answers at /api/webhooks.
app/api/webhooks.ts No. The App Router ignores it. You get a 404.
app/api/webhooks/page.tsx No. That is a web page, not a webhook.
app/_api/webhooks/route.ts No. A folder that starts with _ is private and left out of the routes.
pages/api/webhooks.ts Yes, but this is the older Pages Router. Use its own setup.

Four more things to check:

  • Name and place. It must be route.ts (or route.js), all lowercase, inside the folder.
  • A page in the same folder. You can't have page.tsx and route.ts in the same folder. Keep the webhook in its own folder.
  • Two app folders. If you have both app/ and src/app/, Next.js uses app/ and ignores src/app/. Keep only one.
  • A base path. If next.config.js sets basePath: '/shop', your webhook lives at /shop/api/webhooks. Put that full path in Stripe.

Step 3: Export a POST function

Stripe sends events with POST. If your route.ts only exports GET, Next.js sends back 405. Here is a small route that works. It reads the raw body, checks Stripe's signature, then replies.

// app/api/webhooks/route.ts
import Stripe from 'stripe'

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY as string)

export async function POST(req: Request) {
  const body = await req.text() // raw body, not req.json()
  const sig = req.headers.get('stripe-signature') ?? ''

  let event: Stripe.Event
  try {
    event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET as string)
  } catch (err) {
    return new Response(`Webhook Error: ${(err as Error).message}`, { status: 400 })
  }

  if (event.type === 'checkout.session.completed') {
    // Unlock the purchase here.
  }

  return new Response('ok', { status: 200 })
}

Use a named export, export async function POST, as in the code above. Next.js looks for a function named after the method.

Step 4: Check the URL in Stripe

Open your webhook endpoint in the Stripe Dashboard and compare it, letter by letter, with your route:

  • The path. /api/webhooks and /api/webhook are not the same. Neither are /api/stripe/webhook and /api/webhooks/stripe.
  • The domain. Use your live domain, the one that has your latest deploy. An old preview URL may not have the route.
  • Test and live. Your test (sandbox) setup and your live setup each have their own webhook endpoint. Fix the one that is failing.

Step 5: Deploy, then test again

A file on your computer does not help Stripe. Push and deploy it. When next build finishes, it prints a list of routes. Look for /api/webhooks in that list. Then run the curl test from Step 1 again. You want a 400 from your own code.

Last, send a test event from Stripe, or click Resend on a failed one. You want a 2xx. Stripe retries failed live events for up to three days, so some old events may still come through.

Check it with a free scan

Paste your webhook address into the free Tillping scan: https://tillping.online/#scan. No account is needed. Only scan an address you own or are allowed to test.

The scan sends a few safe test requests, with no real payment. It tells you if the address answers 404 (not found) or 405 (no POST). It also flags a redirect, a Vercel login wall, a Cloudflare challenge, a crash (5xx), a slow reply, a bad certificate, and an endpoint that says OK to a fake signature. Fix what it finds, then scan again.

Sources

Check your webhook now

Paste your webhook address to see what it answers. Free, no account, no keys.

Check my webhook

Get an email when Deep Scans open

One email to confirm, then one when we launch. Leave any time.

We send one email to confirm. You can leave at any time.