If you take payments in a web app, here's the rule that matters most: the browser is never allowed to decide that an order is paid. I learned to take this seriously building two products, Fasco (an e-commerce store) and Planzia (a SaaS with a paywall), and the pattern is the same for both Stripe and Paystack.
This is how I verify payments with webhooks in Next.js, and why the obvious approach is a trap.
Why you can't trust the client
The tempting flow looks like this: the user pays, the payment widget calls back into your React code with "success", and you update the order to paid. It works in testing every time. It's also wide open.
Anyone can open dev tools, watch that success callback, and fire the same request by hand without paying a cent. The frontend simply has no way to prove money actually moved. Only the payment provider knows that, and the only trustworthy way it tells you is a webhook hitting your server.
The flow I actually use
Every payment goes through the same five steps:
- The client creates an order in the database with status `pending`.
- The client hands off to Stripe or Paystack to collect the money.
- The provider charges the card on its own servers.
- The provider sends a webhook to my server confirming the result.
- My webhook handler verifies that webhook and only then flips the order to `paid`.
The browser starts the payment. The server finishes it. Those are two different trust levels.
Verifying the webhook is the whole point
A webhook endpoint is a public URL, so anyone can POST to it pretending to be Stripe. The verification step is what makes it safe. Both providers sign their requests, and you check the signature with a secret only you and the provider know.
// app/api/webhooks/stripe/route.ts
import Stripe from "stripe"
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY)
export async function POST(req) {
const body = await req.text() // raw body, not parsed JSON
const signature = req.headers.get("stripe-signature")
let event
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET
)
} catch {
// bad signature: someone is poking at your endpoint
return new Response("Invalid signature", { status: 400 })
}
if (event.type === "checkout.session.completed") {
const orderId = event.data.object.metadata.orderId
await markOrderPaid(orderId)
}
return new Response("ok", { status: 200 })
}Two Next.js specifics bite people here. First, you need the raw request body for signature verification, so read `req.text()`, not `req.json()`. Parsing the body changes the bytes and the signature check fails. Second, this has to run on the Node runtime, since the crypto the providers use isn't available on the edge.
Paystack works the same way. The webhook arrives with an `x-paystack-signature` header that's an HMAC SHA512 of the body using your secret key. You recompute it and compare.
import crypto from "crypto"
function verifyPaystack(rawBody, signature) {
const hash = crypto
.createHmac("sha512", process.env.PAYSTACK_SECRET_KEY)
.update(rawBody)
.digest("hex")
return hash === signature
}Make the handler idempotent
Providers retry webhooks. If your server is slow or returns an error, the same `charge.success` event can arrive two or three times. If your handler isn't careful, you'll email three receipts or credit a wallet twice.
The fix is small: before acting, check whether you've already processed this event or whether the order is already paid. If it is, return 200 and do nothing. Treat "I've seen this already" as success, not as an error.
Test it without a real card
Both providers send test webhooks. The Stripe CLI can forward live events to your local machine with `stripe listen --forward-to localhost:3000/api/webhooks/stripe`, which is the fastest way to develop this without deploying. Paystack lets you resend events from its dashboard.
The short version: create the order as pending, let the provider charge the card, and let the verified webhook be the only thing that says "paid." Build that first, before the checkout UI, and the rest of your payment flow has solid ground to stand on.


