Как делать11 октября 2026 г.10 мин чтения
API и интеграцииШаг 4 из 8

Вебхуки: приём, подпись, повторы и идемпотентность

Коротко

Вебхук — HTTP-запрос, которым внешний сервис сам сообщает вашему серверу о событии: платёж прошёл, заказ изменился. Надёжный приёмник проверяет HMAC-подпись по сырому телу и метке времени, сохраняет событие по его id и сразу отвечает 2xx, а обрабатывает событие в фоне. Повторная доставка с тем же id не должна выполнить действие второй раз, а старое событие — перезаписать более новое состояние.

РуководствоУровень: СреднийВебхукиПрактика: ~1–2 ч

После материала вы сможете

  • написать приёмник на Express 5, который получает сырое тело через express.raw и отвечает 200 до начала обработки
  • проверить HMAC-SHA256 подпись через timingSafeEqual и отклонить подделку, изменённое тело и запрос с просроченной меткой времени
  • сохранить событие по webhook-id так, чтобы повторная доставка не выполнила действие дважды, а опоздавшее событие не перезаписало новое состояние
  • покрыть приёмник тестами с собственным отправителем: верная и неверная подпись, просроченная метка, дубль

Проверено на

Node.js 24 LTS (24.18)Express 5.3.0node:crypto (HMAC-SHA256, timingSafeEqual)node:testPostgreSQL 17 (17.11, postgres:17-alpine)

Сверено с документацией: 11 октября 2026 г.

Содержание
  1. 01Вебхук или опрос: что меняется для сервера
  2. 02Приёмник: сырое тело и быстрый ответ 2xx
  3. 03Подпись HMAC и защита от replay
  4. 04Повторы отправителя и идемпотентность по event id
  5. 05Обработка в фоне и порядок событий
  6. 06Проверка: тестовый отправитель и автотесты
  7. 07Локальная отладка, мониторинг и частые ошибки

Вебхук или опрос: что меняется для сервера

Узнать об изменении во внешней системе можно двумя способами. Опрос (polling): ваш сервер регулярно спрашивает API «заказ оплачен?». Вебхук (webhook): внешняя система сама отправляет POST на заранее зарегистрированный адрес вашего сервера, когда событие произошло. Так Stripe сообщает, что банк подтвердил платёж, клиент оспорил списание или продлилась подписка, а GitHub — о пушах, pull request и других событиях репозитория.

Опрос

  • Инициатива у вас: запрос по расписанию
  • Задержка равна интервалу опроса
  • Частые запросы упираются в лимиты API
  • Не нужен публичный адрес

Вебхук

  • Инициатива у провайдера: запрос в момент события
  • Почти без задержки и без лишних запросов
  • Нужен публичный HTTPS-адрес и проверка подлинности
  • Событие может прийти дважды, не по порядку или потеряться

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

Провайдер подписал и отправил POSTПроверка подписи и меткиЗапись в журнал по idОтвет 200Обработка в фоне
Путь события в приёмнике из этой статьи: ответ уходит до бизнес-логики.

Приёмник: сырое тело и быстрый ответ 2xx

Материал для backend-разработчиков, знакомых с Express и фоновыми задачами. Код проверен на Node.js 24.18 и Express 5.3.0; из внешних пакетов нужен только express, криптография — встроенный модуль node:crypto, тесты — встроенный node:test.

Провайдер считает подпись по точным байтам тела запроса. Если тело сначала разобрать через express.json(), а потом собрать обратно JSON.stringify(req.body), пробелы, порядок ключей или экранирование символов могут отличаться — и подпись не совпадёт. Документация Stripe прямо требует сырое тело: любое его изменение ломает проверку. Поэтому на маршрут вебхука ставим express.raw({ type: "application/json" }) — в req.body окажется Buffer. В Express 5, если Content-Type не совпал, req.body остаётся undefined; на этот случай приёмник отвечает 415.

Второе требование — скорость. Stripe требует вернуть 2xx до любой сложной логики, GitHub ждёт ответа 2xx не дольше 10 секунд и иначе считает доставку неудачной, а спецификация Standard Webhooks советует отправителям таймаут 15–30 секунд. Поэтому обработчик маршрута делает только быстрые шаги:

  1. Проверить подпись и метку времени

    До разбора JSON: неподписанному телу нельзя доверять даже как структуре.

  2. Разобрать JSON

    Только после проверки, из того же Buffer.

  3. Записать событие по id

    insertIfNew вернёт false, если такой id уже был.

  4. Поставить в очередь

    Только новое событие; обработка не блокирует ответ.

  5. Ответить 200

    И на дубль тоже: иначе отправитель продолжит повторять доставку.

bash
mkdir webhook-receiver
cd webhook-receiver
npm init -y
npm pkg set type=module
npm pkg set private=true --json
npm pkg delete main scripts.test
npm pkg set scripts.start="node src/server.js" scripts.test="node --test"
npm install express
mkdir src scripts test
Проект: ES-модули, единственная зависимость — express
src/app.js
import express from "express";
import { verifyWebhook, WebhookVerificationError } from "./signature.js";

