
Вебхуки — это способ уведомлять внешние системы по HTTP, когда у вас что‑то произошло: платёж прошёл, заказ сменил статус, документ подписан. Это быстрее, чем опрос API, дешевле для вашей инфраструктуры и удобнее для партнёров. Правильно сделанные вебхуки:
Картина на уровне компонентов:
Важная деталь: событие и запись о необходимости его отправить должны появляться атомарно, чтобы ничего не потерялось при сбоях. Для этого используйте транзакцию в вашей базе и явную запись «событие к доставке» в момент бизнес‑события (не полагайтесь на «потом доберём» в памяти сервиса).
Защита должна быть по слоям:
X-Webhook-Signature: sha256=<hex> и X-Webhook-Timestamp: <unix_sec>.X-Webhook-Id в кэше/БД, чтобы не обрабатывать дубликаты.Каноническая строка: <timestamp>.<raw_body>
signature = HMAC_SHA256(secret, canonical)
Значение передаём в hex, чтобы не было сюрпризов с base64.
Правда жизни: гарантировать строгий порядок всех событий сложно и дорого. Дайте партнёрам чёткую договорённость:
id и делайте обработку идемпотентной);Повторы нужны всегда. Типичная стратегия: до N попыток в течение T часов/дней с экспоненциальной задержкой и джиттером (случайной добавкой), чтобы избежать шторма повторов.
Пример интервалов: 1 мин, 5 мин, 30 мин, 2 ч, 12 ч, 24 ч. Останавливаемся по первому успешному 2xx или по достижении предела.
Как трактовать ответы:
Retry-After (в секундах или дате). Если заголовок отсутствует, используйте свой 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));
}
Нужны две сущности: «событие к доставке» и «попытка доставки».
Пример 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 КБ), длинные — усекать с пометкой.
Как сделать интеграцию предсказуемой:
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"
}
}
Что реально снижает нагрузку на поддержку:
Эти функции окупаются с первым серьёзным партнёром.
Принимая вебхуки, придерживайтесь тех же принципов:
X-Webhook-Timestamp (drift не более 300 сек) и подпись. Никаких «секретов в URL».id событий и отбрасывайте дубликаты.X-Webhook-Id с внутренними job‑ами. Отдавайте понятные коды и короткие сообщения об ошибках.Рекомендуемые заголовки исходящего вебхука:
Практики:
Ниже — готовые куски, которые можно вставить в сервис (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'));
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.
id.Итог: с таким фундаментом вебхуки перестают быть источником ночных дежурств и превращаются в надёжный канал событий для партнёров и ваших внутренних продуктов. Бизнесу это даёт предсказуемость интеграций, меньше обращений в поддержку и ощутимо более короткое время от идеи до реальной поставки ценности.