Crypto payment webhooks: verification, retries and idempotency
Webhooks are how your backend learns that a payment arrived. They are also where most integration bugs live. This guide describes how ChainPay delivers them and how to handle them safely.
What you receive
ChainPay sends a POST with a JSON body to your callbackUrl. After a TRON sweep the payload looks like this:
{
"status": "confirmed",
"externalReference": "order-123",
"chain": "tron",
"tokenSymbol": "USDT",
"tokenAddress": "T…",
"isNative": false,
"amount": "25.000000",
"txHash": "802999e236cf8f…",
"fee": "1.25",
"valueForwarded": "23.75",
"txidOut": "b1c2…"
}
statusispending(only if you enabled early notifications withpending: true) orconfirmed.tokenSymbol,tokenAddressandisNativedescribe the asset that actually arrived, which matters for multi-token addresses.fee,valueForwardedandtxidOut(the settlement transaction) are present only when the sweep result is known at notification time, which is currently the case on TRON. On EVM chains theconfirmedwebhook is sent at the confirmation threshold and omits these fields.
1. Verify the sender
Webhooks are not cryptographically signed. Put a long random secret in the query string of callbackUrl and compare it in constant time before doing anything else. Never trust amount or externalReference from an unauthenticated request.
2. Respond fast with a 2xx
Return 200 as soon as the event is stored. ChainPay waits 10 seconds for a response, treats any non-2xx status or timeout as a failure and does not follow redirects. Do slow work (emails, fulfilment) in a queue.
3. Expect retries and duplicates
A failed delivery is retried up to 8 times with exponential backoff, starting at 30 seconds and capped at one hour. Delivery is at least once, so the same event can arrive more than once. Use txHash together with status as the idempotency key and put a unique index on it in your database.
import express from 'express';
import crypto from 'node:crypto';
const app = express();
app.use(express.json());
const SECRET = process.env.WEBHOOK_SECRET;
const processed = new Set(); // use a database unique index in production
app.post('/crypto-webhook', async (req, res) => {
const given = Buffer.from(String(req.query.token ?? ''));
const expected = Buffer.from(SECRET);
if (given.length !== expected.length || !crypto.timingSafeEqual(given, expected)) {
return res.sendStatus(403);
}
const { status, externalReference, amount, txHash } = req.body;
const key = `${txHash}:${status}`;
if (processed.has(key)) return res.sendStatus(200); // duplicate delivery
if (status === 'confirmed') {
await creditOrder(externalReference, amount); // your logic
}
processed.add(key);
res.sendStatus(200); // any non-2xx makes ChainPay retry
});
app.listen(8080);
4. Credit only on confirmed
pending means the transfer is visible on-chain but can still be reorganised away. Show it in the UI if you want, but release goods only on confirmed.
5. Reconcile with the status API
Webhooks can be missed if your server is down for longer than the retry window. Treat the status API as the source of truth and run a periodic check for orders that are still unpaid:
curl https://api.chainpay.site/deposit-address/ADDRESS_ID/status
curl https://api.chainpay.site/deposit-address/ADDRESS_ID/transactions
6. Check the amount
A payer may send less or more than requested. Compare amount to the order total, decide how to handle underpayments and overpayments, and never assume the amounts match.
Callback URL requirements
callbackUrl must be a public HTTP(S) address. Localhost and private IPs are rejected when the address is created and again at each delivery.