Kravchenko

Web Lab

АудитБлогКонтакты

Kravchenko

Web Lab

Разрабатываем сайты и автоматизацию на современных фреймворках под ключ

Услуги
ЛендингМногостраничныйВизитка
E-commerceБронированиеПортфолио
Навигация
БлогКонтактыАудит
Обратная связь
+7 921 567-11-16
info@kravlab.ru
с 09:00 до 18:00

© 2026 Все права защищены

•

ИП Кравченко Никита Владимирович

•

ОГРНИП: 324784700339743

Политика конфиденциальности

Вебхуки: как спроектировать надёжные входящие и исходящие уведомления — меньше инцидентов и быстрее интеграции

Разработка и технологии17 марта 2026 г.
Вебхуки ускоряют интеграции и снижают стоимость поддержки, если спроектированы правильно: с подписями, повторными доставками, журналами и удобной консолью для партнёров. Разбираем рабочую архитектуру, безопасность, стратегию повторов, схему хранилища и даём готовые примеры кода и чек‑лист запуска.
Вебхуки: как спроектировать надёжные входящие и исходящие уведомления — меньше инцидентов и быстрее интеграции

Оглавление

  • Зачем бизнесу вебхуки
  • Архитектура надёжных исходящих вебхуков
  • Безопасность: подписи, mTLS, allowlist и защита от повторов
    • Формула подписи
  • Порядок доставки, повторы и управление очередями
  • Коды ответов, дедлайны и backoff с джиттером
  • Хранилище событий и попыток: схема и индексы
  • Публичный контракт: версия, типы событий, примеры
  • Консоль для партнёров: логи, повтор, пауза, ротация секретов
  • Входящие вебхуки: мы — приёмник
  • Тестирование и наблюдаемость: локально, сквозные проверки, хаос
  • Набор готовых заголовков и практик
  • Набросок реализации: проверка подписи и воркер доставки (Node.js)
    • Проверка подписи входящего вебхука
    • Отправка исходящих вебхуков с backoff и подписями
  • Чек‑лист запуска вебхуков

Зачем бизнесу вебхуки

Вебхуки — это способ уведомлять внешние системы по HTTP, когда у вас что‑то произошло: платёж прошёл, заказ сменил статус, документ подписан. Это быстрее, чем опрос API, дешевле для вашей инфраструктуры и удобнее для партнёров. Правильно сделанные вебхуки:

  • уменьшают время интеграции (понятный контракт + примеры);
  • снижают нагрузку на API (меньше лишних запросов);
  • сокращают количество инцидентов (подписи, повторы, журнал);
  • дают команде поддержки инструмент «увидеть и переотправить» без участия разработчиков.

Архитектура надёжных исходящих вебхуков

Картина на уровне компонентов:

  1. Источники событий (заказы, оплаты, документы) формируют «событие для внешнего мира». Оно записывается в отдельное хранилище, где ждёт доставки.
  2. Воркер доставки читает события из хранилища, формирует HTTP‑запрос на адрес подписчика, подписывает его, отправляет, обрабатывает ответ и пишет попытку в журнал.
  3. Стратегия повторов и дедлайнов управляет новой попыткой при ошибках.
  4. Консоль для партнёров/поддержки показывает историю доставок и позволяет вручную переотправить, поставить на паузу, сменить секрет.

Важная деталь: событие и запись о необходимости его отправить должны появляться атомарно, чтобы ничего не потерялось при сбоях. Для этого используйте транзакцию в вашей базе и явную запись «событие к доставке» в момент бизнес‑события (не полагайтесь на «потом доберём» в памяти сервиса).

Безопасность: подписи, mTLS, allowlist и защита от повторов

Защита должна быть по слоям:

  • HTTPS с современными шифрами. Никогда не отправляйте вебхуки по HTTP без TLS.
  • Секрет на уровень подписки. У каждого получателя — свой секретный ключ. Ротация ключей без даунтайма: храните активный и следующий.
  • Подпись тела запроса. Самый практичный вариант — HMAC SHA‑256 по канонической строке: timestamp + «.» + raw‑тело. Подпись передаётся в заголовке, например: X-Webhook-Signature: sha256=<hex> и X-Webhook-Timestamp: <unix_sec>.
  • Ограничение по IP (allowlist), если получатель статичен, или взаимная TLS‑аутентификация (mTLS) для критичных интеграций.
  • Защита от повторов и атак воспроизведения: отклоняйте запросы, где timestamp старше, чем, например, 5 минут, и проверяйте уникальный X-Webhook-Id в кэше/БД, чтобы не обрабатывать дубликаты.

