A customer pays by card. The gateway says yes. Your app, however, never hears about it: the customer closed the tab on the success page, or their phone lost signal between the bank's screen and yours. Their money is gone and your order says "pending".
Or the opposite. The gateway sends its notification twice, because yours was slow to answer the first time, and the customer's wallet is credited twice.
Both failures come from the same misunderstanding of how online payments work. This article is about getting that part right, whichever gateway you use: Paymob, Fawry, Kashier, Stripe or any other. The exact details of each provider differ and their documentation wins every argument, but the shape below is the same everywhere.
Two messages, and only one is a fact
When a customer pays, your system receives up to two things:
- The redirect. The customer's browser comes back to your site, usually with a few query values saying it worked.
- The webhook (also called a callback or notification). The gateway's server calls your server directly and tells it what happened to the payment.
The redirect is a courtesy to the customer. It depends on a browser that may close, lose signal or be tampered with, and anyone can type a success URL into an address bar. Treat it as a page to show, never as proof of payment. The webhook, once you have verified it, is the fact.
So the flow is: create the order, create the payment with the gateway, send the customer to pay, and wait for the verified webhook to move the order forward. The redirect page can say "we are confirming your payment" and then poll your own server until the webhook has landed.
Step 1: verify that the message is real
Your webhook address is public. Anyone can post to it, so the first thing the handler does is check that the gateway sent the message.
Most gateways sign their notifications with a shared secret, using an HMAC. You compute the signature yourself from the message and compare it with the one that arrived. The recipe (which fields, in which order, with which algorithm) is in the provider's documentation, and some sign the raw request body while others sign a list of fields joined in a fixed order. Follow it exactly.
Two details matter:
- Compare in constant time, with
hash_equals, not==, so that the comparison does not leak where the strings differ. - When the signature covers the raw body, verify the bytes you received. Do not decode the JSON and re-encode it first, because that changes the bytes.
$expected = hash_hmac('sha512', $signedString, config('payments.hmac_secret'));
if (! hash_equals($expected, $received)) {
Log::warning('Payment notice with a bad signature', ['ip' => $request->ip()]);
return response('Invalid signature', 401);
}
A request that fails here is not a payment. Log it, answer with an error, and do nothing else.
Step 2: process each payment once
A webhook can arrive twice, late, or out of order. Gateways retry whenever they do not get a quick success response, and they do not coordinate with your server's mood. The rule that survives all of this is simple: receiving the same notice again must change nothing.
Make that true in the database, not in your hopes:
- Keep your own record of each payment, with the gateway's order and transaction ids, and a state: pending, paid, failed or refunded.
- Put a unique index on the gateway's transaction id.
- Process the notice inside a transaction that locks the payment row, and stop early when the payment is already in a final state.
DB::transaction(function () use ($notice) {
$payment = Payment::where('provider_order_id', $notice['order_id'])
->lockForUpdate()
->first();
if (! $payment) {
Log::error('Notice for an unknown payment', $notice);
return;
}
if ($payment->isFinal()) {
return; // a repeat, or a late duplicate: nothing to do
}
if ($notice['amount_minor'] !== $payment->amount_minor
|| $notice['currency'] !== $payment->currency) {
$payment->markMismatch($notice); // do not mark as paid; tell a human
return;
}
if ($notice['pending']) {
$payment->recordPending($notice);
} elseif ($notice['success']) {
$payment->markPaid($notice['transaction_id'], $notice);
} else {
$payment->markFailed($notice);
}
});
The effects that follow a payment (confirming the order, crediting a wallet, sending a message) belong inside that same transaction, or in a job queued from it. That way "paid" and "wallet credited" cannot come apart.
Step 3: trust your own record of the amount
The notice says what was paid. Your database says what should have been paid. Compare the amount and the currency with your own order, not with the customer's browser. If they differ, do not mark the order as paid: record a mismatch and tell a person. This one check stops a whole class of attacks, and it also catches honest mistakes in how amounts are sent to the gateway (pounds against piasters is a classic).
Step 4: answer quickly, and say what you mean
The gateway is waiting for your response. Save what you must, then return a 2xx fast. If the work behind a payment is slow, such as sending emails or generating invoices, queue it and answer.
What to return when something is wrong is a real decision:
- A bad signature: an error, and nothing is processed.
- A temporary failure on your side (the database was down): an error, so the gateway retries. That is exactly what its retries are for.
- A notice for a payment you do not know: log it loudly and answer success, otherwise the gateway keeps retrying something that will never match. Alert someone, though, because it usually means a bug.
Step 5: the states you will meet
Do not model a payment as paid or unpaid. Real ones have more states:
| State | What it means | What to do |
|---|---|---|
| pending | The customer has not finished, or a bank or kiosk method is still waiting | Keep the order open and show a clear message |
| paid | Confirmed and matches the order | Fulfil, once |
| failed | Declined or expired | Allow a new attempt; release any held stock |
| refunded | Money returned | Reverse what the payment did |
| mismatch | Paid but the amount or currency is wrong | Hold for review |
Some methods, such as cash through a kiosk, can take days to become paid. An order that "failed" an hour ago can become paid later, so decide in advance what happens when money arrives for an order you already cancelled. Usually the answer is a refund, and it is better to have written the path before it happens.
Step 6: do not rely on webhooks alone
Webhooks can be lost: your server was down for an hour, a firewall was misconfigured, a deployment dropped requests. Add a safety net: a scheduled job that looks at payments still pending after a while, asks the gateway's API for their status, and applies the answer through the same function the webhook uses. Once a day, compare your paid total with the gateway's own report.
Also keep the raw notice. When a customer disputes a charge six months later, the original payload with its signature is the best evidence you have.
What to test
- The same webhook posted twice: one effect.
- A tampered amount or signature: refused.
- A notice before the order's record exists, and one for an unknown order.
- Success, then failure, and failure, then success, in both orders.
- A pending notice followed by paid.
- Money arriving for a cancelled order.
- The handler throwing halfway: nothing is half done.
A checklist
- The redirect is a page, not a proof.
- Every notice is verified with a constant-time signature check.
- Payments have a state, a unique gateway id and a locked row while they change.
- Amount and currency are compared with your own order.
- Success is acknowledged fast, and slow work is queued.
- Unknown notices are logged and alerted.
- A scheduled job reconciles what webhooks missed.
- Raw notices are kept.
If you are connecting a gateway to a shop, a booking system or a wallet and want the payment flow reviewed before launch, tell us about it. Payment bugs are the expensive kind.