export function createApp({ secrets, store, queue, logger = console, now = () => Date.now() }) {
  const app = express();
  app.disable("x-powered-by");

  // Raw body only on this route: the signature covers the exact bytes
  app.post(
    "/webhooks/payments",
    express.raw({ type: "application/json", limit: "256kb" }),
    async (req, res) => {
      if (!Buffer.isBuffer(req.body)) {
        return res.status(415).json({ error: "Expected application/json" });
      }
      let meta;
      try {
        meta = verifyWebhook({ secrets, headers: req.headers, rawBody: req.body, now: now() });
      } catch (error) {
        if (!(error instanceof WebhookVerificationError)) throw error;
        logger.warn(`webhook rejected: ${error.message}`);
        return res.status(400).json({ error: "Invalid webhook signature" });
      }

      let event;
      try {
        event = JSON.parse(req.body.toString("utf8"));
      } catch {
        return res.status(400).json({ error: "Body is not valid JSON" });
      }

      // Save first, then answer; a repeated delivery is not queued again
      const isNew = await store.insertIfNew({ id: meta.id, type: event.type, payload: event });
      if (isNew) queue.enqueue(meta.id);
      logger.info(`webhook ${meta.id} ${event.type}: ${isNew ? "queued" : "duplicate"}`);
      res.status(200).json({ received: true, duplicate: !isNew });
    },
  );

  // The rest of the API parses JSON as usual
  app.use(express.json());
  return app;
}
Маршрут вебхука: сырое тело, проверка, запись, очередь и ответ — без бизнес-логики
src/queue.js
// The simplest in-process background queue: the HTTP response does not wait
// for processing. Tasks are lost if the process dies, which is why the event
// is saved to the store first. In production use BullMQ or similar.
export function createBackgroundQueue(handler, logger = console) {
  const running = new Set();
  return {
    enqueue(id) {
      const task = new Promise((resolve) => setImmediate(resolve))
        .then(() => handler(id))
        .catch((error) => logger.error(`event ${id} failed: ${error.message}`))
        .finally(() => running.delete(task));
      running.add(task);
    },
    async idle() {
      while (running.size > 0) await Promise.all(running);
    },
  };
}
Учебная очередь в памяти процесса: ответ не ждёт обработки; для продакшена — BullMQ или аналог

Подпись HMAC и защита от replay

HMAC (hash-based message authentication code) — код подлинности сообщения на общем секрете. Секрет знают только провайдер и вы. Провайдер считает HMAC-SHA256 от подписываемой строки и кладёт результат в заголовок, вы считаете то же самое и сравниваете. Без секрета подпись не подделать, а изменение хотя бы одного байта тела даёт другую подпись. Форматы у провайдеров разные:

Stripe

Заголовки
Stripe-Signature: t=…,v1=…
Что подписывается
метка t, точка, сырое тело
Кодировка и ключ
hex; ключ — строка секрета whsec_… целиком; учитывать только схему v1

GitHub

Заголовки
X-Hub-Signature-256: sha256=…
Что подписывается
только сырое тело
Кодировка и ключ
hex; метки времени в подписи нет, id доставки — в X-GitHub-Delivery

Standard Webhooks

Заголовки
webhook-id, webhook-timestamp, webhook-signature: v1,…
Что подписывается
id.timestamp.тело
Кодировка и ключ
base64; ключ — base64-декодированная часть после whsec_; несколько подписей через пробел

Наш приёмник принимает формат Standard Webhooks — открытой спецификации, которую можно взять за основу и для собственных исходящих вебхуков. Функцию sign мы сверили с эталонной библиотекой standardwebhooks 1.1.1, а строку Stripe в таблице — с генератором тестового заголовка из пакета stripe 23.0.0. Для кода статьи эти SDK не нужны. Обратите внимание на разницу ключей: Stripe использует строку whsec_… как есть, а Standard Webhooks декодирует base64 после префикса — перепутать их значит получить «неверную подпись» на каждом запросе.

src/signature.js
import { createHmac, timingSafeEqual } from "node:crypto";

export const TOLERANCE_SECONDS = 5 * 60;

export class WebhookVerificationError extends Error {}

// Secret in the Standard Webhooks format: "whsec_" + base64 of random bytes
function keyFrom(secret) {
  return Buffer.from(secret.replace(/^whsec_/, ""), "base64");
}

// Signed content: "<id>.<timestamp>.<raw body>", HMAC-SHA256, base64
export function sign(secret, id, timestamp, rawBody) {
  return createHmac("sha256", keyFrom(secret))
    .update(`${id}.${timestamp}.`)
    .update(rawBody)
    .digest("base64");
}