Формула подписи

Каноническая строка: <timestamp>.<raw_body>

signature = HMAC_SHA256(secret, canonical)

Значение передаём в hex, чтобы не было сюрпризов с base64.

Порядок доставки, повторы и управление очередями

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

  • каждое событие может прийти несколько раз (поэтому добавляйте id и делайте обработку идемпотентной);
  • порядок между разными типами событий не гарантируется;
  • для одного подписчика можно поддерживать условный порядок «в среднем» (один воркер на подписчика), но не обещайте абсолютный.

Повторы нужны всегда. Типичная стратегия: до N попыток в течение T часов/дней с экспоненциальной задержкой и джиттером (случайной добавкой), чтобы избежать шторма повторов.

Пример интервалов: 1 мин, 5 мин, 30 мин, 2 ч, 12 ч, 24 ч. Останавливаемся по первому успешному 2xx или по достижении предела.

Коды ответов, дедлайны и backoff с джиттером

Как трактовать ответы:

  • 2xx — успех, останавливаем повторы.
  • 410 Gone — подписка недействительна, отключаем и не повторяем.
  • 429 Too Many Requests — уважайте Retry-After (в секундах или дате). Если заголовок отсутствует, используйте свой backoff.
  • 400–499 — обычно ошибка у получателя. Для 400/404 можно ограничить повторы (например, не более 2–3), чтобы не «палить» очередь; для 401/403 — уведомление партнёру, пауза подписки.
  • 5xx/таймаут — повторяем по backoff.

Таймауты отправки: держите жёсткий дедлайн, например 5–10 секунд на соединение и 10–20 секунд на ответ. Длинные таймауты бьют по пропускной способности очереди.

Backoff с джиттером (псевдокод):

function nextDelay(attempt: number): number {
  // базовые интервалы в секундах
  const schedule = [60, 300, 1800, 7200, 43200, 86400];
  const base = schedule[Math.min(attempt, schedule.length - 1)];
  // +/- 20% джиттер
  const jitter = base * 0.2 * (Math.random() - 0.5) * 2;
  return Math.max(30, Math.round(base + jitter));
}

Хранилище событий и попыток: схема и индексы

Нужны две сущности: «событие к доставке» и «попытка доставки».

  • Событие: id, тип, версия, occurred_at (когда произошло у вас), payload (json), endpoint_id (кому отправлять), статус (pending/sent/failed/paused), next_attempt_at, attempts_count, created_at, updated_at.
  • Попытка: id, event_id, попытка №, запрос (заголовки и часть тела), ответ (статус, заголовки, часть тела), время, длительность, ошибка (таймаут/сетевая/иное).

Пример SQL для PostgreSQL:

create table webhook_events (
  id uuid primary key,
  endpoint_id uuid not null,
  event_type text not null,
  event_version int not null default 1,
  occurred_at timestamptz not null,
  payload jsonb not null,
  status text not null check (status in ('pending','sent','failed','paused')) default 'pending',
  next_attempt_at timestamptz not null default now(),
  attempts_count int not null default 0,
  created_at timestamptz not null default now(),
  updated_at timestamptz not null default now()
);

create index on webhook_events (status, next_attempt_at);
create index on webhook_events (endpoint_id);

create table webhook_attempts (
  id bigserial primary key,
  event_id uuid not null references webhook_events(id) on delete cascade,
  attempt_no int not null,
  requested_at timestamptz not null default now(),
  duration_ms int,
  request_headers jsonb,
  request_body bytea,
  response_status int,
  response_headers jsonb,
  response_body bytea,
  error text
);

-- Для идемпотентности на стороне приёмника можно хранить перечень доставленных id
create table webhook_received (
  event_id uuid primary key,
  received_at timestamptz not null default now()
);

Храним тела попыток с ограничением по размеру (например, до 64 КБ), длинные — усекать с пометкой.

Публичный контракт: версия, типы событий, примеры

