Перейти к основному содержимому
УзнатьРуководстваОбрабатывайте платежи агентов с помощью вебхуков
РУКОВОДСТВО

Схемы вебхуков, о которых разработчики спрашивают чаще всего.

15 минут
КРАТКИЙ ОТВЕТ

Надежный обработчик вебхуков выполняет четыре действия по порядку: проверяет подпись по сырому телу (webhooks.verify в Node, документированный HMAC в другом месте), убирает дубли по идентификатору события, ставит работу в очередь на выполнение в фоновом режиме и возвращает 200. Долгосрочная работа происходит в рабочем процессе, с повторными попытками и идемпотентностью на уровне очереди. Поскольку нет события сбоя, запланированная проверка завершает задания, застрявшие в ожидании оплаты, и согласует их.

ПРЕДВАРИТЕЛЬНЫЕ УСЛОВИЯ

Перед тем как начать.

  • Рабочий профиль агента и секрет подписи из вашей панели управления (Настройки - Вебхуки).
  • Веб-фреймворк с доступом к сырому телу - Express с express.raw, FastAPI, Flask и т.д. Автоматический парсинг JSON middleware нарушает проверку подписи.
  • Очередь задач: BullMQ (Node) или Celery/arq (Python). Вебхук быстро возвращает 200, а очередь выполняет медленную работу.
  • База данных с примитивом upsert (Postgres работает; Redis SET NX также работает для краткосрочной дедупликации).
  • Публичная HTTPS конечная точка - в разработке, ngrok или предварительный просмотр развертывания. Отправитель не будет доставлять на частные URL.
ШАГ 1 ИЗ 4

Подтвердить подпись.

The signature is HMAC-SHA256 over {t}.{rawBody} with your webhook secret, hex-encoded, in the X-Blockchain0x-Signature header (t=<unix>,v1=<hex>), inside a 5-minute replay window. In Node, webhooks.verify from @blockchain0x/node does it and returns a discriminated union; in other languages compute the same HMAC and compare in constant time. Raw-body access matters: if the bytes you sign locally differ from the bytes that arrived, it fails.

TypeScript
import express from "express";
import { webhooks } from "@blockchain0x/node";

const app = express();
// Raw body so the HMAC matches the exact bytes on the wire.
app.use(express.raw({ type: "application/json" }));

app.post("/webhooks/payment", (req, res) => {
  const result = webhooks.verify({
    headers: req.headers,
    rawBody: req.body, // Buffer, raw bytes
    secret: process.env.BLOCKCHAIN0X_WEBHOOK_SECRET!,
  });
  // Discriminated union: branch on ok, no try/catch.
  if (!result.ok) return res.status(400).json({ code: result.code });
  // result.eventType / result.eventId are now set.
  handleEvent(result);
  res.status(200).send("ok");
});
Python
import hmac, hashlib, os, time
from flask import request

SECRET = os.environ["BLOCKCHAIN0X_WEBHOOK_SECRET"].encode()