export function verifyWebhook({ secrets, headers, rawBody, now = Date.now() }) {
  const id = headers["webhook-id"];
  const timestamp = headers["webhook-timestamp"];
  const signatureHeader = headers["webhook-signature"];
  if (!id || !timestamp || !signatureHeader) {
    throw new WebhookVerificationError("Missing webhook headers");
  }
  if (!/^\d{1,12}$/.test(timestamp)) {
    throw new WebhookVerificationError("Invalid timestamp");
  }
  // Replay protection: an intercepted request is useless after the tolerance window
  const age = Math.floor(now / 1000) - Number(timestamp);
  if (Math.abs(age) > TOLERANCE_SECONDS) {
    throw new WebhookVerificationError(`Timestamp outside tolerance (${age}s)`);
  }

  // The header may hold several signatures separated by spaces (secret rotation)
  const received = signatureHeader
    .split(" ")
    .filter((part) => part.startsWith("v1,"))
    .map((part) => Buffer.from(part.slice(3), "base64"));

  for (const secret of secrets) {
    const expected = Buffer.from(sign(secret, id, timestamp, rawBody), "base64");
    for (const candidate of received) {
      // timingSafeEqual throws on different lengths, so compare lengths first
      if (candidate.length === expected.length && timingSafeEqual(candidate, expected)) {
        return { id, timestamp: Number(timestamp) };
      }
    }
  }
  throw new WebhookVerificationError("No matching signature");
}

// GitHub format: X-Hub-Signature-256: sha256=<hex HMAC of the raw body>
export function verifyGithubSignature(secret, rawBody, header = "") {
  const expected = Buffer.from(
    "sha256=" + createHmac("sha256", secret).update(rawBody).digest("hex"),
  );
  const received = Buffer.from(header);
  return received.length === expected.length && timingSafeEqual(received, expected);
}
Проверка подписи: метка времени, несколько подписей, сравнение за постоянное время; отдельно — формат GitHub
  • Сравнение через timingSafeEqual, а не ===: документация Node.js описывает его как сравнение за постоянное время, пригодное для HMAC. Функция бросает ошибку при разной длине буферов, поэтому длину проверяем заранее — длина HMAC-SHA256 всегда 32 байта и секретом не является.
  • Метка времени входит в подписываемую строку, поэтому подменить её, не сломав подпись, нельзя. Библиотеки Stripe по умолчанию допускают расхождение 5 минут и предупреждают: допуск 0 в них отключает проверку свежести. Мы отклоняем и слишком старые, и «будущие» метки; часы сервера синхронизируют по NTP.
  • Внутри окна допуска перехваченный запрос можно повторить — это закрывает журнал событий по id из следующего раздела. У GitHub метки в подписи нет, и его документация предлагает защищаться от replay именно уникальным X-GitHub-Delivery.
  • Ротация секрета: список secrets принимает текущий и прежний. Stripe позволяет держать старый секрет активным до 24 часов и на это время подписывает событие каждым из них.
  • На неверную подпись — 400 без подробностей; причину пишем только в свой лог.

Повторы отправителя и идемпотентность по event id

Отправитель считает доставку успешной только при ответе 2xx. Ошибка, таймаут или редирект — повод для повтора, и политики у провайдеров разные:

Stripe

Автоматические повторы
В live-режиме — до трёх дней с экспоненциальной задержкой; в песочнице — три попытки за несколько часов
Что ещё важно
Ответ 3xx считается ошибкой; каждая попытка получает новую метку и подпись; ручная переотправка из Dashboard — до 15 дней, через CLI — до 30 дней

GitHub

Автоматические повторы
Нет: неудачные доставки автоматически не повторяются
Что ещё важно
Ответ 2xx — в течение 10 секунд; переотправка вручную или скриптом через REST API; при ней X-GitHub-Delivery тот же

Standard Webhooks

Автоматические повторы
Расписание на несколько дней с экспоненциальной задержкой и случайным разбросом
Что ещё важно
Ответ 410 Gone означает «отключите адрес»; на 429, 502, 504 отправитель сбавляет темп

Отсюда главное следствие: одно событие может прийти дважды. Например, вы обработали его, но ответ потерялся или опоздал — отправитель повторит доставку. Stripe прямо пишет, что эндпоинт иногда получает одно событие больше одного раза, и советует записывать id обработанных событий и пропускать уже записанные. Это и есть идемпотентность приёмника: повтор не меняет результат. На повтор отвечаем 200 с пометкой duplicate, а не ошибкой — иначе отправитель продолжит попытки.

src/event-store.js
// In-memory "inbox" for the demo. In production: a table with a unique
// event id (see the SQL below); the logic stays the same.
export class EventStore {
  #events = new Map();