Как сделать интеграцию предсказуемой:

  • Документация с перечислением типов событий и схемой payload. Дайте по каждому типу минимальный и полный пример.
  • Версия контракта в заголовке и в теле: X-Webhook-Version и поле version.
  • Идентификатор события: X-Webhook-Id и id в теле — UUID.
  • Метки времени: occurred_at (когда у вас случилось) и sent_at (когда вы отправили).
  • Поле attempt — номер попытки (партнёры любят логировать).

Пример запроса:

POST /partner/hooks/orders HTTP/1.1
Host: example-partner.com
Content-Type: application/json
User-Agent: MyProduct-Webhook/1.0
X-Webhook-Id: 9f2a9b5b-9f2c-4ac2-8f7d-3f6401b2d1b5
X-Webhook-Event: order.status_changed
X-Webhook-Version: 2
X-Webhook-Timestamp: 1710691200
X-Webhook-Signature: sha256=3f7b5a2d9e0de1e5a6b0f4c5d89c1d1d3b2a9f5a4f6c1e2d3f4a5b6c7d8e9f0a

{
  "id": "9f2a9b5b-9f2c-4ac2-8f7d-3f6401b2d1b5",
  "type": "order.status_changed",
  "version": 2,
  "occurred_at": "2026-03-17T10:00:00Z",
  "sent_at": "2026-03-17T10:00:02Z",
  "attempt": 1,
  "data": {
    "order_id": "A12345",
    "previous_status": "processing",
    "new_status": "shipped",
    "tracking": "TRACK123"
  },
  "meta": {
    "tenant_id": "t-1",
    "region": "eu-central"
  }
}

Консоль для партнёров: логи, повтор, пауза, ротация секретов

Что реально снижает нагрузку на поддержку:

  • Список событий с фильтрами: по времени, типу, статусу, подписчику.
  • Детальная карточка: тело запроса, заголовки, ответ получателя, попытки с временными метками.
  • Кнопка «повторить доставку» для конкретного события и «повторить все неуспешные за период».
  • Управление подпиской: изменить URL, сгенерировать новый секрет (с плавной ротацией — на 24 часа активны оба), включить/поставить на паузу.
  • Веб‑панель для приёмника: показать последние полученные вебхуки, подсветить валидационные ошибки.

Эти функции окупаются с первым серьёзным партнёром.

Входящие вебхуки: мы — приёмник

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

  • Проверяйте X-Webhook-Timestamp (drift не более 300 сек) и подпись. Никаких «секретов в URL».
  • Отвечайте быстро: 2xx как только проверили подпись и записали событие для асинхронной обработки. Не делайте тяжёлую логику в HTTP‑хендлере.
  • Идемпотентность: храните id событий и отбрасывайте дубликаты.
  • Лимитируйте размер тела и время: например, до 512 КБ и 10 секунд.
  • Логи и трассировка: коррелируйте X-Webhook-Id с внутренними job‑ами. Отдавайте понятные коды и короткие сообщения об ошибках.

Тестирование и наблюдаемость: локально, сквозные проверки, хаос

  • Локально: прокидывайте туннель из интернета в разработческую машину (например, через ngrok/Cloudflare Tunnel) и тестируйте реальные запросы.
  • Контракт: публикуйте примеры и схемы (JSON Schema), прогоняйте валидацию на CI.
  • Сквозные тесты: автотест, который создаёт событие в тестовой среде и ждёт подтверждения доставки на тестовый приёмник.
  • Хаос: периодически симулируйте 500/timeout/429 у тестового приёмника, измеряйте, как восстанавливается очередь.
  • Метрики: доля успешных доставок, медиана/95‑й перцентиль задержки от occurred_at до 2xx, количество попыток на событие, размер очереди, доля событий в статусе failed.

Набор готовых заголовков и практик

Рекомендуемые заголовки исходящего вебхука:

  • X-Webhook-Id: UUID события
  • X-Webhook-Event: строка типа события
  • X-Webhook-Version: целое число версии контракта
  • X-Webhook-Timestamp: Unix‑время в секундах
  • X-Webhook-Signature: sha256=
  • User-Agent: <ваш‑продукт>/Webhook <версия>

Практики:

  • Чёткий лимит на TTL повторов (например, 72 часа), дальше — статус failed и уведомление партнёру.
  • Плоская, стабильная структура payload, избегайте внезапных переименований полей.
  • Никогда не меняйте смысл полей без изменения версии.
  • Не отправляйте чувствительные данные, если это не требуется; шифруйте поле целиком при необходимости.

