Taking payments in Nigeria without losing a single order
How BelleFood accepts card, Paystack transfer and direct bank transfer, and why an order is only ever marked paid by the server.


Every Nigerian checkout has the same three customers. One pays by card. One taps “Pay with transfer” on Paystack’s screen, switches to their bank app, sends the money and closes the tab before anything confirms. One would rather send a normal bank transfer and upload the receipt.
BelleFood had to take all three without ever cooking an unpaid order, and without ever leaving a paid customer staring at “payment failed”. This is how the checkout is built.
The order exists before the money does
When a customer taps pay, the order is written first, with a pending payment status and a number like BF-20260929-04265. Nothing else in the system treats it as real yet. The kitchen does not see it, no points are earned, and the customer gets no “order confirmed” message.
Only one thing can move it to verified: the server, after Paystack or a member of staff has confirmed the money.
Two doors for the same confirmation
Paystack can tell us about a payment in two ways, and either one may arrive first.
- The browser. When Paystack’s popup closes, the app calls a
paystack-verifyfunction with the reference. The function asks Paystack directly whether that charge succeeded. - The webhook. Paystack also calls our
paystack-webhookfunction from its own servers. This is the one that saves the order when the customer has already closed the tab.
The webhook trusts nothing it is sent. It checks Paystack’s signature, an HMAC-SHA512 of the raw body, and compares it in constant time:
const digest = await crypto.subtle.sign('HMAC', await signingKey, encoder.encode(rawBody))
if (!constantTimeEqual(toHex(digest), signature.toLowerCase())) {
return new Response(null, { status: 401 })
}
After that, a payment only counts if every one of these is true:
- the event is
charge.successand the charge status issuccess - the order exists and is still
pending - the reference belongs to that order
- the currency is NGN
- the amount in kobo matches the order total
That last check, and the first-order discount, run together inside one database function. Both doors call the same function, so it does not matter which arrives first, or whether both do. A second confirmation finds the order already verified and changes nothing.
Every attempt gets its own reference
A customer whose card is declined will try again, so each attempt is charged as ORDER_NUMBER-attempt. A receipt can only settle the order it was raised for:
const belongsTo = (reference: string, orderNumber: string) =>
reference === orderNumber || reference.startsWith(orderNumber + '-')
If the database write fails, the webhook returns a 500. Paystack treats that as “try again later” and retries, instead of the payment silently disappearing.
The transfer that lands after the customer gives up
Paystack’s transfer screen asks the customer to send money to a temporary account and wait. Bank transfers in Nigeria are fast, but not always that fast, and people close the screen.
So when the popup closes without a success, the checkout does not show an error and move on. It says the payment is not finished yet, and that a transfer already sent will confirm on its own. Then it watches the order. The moment the webhook marks it verified, the customer is taken straight to their order page and the cart is cleared. Nobody has to call the restaurant to ask whether their money arrived.
Direct bank transfer, confirmed by a person
Some customers simply prefer to send money to the restaurant’s own account. They choose bank transfer, see the account details, send the money and upload the receipt with their bank reference.
That order stays pending until a member of staff opens it in the dashboard, checks the receipt against their bank alert and confirms it. Only then does it become verified, exactly like a Paystack payment.
Alerts follow the money, not the order
The kitchen alert, the customer’s “payment verified” message, their points and their order count all come from one database trigger. It fires only when payment_status changes to verified:
if new.payment_status = 'verified' and old.payment_status is distinct from 'verified' then
-- notify, count the order, award points
end if;
Because it reacts to the change and not to the order being created, staff never hear about an order nobody has paid for, and a paid order can never be counted twice.
What I took from it
- Treat the client as a messenger. The browser can say “I think it’s paid”. Only the server, talking to Paystack, decides.
- Make every confirmation idempotent. Payments arrive late, twice and out of order, so let them.
- Design for the customer who leaves early. In Nigeria that is a normal customer, not an edge case.
- Put side effects on the state change. If it should only happen once something is paid, attach it to the moment it becomes paid.