DEV Community

WerninBill
WerninBill

Posted on

Stop marking orders as paid just because the customer came back

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();
Enter fullscreen mode Exit fullscreen mode

...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,
]);
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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)