Набросок реализации: проверка подписи и воркер доставки (Node.js)

Ниже — готовые куски, которые можно вставить в сервис (Express + node

). Они демонстрируют валидацию подписи, защиту по времени и константное сравнение, а также воркер с backoff и логированием попыток.

Проверка подписи входящего вебхука

// package.json: добавьте "type": "module"
import express from 'express';
import crypto from 'crypto';

const app = express();
app.use(express.raw({ type: 'application/json', limit: '512kb' }));

const SECRET = process.env.WEBHOOK_SECRET || 'replace-me';
const MAX_DRIFT_SEC = 300; // 5 минут

function timingSafeEqual(a: Buffer, b: Buffer): boolean {
  if (a.length !== b.length) return false;
  return crypto.timingSafeEqual(a, b);
}

function verifySignature({ timestamp, signature, rawBody }: { timestamp: string; signature: string; rawBody: Buffer; }): boolean {
  // ожидаем формат sha256=<hex>
  const [algo, hex] = (signature || '').split('=');
  if (algo !== 'sha256' || !hex) return false;

  // проверка дрифта
  const now = Math.floor(Date.now() / 1000);
  const ts = parseInt(timestamp, 10);
  if (!Number.isFinite(ts) || Math.abs(now - ts) > MAX_DRIFT_SEC) return false;

  const canonical = Buffer.concat([
    Buffer.from(String(ts), 'utf8'),
    Buffer.from('.', 'utf8'),
    rawBody
  ]);
  const expected = crypto.createHmac('sha256', SECRET).update(canonical).digest('hex');
  return timingSafeEqual(Buffer.from(hex, 'hex'), Buffer.from(expected, 'hex'));
}

app.post('/hooks/incoming', (req, res) => {
  const sig = req.header('X-Webhook-Signature') || '';
  const ts = req.header('X-Webhook-Timestamp') || '';
  const ok = verifySignature({ timestamp: ts, signature: sig, rawBody: req.body as Buffer });
  if (!ok) return res.status(401).json({ error: 'invalid signature' });

  // Минимальная валидация тела
  let payload: any;
  try { payload = JSON.parse((req.body as Buffer).toString('utf8')); } catch { return res.status(400).json({ error: 'invalid json' }); }
  if (!payload?.id) return res.status(422).json({ error: 'missing id' });

  // Здесь — запись в БД и постановка в очередь на асинхронную обработку
  // ... storeReceived(payload.id, payload)

  return res.status(204).end();
});

app.listen(3000, () => console.log('Webhook receiver on :3000'));

Отправка исходящих вебхуков с backoff и подписями

import crypto from 'crypto';
import fetch, { RequestInit } from 'node-fetch';

// Заглушка хранилища: замените на реальную БД
interface WebhookEvent {
  id: string;
  endpointUrl: string;
  secret: string;
  type: string;
  version: number;
  occurred_at: string;
  payload: any;
  attempts_count: number;
  next_attempt_at: number; // unix sec
  status: 'pending' | 'sent' | 'failed' | 'paused';
}

async function loadDueEvents(nowSec: number, limit = 100): Promise<WebhookEvent[]> {
  // SELECT * FROM webhook_events WHERE status='pending' AND next_attempt_at <= now() ORDER BY next_attempt_at LIMIT $1
  return []; // замените на чтение из БД
}

async function markSent(id: string) { /* UPDATE ... */ }
async function markFailed(id: string, nextAttemptSec: number) { /* UPDATE ... */ }
async function logAttempt(args: any) { /* INSERT INTO webhook_attempts ... */ }

function sign(secret: string, timestampSec: number, body: Buffer): string {
  const canonical = Buffer.concat([Buffer.from(String(timestampSec)), Buffer.from('.'), body]);
  const hex = crypto.createHmac('sha256', secret).update(canonical).digest('hex');
  return `sha256=${hex}`;
}

function nextDelaySec(attempt: number): number {
  const schedule = [60, 300, 1800, 7200, 43200, 86400];
  const base = schedule[Math.min(attempt, schedule.length - 1)];
  const jitter = base * 0.2 * (Math.random() - 0.5) * 2;
  return Math.max(30, Math.round(base + jitter));
}