# In Node, webhooks.verify does this. In Python, verify by hand against the
# documented algorithm: HMAC-SHA256 over "{t}.{rawBody}", 300s replay window.
def verify_signature(raw_body: bytes) -> bool:
    sig = request.headers.get("X-Blockchain0x-Signature", "")
    ts = request.headers.get("X-Blockchain0x-Timestamp", "")
    parts = dict(p.split("=", 1) for p in sig.split(",") if "=" in p)
    t, v1 = parts.get("t", ts), parts.get("v1", sig)
    want = hmac.new(SECRET, t.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(want, v1) and abs(time.time() - int(t)) <= 300
ШАГ 2 ИЗ 4

Сделайте обработчик идемпотентным.

Webhooks retry on any non-2xx response, and the same event will arrive multiple times under load even when nothing has gone wrong. Dedupe on the event's id using a database upsert. If the row already exists, skip; if it does not, insert and proceed. Postgres makes this a single statement.

TypeScript
// Pseudocode for a Postgres-backed dedupe table. Replace with your DB of choice.
async function processEventOnce(eventId: string, body: object) {
  // INSERT ... ON CONFLICT DO NOTHING returns rowCount === 0 on duplicate.
  const inserted = await db.query(
    "INSERT INTO webhook_events(id) VALUES ($1) ON CONFLICT DO NOTHING",
    [eventId],
  );
  if (inserted.rowCount === 0) return;          // Already processed.
  await handleEvent(body);
}
Python
async def process_event_once(event_id: str, body: dict):
    # INSERT ... ON CONFLICT DO NOTHING returns 0 rows on duplicate.
    inserted = await db.execute(
        "INSERT INTO webhook_events (id) VALUES ($1) ON CONFLICT DO NOTHING",
        event_id,
    )
    if inserted == "INSERT 0 0":   # asyncpg-style status
        return                     # Already processed.
    await handle_event(body)
ШАГ 3 ИЗ 4

Добавьте в очередь и быстро верните 200.

Конечная точка вебхука должна отвечать в течение секунды. Все, что медленнее, вызывает тайм-ауты и повторные попытки. Схема такова: проверьте, поставьте в очередь, ответьте. Очередь выполняет фактическую доставку в рабочем процессе с повторными попытками, экспоненциальным откатом и собственной идемпотентностью. BullMQ и Celery оба поддерживают идентификатор для каждой задачи, что предотвращает случайное повторное добавление одного и того же события в очередь.

TypeScript (BullMQ)
// Express handler: verify, enqueue, return 200 fast.
import { Queue } from "bullmq";
import { webhooks } from "@blockchain0x/node";

const paymentQueue = new Queue("payments");

app.post(
  "/webhooks/payment",
  express.raw({ type: "application/json" }),
  async (req, res) => {
    const result = webhooks.verify({
      headers: req.headers,
      rawBody: req.body,
      secret: process.env.BLOCKCHAIN0X_WEBHOOK_SECRET!,
    });
    if (!result.ok) return res.status(400).json({ code: result.code });
    await paymentQueue.add(result.eventType, { raw: req.body.toString() }, {
      jobId: result.eventId,            // Idempotency key.
      removeOnComplete: true,
      attempts: 5,
      backoff: { type: "exponential", delay: 1000 },
    });
    res.status(200).send("ok");
  },
);

// Worker file:
import { Worker } from "bullmq";
new Worker("payments", async (job) => {
  await handleEvent(job.data);
});
Python (Celery)
# Flask handler enqueues to Celery (or arq) and returns 200 quickly.
from celery import Celery
from flask import request

celery = Celery("payments", broker=os.environ["REDIS_URL"])

@celery.task(bind=True, max_retries=5)
def handle_payment_event(self, event_type, raw):
    try:
        process_event_once(event_type, raw)
    except Exception as exc:
        raise self.retry(exc=exc, countdown=2 ** self.request.retries)

@app.post("/webhooks/payment")
def webhook():
    raw = request.get_data()
    if not verify_signature(raw):
        abort(401)
    event_id = request.headers.get("X-Blockchain0x-Event-Id", "")
    event_type = request.headers.get("X-Blockchain0x-Event-Type", "")
    handle_payment_event.apply_async(args=[event_type, raw.decode()], task_id=event_id)
    return "ok", 200

arq следует той же схеме на стороне Python - зарегистрируйте задачу с детерминированным идентификатором задания и позвольте очереди обрабатывать повторные попытки. Ключевое ограничение заключается в том, что сама постановка в очередь должна быть быстрой (один круговой вызов к Redis); никогда не блокируйте вебхук на удаленных вызовах.

ШАГ 4 ИЗ 4

Обработайте платеж, который никогда не поступает.

Нет вебхука сбоя - если покупатель отказывается, событие не приходит, и агент остается застрявшим в 'ожидании_платежа'. Поэтому определите это самостоятельно: выполните запланированную проверку заданий, которые ждали слишком долго, согласуйте с цепочкой с помощью transactions.get на случай, если она действительно завершилась, затем освободите удерживаемые ресурсы, переведите задание в конечное неоплаченное состояние и (если это уместно) представьте результат пользователю.

TypeScript
async function sweepStaleAwaitingPayment() {
  for (const job of await findJobsAwaitingPaymentOlderThan("1h")) {
    // Reconcile against the chain before giving up.
    const tx = job.txHash ? await client.transactions.get(job.txHash) : null;
    if (tx) { markJobPaid(job.id); continue; }   // It actually settled.

    // 1. Release any held resources tied to the job.
    releaseHeldResources(job.id);
    // 2. Move it out of 'awaiting_payment' into a terminal 'unpaid' state.
    markJobUnpaid(job.id);
    // 3. (Optional) Notify the user, with a fresh payment link.
    notifyUser(job.userId, { template: "agent_payment_unpaid", jobId: job.id });
  }
}
Python
# Run on a schedule - there is no failure webhook to wait for.
def sweep_stale_awaiting_payment():
    for job in find_jobs_awaiting_payment_older_than("1h"):
        tx = client.transactions.get(job["tx_hash"]) if job.get("tx_hash") else None
        if tx:
            mark_job_paid(job["id"])   # It actually settled.
            continue

        release_held_resources(job["id"])
        mark_job_unpaid(job["id"])
        notify_user(job["user_id"], template="agent_payment_unpaid", job_id=job["id"])
ОБЩИЕ ОШИБКИ

Пять ошибок, которые пропускают или дублируют события.

Парсинг тела перед проверкой подписи

HMAC должен вычисляться на основе необработанных байтов, которые подписал отправитель. Если ваш фреймворк автоматически разбирает JSON до того, как ваш обработчик запустится, байты, которые вы подписываете локально, не совпадут с байтами, которые подписал отправитель (разные пробелы, порядок ключей, кодировка), и каждая подпись будет выглядеть недействительной. Настройте маршрут для получения необработанного тела (Express: express.raw, Flask: request.get_data), сначала проверьте, затем разбирайте.

Выполнение реальной работы внутри обработчика вебхуков

Вебхуки имеют агрессивные политики повторных попыток. Если ваш обработчик занимает 30 секунд для выполнения работы, таймаут отправителя срабатывает, и вебхук отправляется повторно - теперь у вас есть две доставки в процессе для одного и того же платежа. Всегда: проверяйте, ставьте в очередь, возвращайте 2xx. Фактическая работа выполняется в фоновом рабочем процессе, который может занимать столько времени, сколько ему нужно.

Использование статуса HTTP для передачи бизнес-логики

Если ваш обработчик возвращает 4xx, когда пользователь больше не существует в вашей системе, отправитель рассматривает это как 'недействительный запрос' и прекращает повторные попытки. Если он возвращает 5xx для того же условия, отправитель повторяет попытки бесконечно, и ваша очередь заполняется. Возвращайте 200, как только вы безопасно сохранили событие (или признали его дубликатом); используйте логику очереди, а не статус HTTP, чтобы выразить бизнес-решения.

Идемпотентность по хэшу полезной нагрузки вместо ID события

Два разных события о том же агенте (payment.received и позже payment.sent) имеют разные тела и требуют отдельной обработки. Если ваша дедупликация основана на хеше тела, вы можете удалить одно из них. Дедупликация по X-Blockchain0x-Event-Id (уникальный для каждой доставки), и пусть тип события определяет, что делает ваш обработчик.

Ожидание отдельного события подтверждения

Отгруженные события - payment.received, payment.sent, wallet.deployed и webhook.test - нет отдельного события подтверждения. payment.received срабатывает, когда перевод находится в блоке, что является вашим сигналом для большинства работ. Для чего-то дорогого или необратимого опрашивайте transactions.get и применяйте свой собственный порог подтверждения; не ждите события, которого не существует.

СЛЕДУЮЩИЕ ШАГИ

Как только вебхуки станут надежными.

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

Полная справка на docs.blockchain0x.com. Глоссарий вебхуков: платежное поручение. Продуктовая поверхность: Payment API.

Последний обзор: 2026-05-15. Опубликовано под CC BY 4.0.

Вебхуки, которым вы можете доверять под нагрузкой.

Подписано, повторено, идемпотентное. Бесплатно для начала.