Webhook для email-событий: настройка, обработка и отладка
Polling API раз в минуту, чтобы узнать статус отправки — это костыль. Webhook даёт ту же информацию в момент события, без лишних запросов и без задержек. В этой статье разбираем, как принимать, проверять и обрабатывать webhook-уведомления от email-провайдеров. С примерами на Node.js, которые можно взять в продакшн.
Что такое webhook и зачем он нужен
Webhook — это HTTP-запрос, который отправляет внешний сервис на ваш URL при наступлении определённого события. В контексте email это означает: провайдер (SendGrid, Mailgun, Amazon SES, Postmark, любой ESP) вызывает ваш эндпоинт, когда письмо доставлено, отклонено, открыто, получен клик по ссылке или подписчик нажал «отписаться».
Разница с polling-подходом принципиальна. При polling вы сами периодически запрашиваете API провайдера: «есть что-нибудь новое?». Это создаёт нагрузку, добавляет задержку между событием и реакцией, а при масштабировании быстро упирается в rate limit. Webhook переворачивает направление: провайдер сам приходит к вам. Событие произошло — запрос отправлен. Типичная задержка: 1–5 секунд.
Типы событий, которые приходят через webhook, у разных провайдеров похожи:
- delivered — письмо принято почтовым сервером получателя
- bounced — жёсткий отказ (hard bounce), адрес не существует
- deferred — мягкий отказ (soft bounce), провайдер попробует ещё раз
- opened — получатель открыл письмо (через tracking pixel)
- clicked — переход по ссылке внутри письма
- complained — получатель отметил письмо как спам
- unsubscribed — отписка через List-Unsubscribe
Каждый из этих сигналов влияет на качество вашей базы. Hard bounce говорит, что адрес нужно удалить немедленно. Complaint означает, что отправлять этому получателю опасно для репутации домена. И чем быстрее вы на это реагируете, тем ниже bounce rate и выше inbox placement.
Минимальный webhook-приёмник на Node.js
Начнём с самого простого сервера, который принимает POST-запрос, логирует тело и возвращает 200. Это основа, на которую потом навешивается всё остальное.
import express from "express";
const app = express();
app.use(express.json());
app.post("/webhooks/email", (req, res) => {
const events = Array.isArray(req.body) ? req.body : [req.body];
for (const event of events) {
console.log(`[${event.event}] ${event.email} — ${event.timestamp}`);
}
// Отвечаем 200 немедленно, обработку выполняем асинхронно
res.sendStatus(200);
});
app.listen(3000, () => {
console.log("Webhook receiver listening on port 3000");
});Два момента, которые стоит запомнить сразу. Первое: всегда отвечайте 200 (или 202) как можно быстрее. Провайдеры ждут ответ 5–10 секунд; если не дождались — поставят webhook в очередь на повтор. Второе: некоторые провайдеры отправляют массив событий в одном запросе (SendGrid, к примеру, группирует до нескольких тысяч), другие — по одному (Postmark). Обрабатывайте оба формата.
Проверка подписи: не принимайте чужие запросы
Webhook-эндпоинт — это публичный URL. Любой, кто его знает, может отправить поддельный запрос. Если вы без проверки удаляете адрес из базы по событию bounced, злоумышленник может зачистить вашу рассылку за минуту. Поэтому проверка подписи — не опция, а обязательный шаг.
Большинство провайдеров подписывают запросы через HMAC-SHA256. Схема везде примерно одинаковая: провайдер берёт тело запроса (или его часть), вычисляет HMAC с секретным ключом, который вы задали при настройке, и передаёт подпись в заголовке.
Пример middleware для проверки подписи SendGrid:
import crypto from "node:crypto";
const WEBHOOK_SECRET = process.env.SENDGRID_WEBHOOK_SECRET;
function verifySignature(req, res, next) {
const signature = req.headers["x-twilio-email-event-webhook-signature"];
const timestamp = req.headers["x-twilio-email-event-webhook-timestamp"];
if (!signature || !timestamp) {
return res.status(401).json({ error: "Missing signature headers" });
}
const payload = timestamp + JSON.stringify(req.body);
const expected = crypto
.createHmac("sha256", WEBHOOK_SECRET)
.update(payload)
.digest("base64");
const isValid = crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expected)
);
if (!isValid) {
console.warn("Invalid webhook signature, rejecting request");
return res.status(401).json({ error: "Invalid signature" });
}
next();
}
app.post("/webhooks/email", verifySignature, (req, res) => {
// Тело запроса подтверждено — безопасно обрабатываем
res.sendStatus(200);
});Обратите внимание на crypto.timingSafeEqual. Обычное сравнение строк (===) уязвимо к timing-атакам: по времени ответа можно постепенно подобрать правильную подпись. timingSafeEqual сравнивает за постоянное время независимо от того, на каком байте различие.
Mailgun подписывает иначе — через token + timestamp + signature в теле запроса. Amazon SES использует SNS-нотификации с сертификатом X.509. Конкретная реализация различается, но принцип один: не доверяйте входящему запросу без криптографической проверки.
Обработка событий: что делать с каждым типом
Получить событие — половина работы. Вторая половина — правильно на него отреагировать. Вот маршрутизация по типам событий, которая покрывает основные сценарии:
async function processEvent(event) {
switch (event.event) {
case "bounce":
case "dropped":
// Жёсткий отказ — немедленно помечаем адрес как невалидный
await db.query(
"UPDATE subscribers SET status = $1, bounced_at = $2 WHERE email = $3",
["bounced", new Date(event.timestamp * 1000), event.email]
);
break;
case "spamreport":
// Жалоба на спам — прекращаем отправку этому получателю
await db.query(
"UPDATE subscribers SET status = $1, complained_at = $2 WHERE email = $3",
["complained", new Date(event.timestamp * 1000), event.email]
);
break;
case "unsubscribe":
await db.query(
"UPDATE subscribers SET status = $1, unsubscribed_at = $2 WHERE email = $3",
["unsubscribed", new Date(event.timestamp * 1000), event.email]
);
break;
case "deferred":
// Мягкий отказ — инкрементируем счётчик, после N попыток помечаем
await db.query(
"UPDATE subscribers SET soft_bounce_count = soft_bounce_count + 1 WHERE email = $1",
[event.email]
);
break;
case "open":
case "click":
// Позитивный сигнал — обновляем дату активности
await db.query(
"UPDATE subscribers SET last_engaged_at = $1 WHERE email = $2",
[new Date(event.timestamp * 1000), event.email]
);
break;
default:
console.log(`Unhandled event type: ${event.event}`);
}
}Несколько деталей, которые легко упустить. При bounce важно различать hard bounce и soft bounce. Жёсткий отказ (код 5xx) означает, что адрес не существует — повторные попытки не помогут. Мягкий отказ (код 4xx) — временная проблема: переполненный ящик, сервер недоступен. Большинство ESP обрабатывают soft bounce на своей стороне и повторяют отправку. Но если один адрес даёт soft bounce три-пять раз подряд, его стоит перевести в категорию невалидных.
Complaint (spamreport) — самый опасный сигнал. Один complaint на тысячу писем — порог, после которого Gmail начинает задвигать ваши рассылки в спам. Получили complaint — немедленно прекращаете отправку этому адресу и анализируете причину. Может, человек не подписывался. Может, частота рассылки слишком высокая. Может, кнопка отписки спрятана на третьем экране.
Идемпотентность: одно событие — одна обработка
Провайдеры гарантируют доставку «at least once». Это значит, что один и тот же webhook может прийти дважды (или трижды, если ваш сервер ответил медленно и провайдер решил повторить). Если обработчик не идемпотентен, вы получите задвоенные данные: дважды отправленное уведомление пользователю, дважды декрементированный счётчик, дважды записанная метрика.
Простой способ решить проблему — хранить идентификаторы обработанных событий:
async function handleWebhook(req, res) {
res.sendStatus(200);
const events = Array.isArray(req.body) ? req.body : [req.body];
for (const event of events) {
const eventId = event.sg_event_id || event._id || generateEventId(event);
// INSERT ... ON CONFLICT — если запись уже есть, ничего не делаем
const result = await db.query(
`INSERT INTO webhook_events (event_id, payload, received_at)
VALUES ($1, $2, NOW())
ON CONFLICT (event_id) DO NOTHING
RETURNING event_id`,
[eventId, JSON.stringify(event)]
);
// Если строка не вставилась — событие уже обработано
if (result.rowCount === 0) continue;
await processEvent(event);
}
}
function generateEventId(event) {
const raw = `${event.email}:${event.event}:${event.timestamp}`;
return crypto.createHash("sha256").update(raw).digest("hex");
}Если провайдер передаёт уникальный идентификатор события (SendGrid — sg_event_id, Mailgun — id, Postmark — RecordType + MessageID), используйте его. Если нет — сгенерируйте хеш из email + тип события + timestamp. Не идеально (два открытия одного письма с одинаковой меткой времени склеятся), но достаточно для большинства случаев.
Очередь задач: отделяйте приём от обработки
В продакшне обрабатывать событие прямо внутри HTTP-хендлера — плохая идея. Запрос к базе упал, соединение разорвалось, обработчик бросил исключение — и событие потеряно. Провайдер повторит через пять минут, но к этому моменту вы уже могли отправить рассылку по адресу, который bounce’нулся.
Правильный паттерн: HTTP-эндпоинт только принимает запрос, сохраняет payload в очередь и отвечает 200. Отдельный воркер читает очередь и обрабатывает события.
import { Queue, Worker } from "bullmq";
import Redis from "ioredis";
const connection = new Redis(process.env.REDIS_URL);
const webhookQueue = new Queue("email-webhooks", { connection });
// HTTP-эндпоинт — только принимает и кладёт в очередь
app.post("/webhooks/email", verifySignature, async (req, res) => {
const events = Array.isArray(req.body) ? req.body : [req.body];
await webhookQueue.addBulk(
events.map((event) => ({
name: event.event,
data: event,
opts: {
attempts: 5,
backoff: { type: "exponential", delay: 1000 },
},
}))
);
res.sendStatus(200);
});
// Воркер — обрабатывает события из очереди
const worker = new Worker(
"email-webhooks",
async (job) => {
await processEvent(job.data);
},
{ connection, concurrency: 10 }
);
worker.on("failed", (job, err) => {
console.error(`Job ${job.id} failed: ${err.message}`);
});BullMQ + Redis — один из самых распространённых вариантов для Node.js. Но подойдёт любая очередь: RabbitMQ, Amazon SQS, даже таблица в PostgreSQL с полем processed_at. Ключевое: если воркер падает, событие остаётся в очереди и будет обработано после перезапуска.
Retry-логика на стороне провайдера
Что произойдёт, если ваш сервер не ответил 200? Провайдер повторит запрос. Но каждый делает это по-своему:
- SendGrid — повторяет до 24 часов с экспоненциальной задержкой. После этого webhook деактивируется.
- Mailgun — до 8 часов. Задержки между попытками растут.
- Postmark — до 72 часов. Самый терпеливый из популярных провайдеров.
- Amazon SES (через SNS) — 3 попытки с интервалами 20 секунд. При неуспехе сообщение уходит в DLQ.
Практический вывод: нельзя полагаться на retry провайдера как на единственную гарантию. Если ваш сервер лежал четыре часа — часть событий от Mailgun уже потеряна. Поэтому параллельно с webhook стоит периодически запрашивать API провайдера за последние события (раз в час, раз в день) — как страховку.
Отладка: как тестировать webhook локально
Webhook требует публично доступный URL. На локальной машине его нет. Три способа решить проблему:
ngrok — создаёт туннель от публичного URL к вашему localhost. Запускаете ngrok http 3000, получаете URL вида https://abc123.ngrok.io, прописываете его в настройках провайдера. Запросы идут на публичный URL, ngrok перенаправляет на localhost:3000. Для разработки — самый удобный вариант.
Ручное тестирование через curl — отправляете запрос, имитирующий провайдер:
curl -X POST http://localhost:3000/webhooks/email \
-H "Content-Type: application/json" \
-d '[
{
"event": "bounce",
"email": "test@invalid-domain.com",
"timestamp": 1743552000,
"sg_event_id": "test-001",
"reason": "550 5.1.1 User unknown"
}
]'Replay из логов — сохраняйте raw-тело каждого входящего webhook в лог или базу. Когда нужно воспроизвести проблему, берёте реальный payload и отправляете на локальный сервер. Это самый надёжный способ отладки, потому что вы работаете с настоящими данными, а не с придуманными примерами.
Логирование и мониторинг
Webhook — это чужой код, который стучится к вам. Без логирования вы не узнаете, что провайдер перестал отправлять события (сменился формат, истёк токен, URL деактивирован). Минимальный набор метрик:
- Количество входящих запросов в минуту. Резкое падение до нуля — сигнал, что webhook отключился.
- Количество запросов с невалидной подписью. Рост означает либо попытку атаки, либо ротацию ключа на стороне провайдера.
- Время обработки одного события. Если растёт — база тормозит, очередь забивается.
- Размер очереди необработанных событий. Если растёт быстрее, чем снижается — воркеров не хватает.
// Структурированное логирование каждого события
function logEvent(event, status) {
const entry = {
timestamp: new Date().toISOString(),
event_type: event.event,
email: event.email,
provider_event_id: event.sg_event_id || event.id || null,
status, // "processed" | "duplicate" | "error"
};
// stdout → собирается в ELK / CloudWatch / Datadog
console.log(JSON.stringify(entry));
}Настройте алерт на случай, когда за последний час не пришло ни одного webhook-события. Это важнее, чем кажется. Провайдер мог деактивировать ваш URL после серии неудачных доставок, а вы узнаете об этом только когда bounce rate уйдёт за пять процентов.
Чек-лист безопасности
Помимо проверки подписи есть ещё несколько мер, которые стоит внедрить:
- HTTPS. Webhook-URL должен быть только через TLS. Без шифрования payload с email-адресами летит открытым текстом.
- Rate limiting. Ограничьте количество запросов в секунду на webhook-эндпоинт. Провайдер не отправит тысячу запросов в секунду, а атакующий может.
- IP-фильтрация. Некоторые провайдеры публикуют диапазоны IP-адресов, с которых отправляются webhook. Если есть возможность — ограничьте доступ на уровне firewall или reverse proxy.
- Ротация секретов. Меняйте webhook-ключ раз в квартал. Процедура: генерируете новый ключ, обновляете его в приложении, затем в провайдере. Краткое окно, когда оба ключа валидны, не страшно.
Webhook без проверки подписи — это открытая дверь в вашу базу подписчиков. Закрывайте её до того, как кто-то войдёт.
Webhook + валидация: замкнутый цикл чистой базы
Webhook и валидация email работают на одну цель — чистая база с минимальным bounce rate. Но они действуют в разное время. Валидация отсекает плохие адреса до отправки. Webhook отлавливает проблемы после отправки: адрес стал невалидным, ящик переполнен, получатель пожаловался.
Вместе они образуют замкнутый цикл. Вы проверяете базу валидатором перед рассылкой. Отправляете письма. Через webhook получаете обратную связь: какие адреса bounce’нулись, какие пожаловались. Эти данные автоматически обновляют статус в базе. При следующей рассылке этим адресам ничего не уходит.
Без валидации перед отправкой bounce rate будет высоким с самого начала, и webhook станет инструментом уборки последствий. Без webhook после отправки невалидные адреса, появившиеся между проверками, останутся в базе и продолжат портить репутацию.
Проверьте свою базу перед следующей рассылкой. Загрузите список в uChecker — удалите невалидные адреса до того, как они превратятся в bounce. А webhook пусть ловит то, что появится между проверками.
Проверьте базу перед рассылкой в uChecker — 30 бесплатных проверок, чтобы увидеть реальное качество вашего списка.
