Webhooks vs Polling for Recharge Status: Which and Why
A recharge does not complete instantly. You submit, it queues, the operator processes it, and somewhere between two seconds and two minutes later it succeeds or fails. The question is how your system learns the outcome.
Polling every five seconds for two minutes is twenty-four requests per transaction. Twenty-three of them return nothing new.
| Polling | Webhook | |
|---|---|---|
| Requests per transaction | up to 24 | 1 |
| At 1,000 recharges a day | 24,000 | 1,000 |
| Customer waits | up to 5s after settling | immediate |
| If your cron stalls | orders sit unresolved | unaffected |
| If the receiver is down | catches up on next poll | retried automatically |
Your callback URL is reachable by anyone who guesses it. Without a signature check, a stranger could POST a fake success and your system would mark an unpaid order delivered.
$body = file_get_contents('php://input'); // raw bytes
$sent = $_SERVER['HTTP_X_SIGNATURE'] ?? '';
$mine = 'sha256=' . hash_hmac('sha256', $body, $secret);
if (!hash_equals($mine, $sent)) {
http_response_code(401);
exit;
}
Two details people get wrong. Hash the raw body — re-encoding parsed JSON changes the bytes and the signature never matches. And use hash_equals, not ==, because a plain comparison leaks the secret one byte at a time to anyone measuring response times.
Webhooks are delivered at least once. A retry can arrive because your 200 was lost on the way back. If your handler credits a customer every time it runs, a retry credits them twice.
UPDATE orders SET status = ?, settled_at = ?
WHERE reference = ? AND status = 'pending'
The AND status = 'pending' is what makes it safe. A second delivery matches no rows and changes nothing.
Most senders time out at ten to fifteen seconds. If your handler sends an SMS or calls another API before replying, you will hit that limit and be retried for something that actually succeeded. Reply 200 first, queue the slow work.
Framework CSRF protection. A webhook sender is a server, not a browser, and carries no CSRF token. If every delivery returns 403, this is why. Exempt the route — withoutMiddleware in Laravel, csrf_exempt in Django, the exclusion list in CodeIgniter.
Webhooks for the normal path, and a status sweep every ten minutes for anything still pending on your side. That combination survives an outage on either end. Webhooks alone leave a gap if your server is down through the whole retry window.