  // false means the id is already known: a repeated delivery
  async insertIfNew({ id, type, payload }) {
    if (this.#events.has(id)) return false;
    this.#events.set(id, {
      id,
      type,
      payload,
      status: "received",
      attempts: 0,
      receivedAt: new Date().toISOString(),
    });
    return true;
  }

  async get(id) {
    return this.#events.get(id) ?? null;
  }

  async markProcessed(id, result) {
    Object.assign(this.#events.get(id), { status: "processed", result });
  }

  async markFailed(id, error) {
    const event = this.#events.get(id);
    Object.assign(event, { status: "failed", attempts: event.attempts + 1, error: error.message });
  }

  async listUnprocessed() {
    return [...this.#events.values()].filter((event) => event.status !== "processed");
  }
}
Журнал входящих событий в памяти — учебная замена таблицы с уникальным id

В продакшене журнал хранят в базе. Уникальность гарантирует первичный ключ, а INSERT … ON CONFLICT DO NOTHING RETURNING одним запросом отвечает на вопрос «новое ли событие»: по документации PostgreSQL возвращаются только реально вставленные строки. Вариант «сначала SELECT, потом INSERT» не годится: две параллельные доставки на разных экземплярах сервера пройдут проверку одновременно.

inbox.sql
CREATE TABLE webhook_events (
  provider     text        NOT NULL,
  id           text        NOT NULL,  -- webhook-id / event id from the provider
  type         text        NOT NULL,
  payload      jsonb       NOT NULL,
  status       text        NOT NULL DEFAULT 'received'
               CHECK (status IN ('received', 'processed', 'failed')),
  attempts     int         NOT NULL DEFAULT 0,
  received_at  timestamptz NOT NULL DEFAULT now(),
  processed_at timestamptz,
  PRIMARY KEY (provider, id)
);

CREATE TABLE orders (
  id               text PRIMARY KEY,
  status           text NOT NULL,
  provider_version int  NOT NULL DEFAULT 0
);
INSERT INTO orders (id, status) VALUES ('ord_1042', 'new');

-- Receiver: one row returned = new event, zero rows = repeated delivery
INSERT INTO webhook_events (provider, id, type, payload)
VALUES ('payments', 'msg_1', 'order.status_changed', '{"data": {"id": "ord_1042"}}')
ON CONFLICT (provider, id) DO NOTHING
RETURNING id;

-- Processor: an older version does not overwrite a newer one
UPDATE orders SET status = 'refunded', provider_version = 2
WHERE id = 'ord_1042' AND provider_version < 2;
UPDATE orders SET status = 'paid', provider_version = 1
WHERE id = 'ord_1042' AND provider_version < 1;
Таблица журнала, вставка с защитой от дубля и обновление заказа только более новой версией
terminal
$ docker run -d --rm --name kb-pg-webhooks -e POSTGRES_PASSWORD=test postgres:17-alpine
$ docker exec -i kb-pg-webhooks psql -U postgres < inbox.sql
CREATE TABLE
CREATE TABLE
INSERT 0 1
  id
-------
 msg_1
(1 row)

INSERT 0 1
UPDATE 1
UPDATE 0

$ docker exec kb-pg-webhooks psql -U postgres -c "INSERT INTO webhook_events (provider, id, type, payload) VALUES ('payments', 'msg_1', 'order.status_changed', '{\"data\": {\"id\": \"ord_1042\"}}') ON CONFLICT (provider, id) DO NOTHING RETURNING id;" -c "SELECT * FROM orders;"
 id
----
(0 rows)

INSERT 0 0
    id    |  status  | provider_version
----------+----------+------------------
 ord_1042 | refunded |                2
(1 row)
Реальный вывод на PostgreSQL 17.11: первая вставка вернула id, повторная — 0 строк; старая версия заказа не перезаписала новую

Ещё один нюанс из документации Stripe: иногда провайдер создаёт два разных события об одном изменении. Их id различаются, поэтому для критичных действий дополнительно проверяют пару «id объекта + тип события» или версию объекта — об этом следующий раздел.

Обработка в фоне и порядок событий

Приёмник уже записал событие со статусом received. Фоновый обработчик берёт id, выполняет бизнес-действие и ставит processed или failed. Запись до постановки в очередь — ключевой момент: учебная очередь в памяти пропадёт при падении процесса, а строка в журнале останется, и reprocessPending подберёт её по расписанию. Это паттерн inbox — зеркало transactional outbox из материала о событийной архитектуре. В продакшене вместо очереди в памяти берут BullMQ или аналог (см. урок о фоновых задачах) либо воркер, который сам выбирает необработанные строки из таблицы.

src/processor.js
export function createProcessor({ store, orders, logger = console }) {
  async function processEvent(id) {
    const event = await store.get(id);
    if (!event || event.status === "processed") return;
    try {
      if (event.type === "order.status_changed") {
        const { id: orderId, status, version } = event.payload.data;
        const applied = await orders.applyIfNewer(orderId, status, version);
        await store.markProcessed(id, applied ? "applied" : "stale");
        logger.info(`event ${id}: order ${orderId} -> ${status} v${version} (${applied ? "applied" : "stale, skipped"})`);
      } else {
        await store.markProcessed(id, "ignored"); // a type we do not handle
      }
    } catch (error) {
      await store.markFailed(id, error);
      throw error;
    }
  }

  // Run on a schedule: picks up events that failed or were lost from the queue
  async function reprocessPending() {
    for (const event of await store.listUnprocessed()) await processEvent(event.id);
  }

  return { processEvent, reprocessPending };
}
Обработчик: применяет событие, помечает результат, умеет перезапустить необработанные

Теперь порядок. Stripe не гарантирует доставку событий в порядке их возникновения и предупреждает: поле created хранит секунды, у разных событий оно может совпадать, поэтому по нему нельзя ни упорядочивать события, ни искать дубли. Рабочих подхода два:

  • Версия объекта. Если провайдер присылает монотонно растущую версию или номер изменения, применяем событие, только когда оно новее сохранённого состояния. В примере у вымышленного платёжного провайдера это поле data.version, в SQL — условие provider_version < $2 в UPDATE.
  • Событие как сигнал. Не доверяем данным из тела, а перечитываем текущее состояние объекта через API провайдера. Stripe предлагает этот путь для недостающих объектов, а в «тонких» событиях (thin events) он основной: SDK получает актуальный объект отдельным запросом.
src/orders.js
// Order state on our side. version is the object version from the provider:
// it grows with every change, so an old event never overwrites a newer state.
export class OrderStore {
  #orders = new Map();

  async applyIfNewer(id, status, version) {
    const current = this.#orders.get(id);
    if (current && current.version >= version) return false;
    this.#orders.set(id, { status, version });
    return true;
  }

  async get(id) {
    return this.#orders.get(id) ?? null;
  }
}
Состояние заказа: событие со старой версией не перезаписывает новое
src/server.js
import { createApp } from "./app.js";
import { EventStore } from "./event-store.js";
import { OrderStore } from "./orders.js";
import { createProcessor } from "./processor.js";
import { createBackgroundQueue } from "./queue.js";

// Current secret plus, during rotation, the previous one: "whsec_new,whsec_old"
const secrets = (process.env.WEBHOOK_SECRETS ?? "").split(",").filter(Boolean);
if (secrets.length === 0) {
  console.error("Set WEBHOOK_SECRETS: the signing secret from the provider dashboard");
  process.exit(1);
}

const store = new EventStore();
const orders = new OrderStore();
const processor = createProcessor({ store, orders });
const queue = createBackgroundQueue(processor.processEvent);
const app = createApp({ secrets, store, queue });

// Safety net: once a minute, retry events that failed in the background
setInterval(() => processor.reprocessPending().catch(console.error), 60_000).unref();

const port = Number(process.env.PORT ?? 4242);
app.listen(port, "127.0.0.1", (error) => {
  if (error) {
    console.error(`Cannot start server: ${error.code}`);
    process.exit(1);
  }
  console.log(`Webhook receiver: http://127.0.0.1:${port}/webhooks/payments`);
});
Точка входа: секреты из окружения, очередь, повторная обработка раз в минуту

Проверка: тестовый отправитель и автотесты

Чтобы не зависеть от реального провайдера, напишем отправителя, который подписывает тело ровно так же. Он нужен и тестам, и для ручной проверки. Таймаут на fetch — через AbortSignal.timeout, как советует спецификация для отправителей.

src/sender.js
import { randomUUID } from "node:crypto";
import { sign } from "./signature.js";

// Test sender: signs the body exactly the way the provider does
export async function sendWebhook({
  url,
  secret,
  event,
  id = `msg_${randomUUID()}`,
  timestamp = Math.floor(Date.now() / 1000),
  body = JSON.stringify(event),
  signature = `v1,${sign(secret, id, timestamp, body)}`,
}) {
  const res = await fetch(url, {
    method: "POST",
    headers: {
      "content-type": "application/json",
      "webhook-id": id,
      "webhook-timestamp": String(timestamp),
      "webhook-signature": signature,
    },
    body,
    signal: AbortSignal.timeout(15_000),
  });
  return { status: res.status, body: await res.json() };
}
Тестовый отправитель: id, метка времени и подпись по умолчанию, но любое поле можно подменить
scripts/send-test-event.js
import { sendWebhook } from "../src/sender.js";

// node scripts/send-test-event.js <orderId> <status> <version> [eventId]
const [orderId = "ord_1042", status = "paid", version = "1", id] = process.argv.slice(2);
const result = await sendWebhook({
  url: process.env.WEBHOOK_URL ?? "http://127.0.0.1:4242/webhooks/payments",
  secret: process.env.WEBHOOK_SECRET,
  id,
  event: { type: "order.status_changed", data: { id: orderId, status, version: Number(version) } },
});
console.log(result.status, JSON.stringify(result.body));
Ручная отправка события из терминала
terminal
# terminal 1
$ export WEBHOOK_SECRET="whsec_$(node -e "console.log(require('node:crypto').randomBytes(32).toString('base64'))")"
$ WEBHOOK_SECRETS="$WEBHOOK_SECRET" npm start

> webhook-receiver@1.0.0 start
> node src/server.js

Webhook receiver: http://127.0.0.1:4242/webhooks/payments
webhook msg_002 order.status_changed: queued
event msg_002: order ord_1042 -> refunded v2 (applied)
webhook msg_001 order.status_changed: queued
event msg_001: order ord_1042 -> paid v1 (stale, skipped)
webhook msg_001 order.status_changed: duplicate
webhook rejected: No matching signature

# terminal 2 (the same WEBHOOK_SECRET)
$ node scripts/send-test-event.js ord_1042 refunded 2 msg_002
200 {"received":true,"duplicate":false}
$ node scripts/send-test-event.js ord_1042 paid 1 msg_001
200 {"received":true,"duplicate":false}
$ node scripts/send-test-event.js ord_1042 paid 1 msg_001
200 {"received":true,"duplicate":true}
$ curl -s -X POST http://127.0.0.1:4242/webhooks/payments -H 'content-type: application/json' \
    -H 'webhook-id: msg_fake' -H "webhook-timestamp: $(date +%s)" \
    -H 'webhook-signature: v1,AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=' \
    -d '{"type":"order.status_changed","data":{"id":"ord_1042","status":"paid","version":99}}' -w '  HTTP %{http_code}\n'
{"error":"Invalid webhook signature"}  HTTP 400
Реальный запуск: событие v2 применено, опоздавшее v1 пропущено как устаревшее, дубль не поставлен в очередь, поддельная подпись — 400

Автотесты поднимают настоящий HTTP-сервер на свободном порту и отправляют запросы тем же sendWebhook. Секреты в тестах генерируются случайно — реальные ключи не нужны.

test/webhooks.test.js
import assert from "node:assert/strict";
import { randomBytes } from "node:crypto";
import { afterEach, beforeEach, describe, it } from "node:test";
import { createApp } from "../src/app.js";
import { EventStore } from "../src/event-store.js";
import { OrderStore } from "../src/orders.js";
import { createProcessor } from "../src/processor.js";
import { createBackgroundQueue } from "../src/queue.js";
import { sendWebhook } from "../src/sender.js";
import { sign, verifyGithubSignature } from "../src/signature.js";

const newSecret = () => `whsec_${randomBytes(32).toString("base64")}`;
const SECRET = newSecret();
const silent = { info() {}, warn() {}, error() {} };
const paid = (version = 1) => ({
  type: "order.status_changed",
  data: { id: "ord_1042", status: "paid", version },
});

let server, url, store, orders, queue, processor;

async function start({ secrets = [SECRET], slowMs = 0 } = {}) {
  store = new EventStore();
  orders = new OrderStore();
  processor = createProcessor({ store, orders, logger: silent });
  const handle = async (id) => {
    if (slowMs) await new Promise((resolve) => setTimeout(resolve, slowMs));
    await processor.processEvent(id);
  };
  queue = createBackgroundQueue(handle, silent);
  const app = createApp({ secrets, store, queue, logger: silent });
  await new Promise((resolve) => (server = app.listen(0, "127.0.0.1", resolve)));
  url = `http://127.0.0.1:${server.address().port}/webhooks/payments`;
}

beforeEach(() => start());
afterEach(() => new Promise((resolve) => server.close(resolve)));

describe("signature", () => {
  it("accepts a valid signature and processes the event in the background", async () => {
    const res = await sendWebhook({ url, secret: SECRET, event: paid() });
    assert.equal(res.status, 200);
    assert.deepEqual(res.body, { received: true, duplicate: false });
    await queue.idle();
    assert.deepEqual(await orders.get("ord_1042"), { status: "paid", version: 1 });
  });

  it("rejects a signature made with another secret", async () => {
    const res = await sendWebhook({ url, secret: newSecret(), event: paid() });
    assert.equal(res.status, 400);
    assert.equal((await store.listUnprocessed()).length, 0);
  });

  it("rejects a body changed after signing", async () => {
    const id = "msg_tampered";
    const timestamp = Math.floor(Date.now() / 1000);
    const original = await sendWebhook({ url, secret: SECRET, id: "msg_original", event: paid() });
    assert.equal(original.status, 200);
    const signature = `v1,${sign(SECRET, id, timestamp, JSON.stringify(paid()))}`;
    const body = JSON.stringify({ ...paid(), data: { id: "ord_1042", status: "refunded", version: 9 } });
    const res = await sendWebhook({ url, secret: SECRET, id, timestamp, body, signature });
    assert.equal(res.status, 400);
  });

  it("rejects an expired timestamp even with a valid signature (replay)", async () => {
    const tenMinutesAgo = Math.floor(Date.now() / 1000) - 600;
    const res = await sendWebhook({ url, secret: SECRET, event: paid(), timestamp: tenMinutesAgo });
    assert.equal(res.status, 400);
  });

  it("accepts the old secret during rotation", async () => {
    await new Promise((resolve) => server.close(resolve));
    const oldSecret = newSecret();
    await start({ secrets: [newSecret(), oldSecret] });
    const res = await sendWebhook({ url, secret: oldSecret, event: paid() });
    assert.equal(res.status, 200);
  });

  it("checks the GitHub format with the test values from the docs", () => {
    const header = "sha256=757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17";
    assert.equal(verifyGithubSignature("It's a Secret to Everybody", "Hello, World!", header), true);
    assert.equal(verifyGithubSignature("wrong secret", "Hello, World!", header), false);
    assert.equal(verifyGithubSignature("It's a Secret to Everybody", "Hello, World!", "sha256=00"), false);
  });
});

describe("delivery", () => {
  it("does not process a repeated delivery twice", async () => {
    let calls = 0;
    const applyIfNewer = orders.applyIfNewer.bind(orders);
    orders.applyIfNewer = (...args) => { calls++; return applyIfNewer(...args); };

    const first = await sendWebhook({ url, secret: SECRET, id: "msg_dup", event: paid() });
    // The sender retries: same id, new timestamp and signature
    const retry = await sendWebhook({ url, secret: SECRET, id: "msg_dup", event: paid() });
    await queue.idle();
    assert.equal(first.body.duplicate, false);
    assert.equal(retry.status, 200);
    assert.equal(retry.body.duplicate, true);
    assert.equal(calls, 1);
  });

  it("answers before the slow processing finishes", async () => {
    await new Promise((resolve) => server.close(resolve));
    await start({ slowMs: 300 });
    const started = performance.now();
    const res = await sendWebhook({ url, secret: SECRET, event: paid() });
    const elapsed = performance.now() - started;
    assert.equal(res.status, 200);
    assert.ok(elapsed < 300, `answered in ${elapsed.toFixed(0)} ms`);
    assert.equal(await orders.get("ord_1042"), null); // not processed yet
    await queue.idle();
    assert.equal((await orders.get("ord_1042")).status, "paid");
  });

  it("ignores an older event that arrives after a newer one", async () => {
    const refunded = { type: "order.status_changed", data: { id: "ord_1042", status: "refunded", version: 2 } };
    await sendWebhook({ url, secret: SECRET, id: "msg_v2", event: refunded });
    await queue.idle();
    await sendWebhook({ url, secret: SECRET, id: "msg_v1", event: paid(1) });
    await queue.idle();
    assert.deepEqual(await orders.get("ord_1042"), { status: "refunded", version: 2 });
    assert.equal((await store.get("msg_v1")).result, "stale");
  });

  it("keeps a failed event and processes it on the next pass", async () => {
    const applyIfNewer = orders.applyIfNewer.bind(orders);
    orders.applyIfNewer = async () => { throw new Error("database is down"); };
    await sendWebhook({ url, secret: SECRET, id: "msg_fail", event: paid() });
    await queue.idle();
    assert.equal((await store.get("msg_fail")).status, "failed");

    orders.applyIfNewer = applyIfNewer; // the database is back
    await processor.reprocessPending();
    assert.equal((await store.get("msg_fail")).status, "processed");
    assert.equal((await orders.get("ord_1042")).status, "paid");
  });

  it("rejects a body without the JSON content type", async () => {
    const res = await fetch(url, { method: "POST", headers: { "content-type": "text/plain" }, body: "hi" });
    assert.equal(res.status, 415);
  });
});
11 тестов: подпись, подмена тела, просроченная метка, ротация, формат GitHub, дубль, скорость ответа, порядок, повторная обработка
terminal
> webhook-receiver@1.0.0 test
> node --test

▶ signature
  ✔ accepts a valid signature and processes the event in the background (39.7763ms)
  ✔ rejects a signature made with another secret (6.3364ms)
  ✔ rejects a body changed after signing (7.3984ms)
  ✔ rejects an expired timestamp even with a valid signature (replay) (3.4191ms)
  ✔ accepts the old secret during rotation (3.7364ms)
  ✔ checks the GitHub format with the test values from the docs (1.0054ms)
✔ signature (62.3897ms)
▶ delivery
  ✔ does not process a repeated delivery twice (4.4402ms)
  ✔ answers before the slow processing finishes (309.4118ms)
  ✔ ignores an older event that arrives after a newer one (9.2903ms)
  ✔ keeps a failed event and processes it on the next pass (2.8013ms)
  ✔ rejects a body without the JSON content type (3.1035ms)
✔ delivery (329.5465ms)
ℹ tests 11
ℹ suites 2
ℹ pass 11
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 550.4084
Реальный вывод npm test на Node.js 24.18
  • Тест на просроченную метку проверяет именно защиту от replay: подпись верная, но запрос десятиминутной давности отклонён. Если убрать проверку времени из verifyWebhook, падает ровно этот тест — мы проверили.
  • Тест скорости: обработка искусственно занимает 300 мс, а ответ приходит раньше, и в момент ответа заказ ещё не изменён.
  • Тест формата GitHub использует контрольные значения из документации GitHub: секрет It's a Secret to Everybody, тело Hello, World! и ожидаемую подпись.

Локальная отладка, мониторинг и частые ошибки

Провайдер не достучится до localhost. Варианты: туннель, который выдаёт временный публичный HTTPS-адрес (документация Stripe упоминает ngrok); CLI провайдера — stripe listen --forward-to localhost:4242/webhook пересылает события на локальный сервер и печатает секрет подписи для этой сессии, а stripe trigger payment_intent.succeeded создаёт тестовое событие; наконец, собственный отправитель из предыдущего раздела, которому сеть не нужна вовсе. Через туннель не отправляйте реальные данные клиентов и не используйте боевые секреты.

Вебхуки теряются: у GitHub неудачные доставки автоматически не повторяются, и его документация предлагает скрипт по расписанию, который находит неудачные доставки через REST API и запрашивает переотправку. Для платежей по нашему опыту обязательна сверка: периодически запрашивать через API статусы «зависших» заказов, по которым событие так и не пришло. Что стоит отслеживать:

  • число отклонённых подписей — всплеск означает сменённый секрет или попытку подделки;
  • долю дублей — резкий рост говорит, что вы отвечаете слишком медленно и отправитель повторяет;
  • число событий в статусе failed и возраст самого старого необработанного;
  • время ответа приёмника и время от получения до обработки;
  • журнал доставок у провайдера: у Stripe вкладка Event deliveries показывает статусы Delivered, Pending и Failed с кодом ответа.
  • express.json() подключён раньше маршрута вебхука — подпись не сходится или приходит 415
  • Подпись сравнивается через === или в разных кодировках (hex против base64)
  • Секрет Stripe декодирован из base64, а секрет Standard Webhooks — нет (или наоборот)
  • Бизнес-логика выполняется до ответа: таймауты превращаются в повторы, повторы — в дубли
  • Неизвестный тип события возвращает 4xx или 500 — отправитель повторяет его днями; правильно — 200 и пропуск
  • Дубли ищут по метке created, а не по id события
  • Окно допуска метки 0 или в сутки; часы сервера без NTP
  • Секрет лежит в коде или репозитории, а не в переменных окружения, и не ротируется
Как проверено5 · пройденосверено 11 октября 2026 г.
  • запущено, работает

    webhook-receiver (src/app.js, signature.js, event-store.js, orders.js, queue.js, processor.js, server.js, sender.js, scripts/send-test-event.js)

    Временный проект Node.js 24.18 + Express 5.3.0; npm test (node:test) — 11/11 pass против реального HTTP-сервера на порту 0; мутационная проверка: без проверки метки времени падает ровно тест replay

  • запущено, работает

    Ручной запуск npm start + scripts/send-test-event.js + curl с поддельной подписью

    Вывод в статье — фактический (v2 applied, v1 stale, дубль не в очереди, 400 на подделку)

  • запущено, работает

    Совместимость формата подписи

    Только для проверки (в статье не используется): sign() совпадает с standardwebhooks 1.1.1 Webhook.sign, verifyWebhook принимает подпись библиотеки и наоборот; описание Stripe сверено с stripe 23.0.0 webhooks.generateTestHeaderString — v1 = HMAC-SHA256(строка whsec_… как ключ, t.body) hex; verifyGithubSignature проверен на тестовом векторе из документации GitHub

  • запущено, работает

    express.json() до маршрута с express.raw

    Отдельный скрипт на Express 5.3.0: req.body — объект, не Buffer (утверждение в callout)

  • запущено, работает

    inbox.sql

    docker postgres:17-alpine (17.11), контейнер kb-pg-webhooks --rm: первая вставка RETURNING id, повтор — 0 строк (INSERT 0 0), UPDATE со старой версией — UPDATE 0

«Запущено» — код выполнен во временном проекте или контейнере, вывод в статье взят из прогона. «Частично» — проверено с заглушками вместо внешних сервисов. «Не запускалось» — нужны устройство, аккаунт или ключ; шаги сверены с официальной документацией. Файлы проверочного проекта и логи запусков сохранены в архиве InterLAB.

Расскажите о задаче — мы изучим запрос и свяжемся с вами

Есть задача?
Обсудим и оценим — бесплатно.

Расскажите о продукте или проблеме. Предложим архитектуру, срок и стоимость первой версии после изучения задачи.

  • Оценка объёма и рисков до старта
  • NDA — по вашему или нашему шаблону
  • Старт работ в течение недели

Не присылайте через форму пароли, ключи доступа и исходный код — безопасную передачу доступа обсудим отдельно.

Нажимая кнопку, вы соглашаетесь с обработкой персональных данных.