CChainPay
Войти

Вебхуки криптоплатежей: проверка, ретраи и идемпотентность

Вебхуки — способ, которым ваш бэкенд узнаёт о поступлении платежа. Именно здесь чаще всего возникают ошибки интеграции. Этот гайд описывает, как ChainPay их доставляет и как безопасно обрабатывать.

Что вы получаете

ChainPay отправляет POST с JSON на ваш callbackUrl. После перевода в TRON payload выглядит так:

{
  "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. Проверяйте отправителя

Вебхуки не подписываются криптографически. Добавьте длинный случайный секрет в query-строку callbackUrl и сравнивайте его за постоянное время, прежде чем делать что-либо ещё. Никогда не доверяйте amount или externalReference из неаутентифицированного запроса.

2. Быстро отвечайте 2xx

Возвращайте 200 сразу после сохранения события. ChainPay ждёт ответ 10 секунд, любой не-2xx статус или тайм-аут считает неудачей и не следует за редиректами. Медленную работу (письма, выполнение заказа) выносите в очередь.

3. Ожидайте ретраи и дубликаты

Неудачная доставка повторяется до 8 раз с экспоненциальной задержкой: от 30 секунд с ограничением в час. Доставка — минимум один раз, поэтому одно и то же событие может прийти несколько раз. Используйте txHash вместе со status как ключ идемпотентности и добавьте на него уникальный индекс в базе.

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. Зачисляйте только при confirmed

pending означает, что перевод виден в блокчейне, но его ещё могут отменить реорганизацией. Показывайте его в интерфейсе, если хотите, но отдавайте товар только при confirmed.

5. Сверяйтесь через status API

Вебхук можно пропустить, если сервер был недоступен дольше окна ретраев. Считайте status API источником истины и периодически проверяйте заказы, которые остаются неоплаченными:

curl https://api.chainpay.site/deposit-address/ADDRESS_ID/status
curl https://api.chainpay.site/deposit-address/ADDRESS_ID/transactions

6. Проверяйте сумму

Плательщик может отправить меньше или больше запрошенного. Сравнивайте amount с суммой заказа, определите поведение для недоплат и переплат и никогда не предполагайте, что суммы совпадают.

Требования к callback URL

callbackUrl должен быть публичным HTTP(S)-адресом. Localhost и приватные IP отклоняются при создании адреса и снова при каждой доставке.

См. также