async function sendEvent(ev: WebhookEvent) {
  const nowSec = Math.floor(Date.now() / 1000);
  const bodyObj = {
    id: ev.id,
    type: ev.type,
    version: ev.version,
    occurred_at: ev.occurred_at,
    sent_at: new Date().toISOString(),
    attempt: ev.attempts_count + 1,
    data: ev.payload,
    meta: {}
  };
  const body = Buffer.from(JSON.stringify(bodyObj));
  const signature = sign(ev.secret, nowSec, body);

  const headers = {
    'Content-Type': 'application/json',
    'User-Agent': 'MyProduct-Webhook/1.0',
    'X-Webhook-Id': ev.id,
    'X-Webhook-Event': ev.type,
    'X-Webhook-Version': String(ev.version),
    'X-Webhook-Timestamp': String(nowSec),
    'X-Webhook-Signature': signature
  } as Record<string, string>;

  const init: RequestInit = {
    method: 'POST',
    headers,
    body,
    // 10с таймаут на ответ, 5с на соединение — в node-fetch придётся оборачивать в AbortController
  };

  const controller = new AbortController();
  const timeout = setTimeout(() => controller.abort(), 15000);
  let status = 0, respHeaders: Record<string,string> = {}, respBody = Buffer.alloc(0), error: string | null = null;
  const started = Date.now();
  try {
    const resp = await fetch(ev.endpointUrl, { ...init, signal: controller.signal });
    status = resp.status;
    respHeaders = Object.fromEntries([...resp.headers.entries()]);
    const ab = await resp.arrayBuffer();
    respBody = Buffer.from(ab).slice(0, 65536);
  } catch (e: any) {
    error = e?.name === 'AbortError' ? 'timeout' : (e?.message || 'network error');
  } finally {
    clearTimeout(timeout);
  }

  const duration = Date.now() - started;
  await logAttempt({ event_id: ev.id, attempt_no: ev.attempts_count + 1, requested_at: new Date(), duration_ms: duration, request_headers: headers, request_body: body, response_status: status || null, response_headers: respHeaders, response_body: respBody, error });

  if (error) {
    const delay = nextDelaySec(ev.attempts_count + 1);
    return markFailed(ev.id, Math.floor(Date.now() / 1000) + delay);
  }

  if (status >= 200 && status < 300) {
    return markSent(ev.id);
  }

  if (status === 410) {
    // отключаем подписку и помечаем как failed без повторов
    return markFailed(ev.id, 0);
  }

  let delay = nextDelaySec(ev.attempts_count + 1);
  const ra = respHeaders['retry-after'];
  if (status === 429 && ra) {
    const sec = Number(ra);
    if (Number.isFinite(sec)) delay = Math.max(30, Math.round(sec));
  }
  return markFailed(ev.id, Math.floor(Date.now() / 1000) + delay);
}

async function workerLoop() {
  while (true) {
    const nowSec = Math.floor(Date.now() / 1000);
    const batch = await loadDueEvents(nowSec, 100);
    if (batch.length === 0) {
      await new Promise(r => setTimeout(r, 500));
      continue;
    }
    await Promise.allSettled(batch.map(sendEvent));
  }
}

workerLoop().catch(err => {
  console.error('Worker error', err);
  process.exit(1);
});

Обратите внимание: реальная реализация потребует блокировок/меток при выборе событий (чтобы несколько воркеров не отправили одно и то же), а также учёта предельного числа попыток и TTL.

Чек‑лист запуска вебхуков

  • HTTPS везде, поддержка mTLS для критичных.
  • Секрет на подписчика, ротация без даунтайма.
  • Подпись HMAC SHA‑256 по канонической строке timestamp + «.» + raw‑тело.
  • Заголовки: Id, Event, Version, Timestamp, Signature, User‑Agent.
  • Отклонение по времени (±5 минут) и константное сравнение подписи.
  • Идемпотентная обработка у получателя по id.
  • Очередь доставки с экспоненциальным backoff и джиттером, таймауты запроса.
  • Чёткая трактовка кодов: 2xx — ок, 410 — отключаем, 429 — уважаем Retry‑After.
  • Журнал попыток с телом и заголовками (с усечением), фильтры в консоли.
  • Документация с примерами payload и версиями контракта.
  • Сквозные тесты и метрики доставки.
  • Кнопка «повторить» и «поставить на паузу» в интерфейсе.

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


безопасностьинтеграциивебхуки