Imoran ai LogoImoran ai Logo

Verifying Payments with Webhooks in Next.js (Stripe and Paystack)

Never mark an order paid in the browser. How I verify Stripe and Paystack payments with webhooks in Next.js, including signature checks and idempotency, drawn from Fasco and Planzia.

Published: June 25, 2026

Last updated: June 30, 2026

Verifying Payments with Webhooks in Next.js (Stripe and Paystack)

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.

Frequently asked questions

Why shouldn't I mark an order paid in the browser?

The browser can't prove money moved, and anyone can replay the success callback without paying. Only a verified webhook from Stripe or Paystack confirms a real payment.

How do I verify a webhook in Next.js?

Read the raw request body (not parsed JSON), check the provider's signature with your secret, and run it on the Node runtime. Stripe uses constructEvent; Paystack uses an HMAC SHA512 of the body.

Why does my webhook fire more than once?

Providers retry webhooks. Make the handler idempotent: if you've already processed the event or the order is already paid, return 200 and do nothing.

Need payments wired into your app?

That's a common piece of work for me with both Stripe and Paystack. Reach out and I'll help.

Let's Work
Together

Hand Reaching

Have a project in mind? We'd love to hear about it. Let's create something great together!

PRODUCT DEVELOPERUI ENGINEERMVP DEVELOPMENTFRONTEND DEVELOPMENTPRODUCT DEVELOPERUI ENGINEERMVP DEVELOPMENTFRONTEND DEVELOPMENTPRODUCT DEVELOPERUI ENGINEERMVP DEVELOPMENTFRONTEND DEVELOPMENT