Crypto Payment Callbacks: How to Handle Them Without Double-Crediting
The callback is where most integrations break, and the breakage is always the same shape: the same notification arrives twice and the customer gets credited twice.
Why the same callback arrives more than once
A gateway cannot tell the difference between 'your server did not receive this' and 'your server received it but the reply got lost'. When in doubt it retries, because the alternative — silently dropping a payment notification — is far worse.
So retries are a feature, not a bug. Your handler has to be built for them.
The rule
Processing the same payment notification twice must have exactly the same effect as processing it once. That is all idempotency means here.
The way to get it is to make your own order the source of truth, not the callback. Look the order up, check whether it is already paid, and stop there if it is.
- Find your order using the request_id you generated
- If it is already marked paid — return success immediately, change nothing
- Otherwise credit the customer and mark it paid in one database transaction
- Return success so the gateway stops retrying
Verify before you trust
Your callback URL is reachable by anyone who guesses it. Treat every request as untrusted until it proves otherwise.
Check the merchant credentials in the request. Check that the transaction id belongs to an order you actually created. Never credit based on the amount alone — an attacker can send you any amount they like.
Read the right field
Credit the amount the gateway reports as received, not the amount you originally asked for. Those differ by the identifying decimals, and the difference is real money the customer paid.
Amounts are reported in USD even when the customer paid in Bitcoin or Litecoin. That is deliberate: most shop code reads the amount field and credits it straight to the customer, and handing that code a coin amount is an accounting error nobody notices until the books stop balancing.
Return the right response
Return HTTP 200 with a success body once you have finished. Anything else is read as failure and schedules another attempt.
Do not return success before you have committed the credit. If your database write fails after you replied, the gateway will never retry and the payment is lost from your side.
A worked example of the failure
A customer pays a $100 invoice. Your server is briefly overloaded and the reply times out. The gateway retries thirty seconds later. Your handler, which credits on every call, adds another $100 to the customer's balance.
Nobody notices until reconciliation, and by then the pattern is discoverable: pay once, deliberately time out, get credited twice. The fix is four lines — check whether the order is already paid.
Frequently asked questions
- How many times will a failed callback retry?
- Typically up to eight attempts with an exponential backoff: 30 seconds, then 1, 2, 4 minutes and so on, up to an hour between attempts.
- Should I verify a signature?
- Verify whatever the gateway offers — signature, credentials, or both — and additionally confirm the transaction id matches an order you created. Layered checks cost nothing.
- What if my site was down for the whole retry window?
- The payment is still in your wallet and the invoice is still marked paid. You can look the invoice up by its transaction id and reconcile manually.
- Can I test the callback without spending real money?
- Yes. Create an invoice, then use the gateway's manual reconciliation to attach a payment to it, which triggers the same callback path your production code will see.