Запустите локальный обработчик, откройте порт через npx portpreview PORT и укажите HTTPS-маршрут в нужном Message Stream. Защитите его Basic Authentication или секретным заголовком, проверяйте JSON, сохраняйте идемпотентно и быстро отвечайте HTTP 200.
Что Postmark отправляет в вебхук
Postmark отправляет HTTP POST для Delivery, Bounce, Open, Click, Spam Complaint, Subscription Change и входящей почты. Маршрутизируйте по RecordType. Delivery означает прием сервером, но не попадание во входящие. Bounce содержит Type, TypeCode, Inactive, CanActivate. официальный обзор вебхуков справочник bounce-вебхука
Небольшой локальный обработчик на Express
Express на порту 3000 сначала проверяет Basic Auth, затем минимальную схему и надежно пишет ключ дедупликации. Замените helpers своей транзакцией или очередью. Ограничьте размер и не логируйте полные письма с персональными данными, ссылками и вложениями.
import crypto from 'node:crypto';
import express from 'express';
const app = express();
app.use('/webhooks/postmark', express.json({ limit: '2mb' }));
function safeEqual(actual, expected) {
const a = Buffer.from(actual);
const b = Buffer.from(expected);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
function authorized(req) {
const value = req.get('authorization') ?? '';
if (!value.startsWith('Basic ')) return false;
const decoded = Buffer.from(value.slice(6), 'base64').toString('utf8');
const separator = decoded.indexOf(':');
if (separator < 0) return false;
return safeEqual(decoded.slice(0, separator), process.env.POSTMARK_WEBHOOK_USER ?? '') &&
safeEqual(decoded.slice(separator + 1), process.env.POSTMARK_WEBHOOK_PASSWORD ?? '');
}
app.post('/webhooks/postmark', async (req, res) => {
if (!authorized(req)) return res.sendStatus(401);
const event = req.body;
if (typeof event?.RecordType !== 'string' ||
typeof event?.MessageID !== 'string') {
return res.status(400).json({ error: 'Invalid Postmark event' });
}
const deliveryKey = `${event.RecordType}:${event.MessageID}:${event.ID ?? ''}`;
await saveWebhookOnce({ provider: 'postmark', deliveryKey, event });
res.sendStatus(200);
});
app.listen(3000);
Публичный HTTPS-адрес для localhost
Проверьте локальный порт, выполните npx portpreview 3000 и добавьте маршрут https://YOUR-TUNNEL.portpreview.dev/webhooks/postmark. Туннель дает HTTPS и доступность, но не аутентифицирует Postmark. руководство по безопасности localhost-туннелей
Настройка нужного вебхука Postmark
Для outbound выберите правильные Server и Message Stream, добавьте URL и только нужные triggers. У Inbound Message Stream свой URL. API поддерживает HttpAuth, HttpHeaders; X-Postmark-Server-Token нужен для API и не отправляется получателю. Webhooks API
Аутентификация Postmark — не криптографическая подпись
Postmark не поддерживает HMAC-подпись вебхуков: нет signing secret и X-Postmark-Signature. Basic Authentication, IP allowlist и секретный header подтверждают общий секрет, но не связывают его криптографически с body. Используйте HTTPS, валидацию и актуальные IP. Предпочитайте HttpAuth; в https://username:[email protected]/path нужны отдельные стойкие, правильно закодированные данные. Не используйте Server API token.
Обработка доставки и bounce по типу
Вынесите бизнес-логику в worker и обрабатывайте сохраненные события идемпотентно. Не блокируйте адрес навсегда по одному полю: учитывайте актуальную классификацию Bounce. Spam Complaint и Subscription Change — отдельные типы, Open и Click повторяются.
async function processPostmarkEvent(event) {
switch (event.RecordType) {
case 'Delivery':
await markAcceptedByRecipientServer({
messageId: event.MessageID,
deliveredAt: event.DeliveredAt
});
break;
case 'Bounce':
await recordBounce({
bounceId: String(event.ID),
messageId: event.MessageID,
type: event.Type,
inactive: event.Inactive,
canActivate: event.CanActivate
});
break;
default:
await recordUnhandledPostmarkType(event.RecordType);
}
}
Проектирование с учетом повторов и дублей
Без HTTP 200 Postmark повторяет запрос; Bounce и Inbound дольше, чем Click, Open, Delivery и Subscription Change, а 403 останавливает повторы. Дубль после commit и timeout легитимен. Создайте unique key из MessageID, а для смешанного endpoint добавьте RecordType и ID. Отвечайте 200 после надежной передачи, внешние API вызывайте асинхронно. повторы и идемпотентность вебхуков
Безопасная проверка реальных событий
Сначала отправьте curl POST, затем письмо на свой адрес для Delivery. Bounce проверяйте официальными средствами Postmark, включая black-hole test domain, если доступен. Сохраните MessageID и дважды отправьте очищенный fixture, ожидая один эффект.
Диагностика типичных сбоев вебхука Postmark
Запрос не доходит до endpoint
Проверьте туннель, маршрут и порт. При 401 сравните credentials, перезапустите приложение и убедитесь, что proxy не удаляет Authorization, не выводя значение. При повторах проверьте публичные status и latency: нужен 200. При отличии payload сверьте RecordType, trigger и stream.
Все запросы возвращают HTTP 401
руководство по ошибкам 401/403
Повторы после успешной обработки
Проверьте туннель, маршрут и порт. При 401 сравните credentials, перезапустите приложение и убедитесь, что proxy не удаляет Authorization, не выводя значение. При повторах проверьте публичные status и latency: нужен 200. При отличии payload сверьте RecordType, trigger и stream.
Payload не совпадает с примером
Проверьте туннель, маршрут и порт. При 401 сравните credentials, перезапустите приложение и убедитесь, что proxy не удаляет Authorization, не выводя значение. При повторах проверьте публичные status и latency: нужен 200. При отличии payload сверьте RecordType, trigger и stream.
Чек-лист безопасности для production
Используйте HTTPS и отдельные стойкие credentials; разделяйте API tokens; ротируйте после теста; удаляйте старые URL; проверяйте тип, размер и поля; скрывайте почтовые данные и секреты; применяйте least privilege; мониторьте ошибки, lag, дубли и dead letters.
