Most payment integrations have two ways of telling your store that someone paid. The customer's browser gets sent back to a "thank you" URL, and the payment service calls a callback URL on your server. A surprising number of shops only really trust the first one.
That's a problem, because the redirect is just a URL. Anyone can type it.
I spend a lot of my time on the callback side of a payment gateway, and these are the checks I'd want in any integration, whichever provider you use.
1. The return URL is for the customer, not your database
Treat the redirect as "show the customer a nice page" and nothing more. Don't fulfil anything there. If your success page reads ?status=paid&order=1042, sooner or later somebody will try order=1043.
Order state should only change when your server has confirmed the payment itself.
2. Callbacks are public endpoints too
This is the part people miss. Plenty of gateways call your callback with a plain HTTP request. From your server's point of view, that request looks exactly like one anybody could send with curl. So if your handler does this:
$order = get_order($_GET['order_id']);
$order->mark_paid();
...your callback is a free "mark as paid" button.
3. Give every order a secret nobody can guess
When you create the payment, generate a random value, store it with the order and put it in the callback URL:
$nonce = bin2hex(random_bytes(16));
save_order_nonce($orderId, $nonce);
$callbackUrl = 'https://example.com/payment-callback.php?' . http_build_query([
'order_id' => $orderId,
'nonce' => $nonce,
]);
In the callback, check it before you do anything else. Use hash_equals so the comparison doesn't leak timing information:
$expected = get_order_nonce($_GET['order_id'] ?? '');
if (!$expected || !hash_equals($expected, $_GET['nonce'] ?? '')) {
http_response_code(403);
exit;
}
If your provider signs its callbacks (an HMAC header, say), verify that as well. The nonce doesn't replace a signature. It just means a guessed order ID gets an attacker nowhere.
4. Check the amount, and fail closed
A genuine callback for the wrong amount is still wrong. Compare what was paid with what the order costs, in the same currency.
If you have to convert currencies for that comparison and the conversion call fails, reject the callback. It's tempting to write "if we can't convert, just accept it", but at that point your amount check isn't checking anything.
$paid = convert($paidAmount, $paidCurrency, $order->currency);
if ($paid === null) {
// Conversion failed. Don't guess, let the provider retry.
http_response_code(503);
exit;
}
// Allow a small tolerance (0.5% here) for rounding and rate drift.
if ($paid < $order->total * 0.995) {
mark_underpaid($order);
exit;
}
5. Expect the same callback twice
Networks retry. Providers retry. Make the handler idempotent:
if ($order->status === 'paid') {
http_response_code(200); // already handled
exit;
}
Better still, wrap the update in a transaction or put a unique constraint on the payment ID, so two callbacks arriving at the same moment can't both fulfil the order.
6. Log everything, say nothing
Log the raw callback (minus secrets) so you can answer "did they actually pay?" six weeks from now. Don't echo details back in the response. A plain 200 or 403 is plenty.
Checklist
- The redirect page never changes order state
- The callback checks a per-order secret with
hash_equals - Signatures are verified if the provider offers them
- Amount and currency are checked, and failed conversions are rejected
- The handler is idempotent
- Raw callbacks are logged
Disclosure: I work on WerninBill, a card and crypto payment gateway. Our PHP starter integration uses the same per-order nonce and fail-closed amount check, but nothing above is specific to us. It applies to pretty much any gateway that calls a URL on your server.
Top comments (0)