CChainPay
เข้าสู่ระบบ

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…"
}

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.

Related