Ayobami ZenthosGet in touch
All insights

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.

BelleFood dish pageBelleFood home screenBelleFood order tracking

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.

  1. The browser. When Paystack’s popup closes, the app calls a paystack-verify function with the reference. The function asks Paystack directly whether that charge succeeded.
  2. The webhook. Paystack also calls our paystack-webhook function 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.success and the charge status is success
  • 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.
See BelleFood