Paystack vs Flutterwave for a Next.js Checkout: Fees, Webhooks, Splits
Founder & Lead Engineer, RAITHub
For a Next.js checkout in Nigeria or elsewhere in Africa, Paystack and Flutterwave follow the same flow: initialise on the server, redirect to hosted checkout, then verify before marking anything paid. They differ in details that break code: Paystack takes kobo and signs webhooks with HMAC SHA512; Flutterwave takes naira and sends a static secret hash. Flutterwave publishes 2% for local Nigerian payments.
This is engineering guidance, and it is honest about its footing. RAITHub has not shipped a Paystack or Flutterwave integration. It has shipped Stripe rent collection in PropDesk (1,024 tests), and bKash, Nagad and SSLCommerz in TheSkinProof, the founder's own marketplace venture. SSLCommerz works the same way as both providers here: a hosted page, a server-to-server notification, and a validation call before the order is paid. Every provider detail below was checked on 30 September 2026. Paystack's documentation site and pricing page refused our automated check that day, so its technical facts come from Paystack's own published OpenAPI specification and documentation code snippets on GitHub. Where we could not confirm something, the table says so.
How do Paystack and Flutterwave compare?
On the flow, closely. On fees, amount units, webhook security and split payments, enough to matter in code.
| Aspect | Paystack | Flutterwave |
|---|---|---|
| Where it works | Nigeria, Ghana, South Africa and Kenya, with a private beta in Côte d'Ivoire and Egypt; the API's currency list is NGN, GHS, KES, ZAR and USD | Payment methods documented for NGN, GHS, KES, ZAR, UGX, RWF, TZS, MWK, EGP, XAF and XOF, plus USD, EUR and GBP |
| Published fees, Nigeria | Published at paystack.com/pricing; not quoted here because the page could not be checked on 30 September 2026 | 2% per local transaction (1.4% transaction fee plus 0.6% platform fee); 4.8% for international cards; 7.5% VAT on fees |
| Settlement | Not confirmed at source; check your dashboard and pricing page | "The next day for local payments"; international timing varies |
| Create a checkout | POST /transaction/initialize with email and amount; returns authorization_url, access_code and reference | POST /v3/payments with tx_ref, amount, currency, redirect_url and customer; returns data.link |
| Amount unit | An integer in the smallest denomination: 350000 is NGN 3,500.00 | Major units: 3500 is NGN 3,500 |
| Verify a payment | GET /transaction/verify/:reference; status success | GET /v3/transactions/{id}/verify; check status successful, tx_ref, currency, and amount at least what you expect |
| Webhook verification | HMAC SHA512 of the raw body with your secret key, sent in x-paystack-signature | The secret hash you set, sent as-is in verif-hash; a plain comparison, not a signature over the body |
| Webhook delivery | Successful payment event: charge.success; retry schedule not confirmed at source | Successful payment event: charge.completed; respond 200 within 60 seconds; retries are opt-in, 3 times at 30-minute intervals |
| Split payments | Subaccounts with a percentage_charge; pass subaccount or a multi-split split_code on initialise; bearer sets who pays the fee | Subaccounts with split_type percentage or flat; pass a subaccounts array with the charge type, amount and a split ratio for several |
| Test mode | Test secret keys start with sk_test_ | Keys carry _TEST, a dashboard toggle switches modes, and test cards cover PIN, 3DS, AVS and no-auth flows |
Sources: Paystack's introduction to Paystack (countries), OpenAPI specification (initialise fields, amount unit, currencies, verify path), documentation snippets (webhook HMAC, charge.success payload, subaccounts and splits) and MCP server README (test key prefix). Flutterwave's Nigeria pricing, payment methods, Standard checkout, transaction verification, webhooks, split payments, authentication and testing pages. Payment APIs change; re-check before you build.
Which one should I choose for a Next.js app in Nigeria?
Choose on coverage, cost at your real ticket size, and the payment methods your customers use, not on the integration effort, which is similar. If you might add the second later, one as primary and one as fallback, put a provider interface in from day one.
| If this matters most | Lean towards | Why |
|---|---|---|
| East Africa, francophone Africa or Egypt as well as Nigeria | Flutterwave | Its documented payment methods cover more currencies, including M-Pesa for KES and mobile money in several markets |
| Webhook authenticity without an extra API call | Paystack | An HMAC over the body proves the payload was not changed; a static hash only proves the sender knows the secret |
| Predictable fees on large tickets | Whichever is cheaper at your amount | Model a real basket or a real annual rent against both pricing pages, including any caps, VAT and stamp duty |
| Payouts to sellers, landlords or partners | Either | Both support subaccounts; the harder work is your own ledger and refunds |
| Resilience when one provider has an incident | Both | Behind one interface, switching the primary is a configuration change |
One fee detail from Flutterwave's split-payments page: a CBN stamp duty fee of NGN 50 applies to transactions above NGN 10,000. That is general information from a provider's documentation; confirm tax treatment with your adviser.
How does the checkout flow work in the Next.js App Router?
Four steps, and only the first and last belong to you. Create a pending payment row with your own reference; call the provider from the server with your secret key; redirect the customer to the hosted page; then confirm the payment from the webhook and from the return page, whichever arrives first. Flutterwave's docs say to "always verify the final state of the transaction" on your server after the redirect, rather than trusting its query parameters.
// app/checkout/actions.ts: a server action. Secret keys never reach the browser.
'use server'
import { redirect } from 'next/navigation'
import { createPendingPayment } from '@/lib/payments/store' // your data layer
export async function startCheckout(orderId: string, provider: 'paystack' | 'flutterwave') {
// Amount comes from your database, in kobo, never from the form.
const p = await createPendingPayment(orderId, provider) // { reference, amountKobo, email }
const returnUrl = process.env.APP_URL + '/checkout/return?ref=' + p.reference
if (provider === 'paystack') {
const res = await fetch('https://api.paystack.co/transaction/initialize', {
method: 'POST',
headers: { Authorization: 'Bearer ' + process.env.PAYSTACK_SECRET_KEY, 'Content-Type': 'application/json' },
body: JSON.stringify({ email: p.email, amount: p.amountKobo, currency: 'NGN', reference: p.reference, callback_url: returnUrl }),
})
const json = await res.json()
if (!res.ok || !json.status) throw new Error('paystack initialise failed')
redirect(json.data.authorization_url)
}
const res = await fetch('https://api.flutterwave.com/v3/payments', {
method: 'POST',
headers: { Authorization: 'Bearer ' + process.env.FLW_SECRET_KEY, 'Content-Type': 'application/json' },
body: JSON.stringify({
tx_ref: p.reference,
amount: p.amountKobo / 100, // Flutterwave takes naira
currency: 'NGN',
redirect_url: returnUrl,
customer: { email: p.email },
}),
})
const json = await res.json()
if (!res.ok || json.status !== 'success') throw new Error('flutterwave payment link failed')
redirect(json.data.link)
}
Keep every amount in your database as integer kobo, and convert only at this edge. Integer kobo divided by 100 gives at most two decimal places for Flutterwave; on the way back, Math.round(amount * 100) turns its naira figure into kobo without floating-point drift.
How do you verify a Paystack or Flutterwave webhook in a Next.js route handler?
Read the raw body, check the signature with a constant-time compare, re-fetch the payment from the provider, and confirm it in a transaction that is safe to run twice. Next.js route handlers give you the unparsed body directly; its docs note you do not need bodyParser configuration, unlike Pages Router API routes (Next.js: route.js).
// lib/payments/verify.ts
import { createHash, createHmac, timingSafeEqual } from 'node:crypto'
/** Constant-time string compare. Hashing first gives equal-length buffers. */
export function safeEqual(a: string, b: string): boolean {
if (!a || !b) return false
const x = createHash('sha256').update(a).digest()
const y = createHash('sha256').update(b).digest()
return timingSafeEqual(x, y)
}
/** Paystack: HMAC SHA512 of the exact request bytes, keyed with your secret key, hex. */
export function paystackSignatureOk(raw: Buffer, header: string | null, secretKey: string): boolean {
if (!header) return false
const expected = createHmac('sha512', secretKey).update(raw).digest('hex')
return safeEqual(header.toLowerCase(), expected)
}
/** Flutterwave: the verif-hash header must equal the secret hash set in your dashboard. */
export function flutterwaveHashOk(header: string | null, secretHash: string): boolean {
return !!header && safeEqual(header, secretHash)
}
// app/api/webhooks/paystack/route.ts
import { paystackSignatureOk } from '@/lib/payments/verify'
import { confirmPayment } from '@/lib/payments/confirm'
export const runtime = 'nodejs' // node:crypto; not the edge runtime
export async function POST(req: Request) {
const raw = Buffer.from(await req.arrayBuffer()) // the bytes Paystack signed, before any parsing
const secret = process.env.PAYSTACK_SECRET_KEY!
if (!paystackSignatureOk(raw, req.headers.get('x-paystack-signature'), secret)) {
return new Response('invalid signature', { status: 401 })
}
const event = JSON.parse(raw.toString('utf8')) as { event: string; data: { reference: string } }
if (event.event !== 'charge.success') return new Response('ignored', { status: 200 })
// Treat the webhook as a hint. The amount you trust comes from the verify API.
const res = await fetch(
'https://api.paystack.co/transaction/verify/' + encodeURIComponent(event.data.reference),
{ headers: { Authorization: 'Bearer ' + secret }, cache: 'no-store' },
)
if (!res.ok) return new Response('verify failed', { status: 502 }) // not 200, so delivery counts as failed
const { data } = (await res.json()) as {
data: { status: string; reference: string; amount: number; currency: string }
}
if (data.status !== 'success') return new Response('not successful', { status: 200 })
await confirmPayment({ provider: 'paystack', reference: data.reference, amountKobo: data.amount, currency: data.currency })
return new Response('ok', { status: 200 })
}
// app/api/webhooks/flutterwave/route.ts
import { flutterwaveHashOk } from '@/lib/payments/verify'
import { confirmPayment } from '@/lib/payments/confirm'
export const runtime = 'nodejs'
export async function POST(req: Request) {
if (!flutterwaveHashOk(req.headers.get('verif-hash'), process.env.FLW_SECRET_HASH!)) {
return new Response('invalid hash', { status: 401 })
}
const event = JSON.parse(await req.text()) as { event: string; data: { id: number } }
if (event.event !== 'charge.completed' || !Number.isSafeInteger(event.data.id)) {
return new Response('ignored', { status: 200 })
}
const res = await fetch('https://api.flutterwave.com/v3/transactions/' + event.data.id + '/verify', {
headers: { Authorization: 'Bearer ' + process.env.FLW_SECRET_KEY },
cache: 'no-store',
})
if (!res.ok) return new Response('verify failed', { status: 502 })
const { data } = (await res.json()) as {
data: { status: string; tx_ref: string; amount: number; currency: string }
}
if (data.status !== 'successful') return new Response('not successful', { status: 200 })
await confirmPayment({
provider: 'flutterwave',
reference: data.tx_ref,
amountKobo: Math.round(data.amount * 100), // naira back to kobo
currency: data.currency,
})
return new Response('ok', { status: 200 })
}
// lib/payments/confirm.ts: idempotent. Two deliveries of one event confirm it once.
import { pool } from '@/lib/db' // a node-pg Pool
type Confirm = { provider: string; reference: string; amountKobo: number; currency: string }
export async function confirmPayment(p: Confirm): Promise<'paid' | 'duplicate' | 'mismatch'> {
const client = await pool.connect()
try {
await client.query('BEGIN')
// Row lock: a concurrent duplicate waits here, then sees status = 'paid'.
const { rows } = await client.query(
'SELECT id, status, amount_kobo, currency FROM payments WHERE provider = $1 AND reference = $2 FOR UPDATE',
[p.provider, p.reference],
)
const row = rows[0]
if (!row) throw new Error('unknown reference: ' + p.reference) // 500, so the event is not lost silently
if (row.status === 'paid') {
await client.query('ROLLBACK')
return 'duplicate'
}
if (p.amountKobo < Number(row.amount_kobo) || p.currency !== row.currency) {
await client.query("UPDATE payments SET status = 'needs_review' WHERE id = $1", [row.id])
await client.query('COMMIT')
return 'mismatch'
}
await client.query("UPDATE payments SET status = 'paid', paid_at = now() WHERE id = $1", [row.id])
// Fulfil in the same transaction: mark the order, queue the receipt email.
await client.query('COMMIT')
return 'paid'
} catch (err) {
await client.query('ROLLBACK')
throw err
} finally {
client.release()
}
}
Behind that code sits one constraint, UNIQUE (provider, reference) on the payments table, and one rule: a payment moves to paid only from inside confirmPayment. The return page calls the same function after its own verify call, so it does not matter whether the customer's browser or the webhook arrives first.
Three details that catch teams out:
- Do not parse before you verify.
req.json()followed byJSON.stringifydoes not give back the bytes Paystack signed; key order and whitespace can differ, and the HMAC fails. - Check that your proxy lets the webhook through. An auth check in
proxy.tsthat redirects/api/webhooks/*to a login page turns every delivery into a failure. See Next.js proxy.ts and middleware for how that file behaves in Next.js 16. - Answer fast. Flutterwave expects a 200 within 60 seconds. If fulfilment is slow, record the event, return 200 and process it from a queue.
Is Flutterwave's secret hash less secure than Paystack's HMAC signature?
It proves less. An HMAC over the body proves the payload came from Paystack and was not changed. A static verif-hash proves only that the sender knows a secret, and anyone who sees one request, in a log or a debugging tool, can replay that header on a forged body. Flutterwave's own guidance covers the gap: re-query its API to verify the transaction before giving value. With the route above, a forged Flutterwave webhook can at most trigger a verify call that returns the real status. Do not log request headers, rotate the hash if it may have leaked, and compare it in constant time anyway.
How do split payments work on Paystack and Flutterwave?
Both use subaccounts: you register each payee's bank account once, then name the subaccount or a split on each payment. The provider settles each share; your job is the ledger, the refunds and the reporting.
- Paystack: create a subaccount with a
percentage_charge, then passsubaccounton initialise, or create a multi-split and pass itssplit_code.transaction_chargesets a flat fee that overrides the percentage, andbearerisaccountorsubaccount. - Flutterwave: create a subaccount with
split_typepercentageorflatand asplit_value, then pass asubaccountsarray on the payment with atransaction_charge_type, atransaction_charge, and atransaction_split_ratiowhen several subaccounts share one payment.
Refunds on a split payment are where marketplaces lose money, so decide who absorbs them before launch. Split payments and escrow covers the ledger, and Stripe Connect for marketplaces shows the same decisions on Stripe. Whether collecting money on behalf of others needs a licence in your market is a question for your adviser, not your developer.
How do you test a Paystack or Flutterwave integration before going live?
In each provider's test mode, with replayed webhooks for the cases a happy-path demo never shows. The ones worth writing first:
- A webhook with a wrong or missing signature is rejected with 401, and nothing changes.
- The same webhook delivered twice, including at the same moment, marks the payment paid once.
- The return page and the webhook both confirm; the order is fulfilled once.
- The verify API reports a lower amount or a different currency; the payment goes to review, not paid.
- A Flutterwave amount of 1234.5 naira becomes 123,450 kobo exactly.
- The verify call times out; the handler returns non-200 and a daily reconciliation job later picks it up.
- A test key is never used in production: fail at startup if
sk_test_or_TESTappears in a production secret.
The full method is in testing payments and webhooks end to end. If webhooks are not arriving at all, the checks in why a Stripe webhook is not firing apply to any provider: URL, environment, proxy and response code.
Can Paystack and Flutterwave run behind one interface?
Yes, and they should if you might ever add the second. Give your checkout one interface, createSession, verify, refund and verifyWebhook, with an adapter per provider that owns its amount unit, its statuses and its signature scheme. TheSkinProof does this for bKash, Nagad, SSLCommerz and cash on delivery; the bKash and SSLCommerz integration guide shows the shape, and the payments engineering guide covers ledgers and reconciliation.
How do working hours line up between Lagos and Dhaka?
Lagos is UTC+1 and Dhaka UTC+6, so Dhaka is 5 hours ahead and there are about 4 shared working hours: a Lagos morning from 09:00 is Dhaka's afternoon from 14:00. The working week is agreed per client, and the rest runs on written daily handoffs.
Why RAITHub for this
- The same pattern has shipped. TheSkinProof, the founder's own venture, runs SSLCommerz with the same verify-before-paid pattern, alongside bKash, Nagad and cash on delivery, with 750+ tests across 217 API endpoints. PadhAI, built by RAITHub, runs 9 payment gateways.
- Webhooks with money-grade tests. PropDesk's Stripe rent collection sits inside a platform with 1,024 automated tests, 932 unit and 92 end to end, run in CI.
- Honest scope. Paystack and Flutterwave have not been shipped by RAITHub, so they are quoted as new work, built against each provider's test mode with the tests listed here.
- Next.js App Router is everyday work. This site runs on Next.js 16. See Next.js development for the frontend and API and backend development for the payment service behind it.
When you don't need us
- You sell on a hosted store with an official plugin. Paystack publishes its own WooCommerce plugin; install it rather than writing a checkout.
- You only need a payment link. Both providers offer hosted checkout; a link sent by email may be all a small business needs.
- You have one provider, one currency and no payouts. The route handler above is most of the job; a competent in-house developer can ship it.
- You need a team that has already shipped Paystack or Flutterwave and can show it in production.
- You need licensing or tax advice on holding or splitting funds. Ask a qualified adviser first.
Provider documentation checked on 30 September 2026. Fees, tax and licensing points are general information only; confirm them with the provider and your adviser.
If you are choosing between Paystack and Flutterwave, or your webhooks are already marking payments twice, book the free 15-minute technical audit. Bring your order flow, your ticket sizes and the countries you sell in.
Frequently asked questions
Is Paystack or Flutterwave better for a Next.js app in Nigeria?
Neither is better in general. Both use server-side initialisation, hosted checkout and a verify call. Flutterwave documents more currencies and publishes 2% for local Nigerian payments; Paystack signs webhooks with an HMAC over the body. Compare both pricing pages at your real transaction size.
How do I verify a Paystack webhook in Next.js?
In a route handler, read the raw body with request.arrayBuffer(), compute an HMAC SHA512 of it with your Paystack secret key, and compare the hex result with the x-paystack-signature header in constant time. Then call the verify endpoint before marking the payment paid.
How does Flutterwave webhook verification work?
You set a secret hash in your Flutterwave dashboard, and Flutterwave sends it unchanged in the verif-hash header. Compare it with your stored value, then re-query the transaction verify endpoint, because the header does not prove the body was unchanged.
Does Paystack use kobo or naira for amounts?
Paystack's API takes amounts in the smallest denomination of the currency, so kobo for NGN: 350000 means NGN 3,500. Flutterwave's hosted checkout takes major units, so 3500 means NGN 3,500. Store kobo and convert inside each provider's adapter.
How do I stop a webhook from marking a payment paid twice?
Give each payment a unique reference, lock its row inside a transaction, and move it to paid only if it is not already paid. A duplicate or concurrent delivery then finds the payment paid and changes nothing.
Has RAITHub integrated Paystack or Flutterwave before?
No. RAITHub has shipped Stripe in PropDesk, bKash, Nagad and SSLCommerz in TheSkinProof, the founder's own venture, and 9 gateways in PadhAI. Paystack and Flutterwave would be quoted as new work using the same patterns.
Related posts
Technical SEO Checklist for 2026: The Foundation That Lets You Rank
7 min readLocal and Geo SEO for Service Businesses: Rank Where Your Customers Are
7 min readSaaS Entitlements: Enforcing Plans, Limits and Add-ons in Code
14 min readReady to discuss your project?
Book a free 15-minute technical audit with our engineering team.