Stripe Payments in Next.js: The Webhook Is the Source of Truth
The most common payment bug in SaaS apps: the user pays, lands on a success page, but doesn't get access. The developer trusted the redirect instead of the webhook.
Stripe Checkout handles the payment UI. But everything that happens after — verifying the payment, granting access, updating the database, sending emails — is your responsibility. Get the ordering wrong and you'll lose transactions.
The Flow
User clicks "Buy"
→ Your server creates a Checkout Session
→ User pays on Stripe's hosted page
→ Stripe redirects user to your success URL
→ Stripe fires a webhook to your server (independently)
→ Your webhook handler grants access
The redirect and the webhook are independent events. The redirect happens immediately. The webhook might take seconds. Your success page should never assume the webhook has fired.
Creating a Checkout Session
const session = await stripe.checkout.sessions.create({
mode: "payment",
line_items: [{ price: priceId, quantity: 1 }],
success_url: `${baseUrl}/checkout/success`,
cancel_url: `${baseUrl}/checkout/cancel`,
metadata: { userId: currentUser.id },
})
return Response.json({ url: session.url })
The critical detail: metadata: { userId }. This links the payment to your user. Without it, when the webhook fires, you won't know who paid.
The Webhook Handler
This is the most important endpoint in your app. Every payment flows through it.
Step 1: Verify the Signature
const body = await req.text()
const sig = req.headers.get("stripe-signature")!
let event: Stripe.Event
try {
event = stripe.webhooks.constructEvent(body, sig, webhookSecret)
} catch {
return Response.json({ error: "Invalid signature" }, { status: 400 })
}
Without signature verification, anyone can POST to your webhook URL and grant themselves access. The signature proves the request came from Stripe.
Step 2: Process the Event
if (event.type === "checkout.session.completed") {
const session = event.data.object
const userId = session.metadata?.userId
// 1. Create database record (permanent, auditable)
await db.insert(licenses).values({
userId,
plan: "pro",
paymentId: session.id,
})
// 2. Update auth metadata (what the UI reads)
await authProvider.updateUser(userId, { plan: "pro" })
// 3. Send confirmation email
await sendEmail({ to: session.customer_details?.email, ... })
}
The order matters: database first (permanent record), then auth metadata (UI update), then email (nice-to-have). If the auth update fails, the database record serves as a fallback.
Step 3: Return 200
Always return a 200 response, even if you encounter an error you've handled. Stripe retries non-200 responses for up to 72 hours. If your handler returns 500 because of an unrelated error, Stripe will keep retrying and you might process the same event multiple times.
Idempotency
Webhooks can fire more than once. Network issues, Stripe retries, duplicate events. Your handler must produce the same result regardless of how many times it runs.
// Check before inserting
const existing = await db.query.licenses.findFirst({
where: eq(licenses.paymentId, session.id),
})
if (existing) {
return Response.json({ received: true })
}
// Safe to insert
await db.insert(licenses).values({ ... })
The paymentId acts as a natural deduplication key. If you've already processed this payment, skip it.
The Success Page Trap
After payment, Stripe redirects to your success URL. The webhook may not have fired yet. If your success page queries the database for the license, it might not exist.
Two approaches:
Polling — the success page checks for the license every 2 seconds and shows a loading state until it appears. Works but feels janky.
Optimistic messaging — "Your access is being activated. This usually takes a few seconds." Then redirect to the dashboard, which will show the correct state once the webhook processes. Cleaner UX.
Never show "You're now a Pro member!" based on the redirect. Only the webhook confirms payment.
Testing Locally
Stripe CLI forwards webhooks to your local server:
stripe listen --forward-to localhost:3000/api/webhooks/payment
This gives you a temporary webhook secret for local development. Use test card 4242 4242 4242 4242 with any future expiry.
Test these scenarios:
- Successful payment — license created, metadata updated
- Duplicate webhook — no duplicate license
- Missing userId in metadata — graceful error, no crash
- Stripe signature mismatch — rejected with 400
The Checklist
Before going live:
- [ ] Webhook signature verification is enabled
- [ ]
userIdis in checkout session metadata - [ ] Handler is idempotent (duplicate-safe)
- [ ] Database record is created before auth metadata update
- [ ] Confirmation email is sent
- [ ] Success page doesn't assume payment is processed
- [ ] Webhook endpoint is excluded from CSRF middleware
- [ ] Error responses don't leak internal details
- [ ] All test scenarios pass locally with Stripe CLI
Payments are the one flow where bugs cost money. Test every path.