Wysłanie e-maila to dopiero pierwsza połowa historii. Ten rozdział odpowiada na pytanie, co dzieje się po opuszczeniu przez wiadomość Twojego dostawcy, jak się o tym dowiadujesz i co zrobić z każdym zdarzeniem. Webhooki to sposób, w jaki Twoja aplikacja dowiaduje się w czasie rzeczywistym, czy e-mail został dostarczony, odbity, otwarty, kliknięty czy oznaczony jako spam. Zamiast odpytywać dostawcę „co się stało z wiadomością X?”, to dostawca dzwoni do Ciebie w chwili, gdy coś się wydarzy.
Szybkie odpowiedzi na pytania z tego rozdziału:
- Czym jest webhook i po co mi on?
- Jakie typy zdarzeń będę otrzymywać i na które muszę reagować?
- Jak zbudować endpoint webhooka, który nie wpędzi mnie w kłopoty?
- Jak weryfikować podpisy, by nikt nie mógł sfałszować zdarzeń?
- Jak obsługiwać odbicia, skargi i dostarczenia?
- Dlaczego moje przetwarzanie musi być idempotentne?
- Co się dzieje, gdy mój endpoint zawiedzie, i jak działają ponowienia?
- Jak przetestować webhooki przed wdrożeniem na produkcję?
Ten rozdział jest celowo neutralny względem dostawców. Każdy przykład używa albo surowych standardów (wbudowanego w Node crypto, schematu podpisów Standard Webhooks, zwykłego HTTP), albo cienkiej abstrakcji provider / emailClient, która należy do Ciebie. Nazwy dostawców, Amazon SES, Postmark, SendGrid, Mailgun, Resend, Brevo, SparkPost, Mandrill, pojawiają się wyłącznie jako konkretne przykłady tego, jak dany producent nazywa daną rzecz. Nic tutaj nie zakłada konkretnego ESP, a wzorce przenoszą się bez zmian, gdy zmieniasz dostawcę.
Czym jest webhook i po co mojemu systemowi pocztowemu?
Webhook to endpoint HTTP, który jest Twój i który Twój dostawca poczty wywołuje (żądaniem POST), gdy tylko coś dzieje się z wiadomością. To odwrotność wywołania API: normalnie to Twoja aplikacja woła dostawcę, ale przy webhookach to dostawca woła Twoją aplikację. Ten model „push” jest jedynym praktycznym sposobem, by dowiedzieć się o zdarzeniach, które dzieją się na cudzym serwerze pocztowym, w cudzej skrzynce, minuty lub godziny po wysłaniu wiadomości.
Webhooki są Ci potrzebne z trzech konkretnych powodów:
- Twoja lista supresji pozostaje dokładna. Odbicia i skargi przychodzą jako zdarzenia, a Ty używasz ich, by przestać wysyłać na adresy, które Ci zaszkodzą.
- Wcześnie wychwytujesz problemy z dostarczalnością, zanim rosnący wskaźnik odbić czy skarg zatopi reputację nadawcy.
- Możesz mierzyć zaangażowanie (otwarcia, kliknięcia) i pokazywać realny status dostarczenia w swoim produkcie.
Aplikacja, która wysyła pocztę, ale ignoruje zdarzenia webhooków, leci na ślepo. Wciąż wysyła na adresy, które się odbijają, i do odbiorców, którzy już złożyli skargę, a to dokładnie ten mechanizm, przez który domena wysyłkowa trafia na listy blokujące. Ten rozdział domyka pętlę pracy nad niezawodnością z poprzedniego rozdziału. W rozdziale Niezawodność wysyłki zadbałeś o to, by każdy e-mail został wysłany dokładnie raz, i zapisałeś każdy identyfikator wiadomości od dostawcy. Webhooki to moment, w którym te identyfikatory się opłacają: każde przychodzące zdarzenie odwołuje się do wiadomości, pozwalając Ci zaktualizować jej status, oczyścić listy i automatycznie reagować na problemy.
Dlaczego nie odpytywać po prostu API dostawcy?
Odpytywanie (polling) to oczywista alternatywa i zarazem zły domyślny wybór. Aby poznać status dostarczenia przez odpytywanie, musiałbyś wielokrotnie pytać dostawcę „co się stało z wiadomością X?” dla każdej wiadomości w locie, bo nie da się z góry wiedzieć, kiedy (ani czy) nadejdzie odbicie. Ekonomia tego nie domyka:
- Miękkie odbicie lub skarga może nadejść sekundy po wysyłce albo godziny później (część dostawców skrzynek pakietuje informacje zwrotne o skargach). By wychwycić te wolne, musisz odpytywać każdą wiadomość przez godziny, co mnoży Twój wolumen żądań przez częstotliwość odpytywania.
- Większość dostawców limituje (rate-limit) swoje API statusu, a wielu nie przechowuje statusu pojedynczej wiadomości długo. Zanim odpytasz, zdarzenie może już wygasnąć.
- Odpytywanie jest z natury pull-based i stratne: jeśli przestaniesz odpytywać wiadomość zbyt wcześnie, po prostu nigdy nie dowiesz się, co się z nią stało.
Webhooki odwracają to wszystko. Dostawca mówi Ci dokładnie raz, w chwili, gdy zdarzenie jest znane, i ponawia, aż potwierdzisz odbiór. Odpytywanie zostaw na rekoncyliację, okresowy przegląd wychwytujący zdarzenia, które awaria webhooków mogła upuścić (omówione poniżej w trybach awarii), a nie jako główny mechanizm.
Webhooki kontra przychodzące kanały informacji zwrotnej
Webhooki dostawcy nie są jedynym sposobem, w jaki sygnały o dostarczaniu do Ciebie docierają. Istnieją dwa inne kanały, które warto rozumieć, bo czasem zasilają tę samą logikę supresji:
- DSN-y (Delivery Status Notifications, RFC 3464). Gdy wysyłasz przez surowy SMTP bez zarządzanego ESP, twarde i miękkie odbicia wracają jako maszynowo czytelne wiadomości o odbiciu na Twój adres
Return-Path(kopertowyMAIL FROM). Twój dostawca zwykle parsuje je za Ciebie i re-emituje jako zdarzenia webhookabounced; jeśli prowadzisz własny MTA, sam parsujesz DSN. - Pętle zwrotne (FBL / ARF, RFC 5965). Kilku dostawców skrzynek odsyła nadawcy kopię skarg na spam w formacie Abuse Reporting Format. Twój ESP zapisuje się do nich w Twoim imieniu i wystawia je jako zdarzenia
complained. Jeśli wysyłasz bezpośrednio, zapisujesz się sam (na przykład przez program postmaster danego dostawcy) i parsujesz raporty ARF na tę samą ścieżkę obsługi skarg opisaną dalej w tym rozdziale.
Sedno: webhook bounced lub complained to zwykle znormalizowany widok Twojego dostawcy na bazowy SMTP DSN lub raport zwrotny ARF. Logika obsługi w tym rozdziale jest identyczna niezależnie od tego, który kanał wygenerował sygnał.
Przykłady poniżej trzymają się schematu podpisów Standard Webhooks, otwartej, neutralnej wobec producentów specyfikacji do podpisywania ładunków webhooków, ale architektura jest uniwersalna. Każdy poważny dostawca wysyła podpisany JSON przez HTTPS POST i ponawia przy awarii. Nazwy zdarzeń i nagłówki podpisów różnią się między dostawcami; wzorce nie.
Jakie typy zdarzeń będę otrzymywać?
Każde zdarzenie mówi Ci coś innego o podróży e-maila. Potraktuj je jako dwie grupy: sygnały zdrowia (bounced, complained), które wymagają działania w celu ochrony reputacji, oraz sygnały obserwowalności (sent, delivered, opened, clicked), które zasilają śledzenie statusu i analitykę.
| Zdarzenie | Kiedy wyzwalane | Do czego służy |
|---|---|---|
email.sent | E-mail przyjęty przez dostawcę | Potwierdzenie zainicjowania wysyłki |
email.delivered | E-mail dostarczony do serwera odbiorcy | Potwierdzenie dostarczenia |
email.bounced | E-mail odbity (twardo lub miękko) | Higiena listy, alertowanie |
email.complained | Odbiorca oznaczył jako spam | Natychmiastowy wypis |
email.opened | Odbiorca otworzył e-mail | Śledzenie zaangażowania |
email.clicked | Odbiorca kliknął link | Śledzenie zaangażowania |
Powyższe nazwy są poglądowe. Dokładne ciągi znaków różnią się między dostawcami, a zmapowanie ich na mały wewnętrzny słownik to pierwsze zadanie Twojej warstwy webhooków.
Mapowanie nazw zdarzeń dostawcy
Znormalizuj słownik każdego dostawcy na własny zestaw kanoniczny w chwili, gdy ładunek nadejdzie, aby reszta Twojego kodu nigdy nie rozgałęziała się na ciągi znaków specyficzne dla producenta. Poniższa tabela pokazuje, jak kilku popularnych dostawców nazywa każdą kategorię. Potraktuj ją jako punkt wyjścia, nie ewangelię, zweryfikuj względem aktualnej dokumentacji swojego dostawcy, bo producenci dodają i zmieniają nazwy zdarzeń.
| Kanoniczne | SendGrid | Amazon SES (przez SNS) | Mailgun | Postmark |
|---|---|---|---|---|
sent | processed | Send | accepted | , (domyślne przy wywołaniu API) |
delivered | delivered | Delivery | delivered | Delivery |
bounced (twarde) | bounce (typ blocked/bounce) | Bounce (bounceType: Permanent) | failed (severity permanent) | HardBounce / Bounce |
bounced (miękkie) | deferred / bounce | Bounce (bounceType: Transient) | failed (severity temporary) | SoftBounce |
complained | spamreport | Complaint | complained | SpamComplaint |
opened | open | Open (Engagement) | opened | Open |
clicked | click | Click (Engagement) | clicked | Click |
(także) dropped/unsubscribed | dropped, unsubscribe | Reject, Rendering Failure | unsubscribed | SubscriptionChange |
Mała funkcja normalizująca trzyma ciągi producenta zamknięte w jednym miejscu:
Kod dla zespołu technicznego (TypeScript)
// Mapuj specyficzny dla dostawcy ładunek zdarzenia na własny słownik kanoniczny.
// Niech to będzie JEDYNE miejsce, które zna ciągi znaków specyficzne dla producenta.
type CanonicalType =
| 'sent' | 'delivered' | 'bounced' | 'complained' | 'opened' | 'clicked' | 'unsubscribed';
interface CanonicalEvent {
id: string; // stabilny, dostarczony przez dostawcę id zdarzenia (dla idempotencji)
type: CanonicalType;
messageId: string; // Twój zapisany id wiadomości od dostawcy
email: string;
bounceClass?: 'hard' | 'soft';
occurredAt: Date;
raw: unknown; // zachowaj oryginalny ładunek dosłownie
}
function normalize(providerName: string, payload: any): CanonicalEvent {
switch (providerName) {
case 'ses': {
const m = payload.mail;
const typeMap: Record<string, CanonicalType> = {
Delivery: 'delivered', Bounce: 'bounced', Complaint: 'complained',
Open: 'opened', Click: 'clicked', Send: 'sent',
};
return {
id: payload.mail.messageId + ':' + payload.notificationType,
type: typeMap[payload.notificationType],
messageId: m.messageId,
email: (m.commonHeaders?.to ?? [''])[0],
bounceClass: payload.bounce?.bounceType === 'Permanent' ? 'hard' : 'soft',
occurredAt: new Date(m.timestamp),
raw: payload,
};
}
// case 'sendgrid': ... case 'postmark': ... case 'resend': ...
default:
throw new Error(`No normalizer for provider ${providerName}`);
}
}
Które zdarzenia mogę bezpiecznie zignorować, a których nie?
bounced i complained to zdarzenia, których nie wolno ignorować. Wpływają bezpośrednio na dostarczalność, a w przypadku skarg także na obowiązki prawne i etyczne. Pozostałe cztery są wartościowe, ale opcjonalne: możesz uruchomić działający system, który obsługuje tylko odbicia i skargi, a resztę dodać później dla analityki. Jeśli kiedykolwiek podłączysz tylko dwa handlery, niech to będą właśnie te dwa.
Jest też trzecie zdarzenie warte awansu do „musisz obsłużyć”, jeśli Twój dostawca je emituje: zdarzenie wypisu (unsubscribe) powiązane z jednoklikowym list-unsubscribe. Zgodnie z RFC 8058 (List-Unsubscribe-Post) odbiorca może wypisać się z wiadomości masowej jednym kliknięciem bezpośrednio z interfejsu skrzynki, a dostawca skrzynki wykonuje POST na Twój URL z nagłówka List-Unsubscribe. Wielu ESP, które hostują ten URL w Twoim imieniu, wystawia wynik jako zdarzenie webhooka. Jeśli je otrzymasz, potraktuj je jak skargę-light: natychmiast zasuprymuj adres dla tego strumienia pocztowego. Zobacz Zgodność prawna, gdzie znajdziesz ramy prawne, oraz Maile marketingowe, gdzie jednoklikowy wypis jest obowiązkowy.
Jaka jest różnica między sent a delivered?
sent oznacza, że dostawca przyjął Twoje żądanie i zakolejkował wiadomość. delivered oznacza, że serwer odbiorcy ją przyjął. Przerwa między nimi to dokładnie miejsce, gdzie zdarzają się odbicia. Nie mów użytkownikowi „dostarczone”, gdy widziałeś jedynie sent. Wiadomość może tkwić w stanie sent przez sekundy lub minuty i mimo to się odbić.
Pomaga myśleć o cyklu życia jak o małej maszynie stanów, bo część przejść jest terminalna, a część nie:
queued ──► sent ──► delivered ──► opened ──► clicked
│ │
├──► bounced (hard) [terminal, suppress]
├──► bounced (soft) [retryable, count]
└──► complained [terminal, suppress]
delivered nie jest terminalne: skarga prawie zawsze nadchodzi po dostarczeniu, bo człowiek musi najpierw otrzymać wiadomość, zanim oznaczy ją jako spam. Tak więc jedna wiadomość zasadnie produkuje sent → delivered → complained. Twój model statusu musi pozwalać, by dostarczona wiadomość później stała się skarżona; nie zamrażaj statusu na delivered.
Czy delivered oznacza, że odbiorca przeczytał e-mail?
Nie. Dostarczenie oznacza, że wiadomość dotarła do serwera odbiorcy, a nie że człowiek ją zobaczył. Dopiero opened na to wskazuje, i nawet opened jest przybliżone: opiera się na pikselu śledzącym, który funkcje prywatności i blokowanie obrazków mogą tłumić. Na przykład Apple Mail Privacy Protection wstępnie pobiera obrazki i może zarejestrować „otwarcie”, którego odbiorca nigdy nie wykonał. Traktuj wskaźniki otwarć jako sygnał kierunkowy, nigdy jako dowód, że konkretna osoba przeczytała konkretny e-mail.
clicked to silniejszy sygnał niż opened (maszyna wstępnie pobierająca obrazki rzadko podąża za linkami), ale ma własne zastrzeżenia: przekierowania śledzenia linków mogą być wyzwalane przez skanery bezpieczeństwa, które „klikają” każdy link w przychodzącej wiadomości, by sprawdzić ją pod kątem malware. Jeśli polegasz na zaangażowaniu w czymkolwiek istotnym, politykach wygaszania, kampaniach reaktywujących (zobacz Zarządzanie listą), potwierdzaj otwarcia kliknięciami i odpowiedziami, zamiast ufać samym otwarciom.
Jak poprawnie skonfigurować endpoint webhooka?
- 1
Odbierz POST od dostawcy
- 2
Zweryfikuj podpis
HMAC, porównanie w stałym czasie, tolerancja czasu.zły podpis → 401, odrzuć - 3
Dedup po ID zdarzenia
już widziane → pomiń (idempotencja) - 4
Rozgałęź po typie zdarzenia
hard bounce → supresjaskarga → supresja + wypisdelivered / open / click → log - 5
Odpowiedz 2xx szybko
Przetwarzaj asynchronicznie. Nie blokuj odpowiedzi.
Są trzy kroki: utwórz endpoint, weryfikuj podpisy przy każdym żądaniu i zarejestruj URL u dostawcy. Zrób te trzy rzeczy poprawnie, a reszta rozdziału (przetwarzanie, idempotencja, ponowienia) buduje na tym fundamencie.
Krok 1: Jak powinien zachowywać się sam endpoint?
Twój endpoint musi:
- Przyjmować żądania POST.
- Szybko zwracać status 2xx (w ciągu 5 sekund).
- Obsługiwać duplikaty zdarzeń (idempotentne przetwarzanie, omówione niżej).
Kod dla zespołu technicznego (TypeScript)
app.post('/webhooks/email', async (req, res) => {
// Zwróć 200 natychmiast, by potwierdzić odbiór
res.status(200).send('OK');
// Przetwarzaj asynchronicznie
processWebhookAsync(req.body).catch(console.error);
});
Najważniejszym wzorcem jest tu najpierw potwierdź, przetwarzaj później. Dostawca daje Ci ciasny budżet, zwróć 2xx w ciągu około 5 sekund, a wolną lub nieudaną odpowiedź interpretuje jako „dostarczenie nieudane”, co wyzwala ponowienia (więcej o tym poniżej). Jeśli Twój handler wykonuje swoją właściwą pracę (zapisy do bazy, aktualizacje list, wysyłanie kolejnych maili), zanim odpowie, wolna baza danych albo czkawka w dół strumienia zamienia się w timeout, ponowienie i ostatecznie podwójne przetwarzanie.
Dlatego handler robi dwie rzeczy w kolejności: (1) wysyła 200 natychmiast, aby potwierdzić odbiór, a następnie (2) uruchamia właściwe przetwarzanie asynchronicznie. Zwróć uwagę na .catch(console.error). Ponieważ odpowiedź została już wysłana, błędu w przetwarzaniu asynchronicznym nie da się zgłosić dostawcy z powrotem, więc musisz go złapać i zalogować sam, inaczej awaria zniknie po cichu. W prawdziwym systemie „przetwarzaj asynchronicznie” powinno oznaczać zakolejkowanie do trwałej kolejki zadań, a nie tylko odpalenie obietnicy w tle. Zobacz rozdział Niezawodność wysyłki, gdzie znajdziesz argument o trwałości.
Kluczowy błąd kolejności: weryfikuj, zanim potwierdzisz
Powyższy fragment jest uproszczony dla idei „najpierw potwierdź”, ale ukrywa pułapkę. Musisz zweryfikować podpis przed zwróceniem 200 i musisz przechwycić surowe ciało zanim dotknie go jakikolwiek parser JSON. Poprawna kolejność wewnątrz handlera to:
- Odczytaj surowe bajty żądania.
- Zweryfikuj podpis względem tych bajtów (odrzuć z
4xx, jeśli nieprawidłowy). - Trwale utrwal surowe zdarzenie (zakolejkuj lub wstaw), to jedyna „praca”, którą wykonujesz synchronicznie, i musi być tania.
- Zwróć
200. - Przetwórz zdarzenie później z kolejki.
Jeśli zwrócisz 200 przed weryfikacją, sfałszowane żądanie zostanie potwierdzone, a atakujący dowie się, że Twój endpoint po cichu połyka wszystko. Jeśli wykonasz ciężkie przetwarzanie (supresję, wysyłki następcze) w kroku 3, ponownie wprowadzasz problem timeoutu. Trwały-ale-tani zapis w kroku 3 jest zawiasem: jest na tyle szybki, by nie wysadzić budżetu, a jednocześnie na tyle trwały, że awaria procesu po 200 nie zgubi zdarzenia.
Jaki kod statusu zwracać i kiedy?
| Sytuacja | Zwróć | Dlaczego |
|---|---|---|
| Zweryfikowane, pomyślnie zakolejkowane | 200 / 204 | Potwierdź; nie ponawiaj |
| Podpis nieprawidłowy lub brakujący | 400 | Trwałe, ponawianie nie pomoże; sygnalizuje manipulację |
| Timestamp poza tolerancją (replay) | 400 | Trwałe dla tego ładunku |
| Twoja kolejka/baza nie działa, chcesz ponowienia | 500 / 503 | Przejściowe, poproś dostawcę o ponowne dostarczenie |
| Nieznany typ zdarzenia, którego nie obsługujesz | 200 | Potwierdź i porzuć; nie wymuszaj ponowień dla zdarzeń, które ignorujesz |
Subtelny jest ostatni wiersz: zwrócenie kodu innego niż 2xx dla zdarzenia, o które po prostu nie dbasz, sprawia, że dostawca ponawia je przez 24 godziny za nic. Potwierdzaj nieznane zdarzenia i je odrzucaj.
Krok 2: Jak weryfikować podpisy webhooków?
Zawsze weryfikuj podpisy webhooków, aby zapobiec podszywaniu (spoofingowi). Twój URL webhooka jest z konieczności publicznym endpointem HTTP. Każdy, kto go odkryje, mógłby wysłać POST-em fałszywe zdarzenia: sfałszowane delivered, by oznaczyć nieudany e-mail jako udany, albo złośliwe bounced, by zasuprymować prawidłowy adres. Weryfikacja podpisu dowodzi, że każde żądanie naprawdę pochodzi od Twojego dostawcy.
Kod dla zespołu technicznego (TypeScript)
import crypto from 'crypto';
// Schemat Standard Webhooks (standardwebhooks.com):
// HMAC-SHA256 nad `${id}.${timestamp}.${rawBody}` przy użyciu współdzielonego sekretu.
// Sekret jest zakodowany w base64 i poprzedzony "whsec_"; zdekoduj przed podpisaniem.
// Gdy istnieje, preferuj oficjalny helper weryfikacyjny dostawcy .
// ta ręcznie napisana wersja pokazuje mechanizm i jest przenośna.
const rawSecret = process.env.EMAIL_WEBHOOK_SECRET!; // "whsec_<base64>"
const secretKey = Buffer.from(rawSecret.replace(/^whsec_/, ''), 'base64');
// express.raw() zachowuje dokładne bajty, które podpisał dostawca
app.post('/webhooks/email', express.raw({ type: 'application/json' }), (req, res) => {
const id = req.header('webhook-id');
const timestamp = req.header('webhook-timestamp');
const signatureHeader = req.header('webhook-signature'); // oddzielone spacją "v1,<b64> v1,<b64>"
const rawBody = (req.body as Buffer).toString('utf8');
if (!id || !timestamp || !signatureHeader) {
return res.status(400).send('Missing signature headers');
}
// 1. Ochrona przed replay: odrzuć cokolwiek starsze (lub nowsze) niż 5 minut.
const now = Math.floor(Date.now() / 1000);
const ts = Number(timestamp);
if (!Number.isFinite(ts) || Math.abs(now - ts) > 300) {
return res.status(400).send('Timestamp outside tolerance');
}
// 2. Przelicz oczekiwany podpis nad DOKŁADNYMI otrzymanymi bajtami.
const signedContent = `${id}.${timestamp}.${rawBody}`;
const expected = crypto.createHmac('sha256', secretKey).update(signedContent).digest('base64');
// 3. Nagłówek może nieść WIELE podpisów (rotacja klucza). Zaakceptuj, jeśli KTÓRYKOLWIEK pasuje,
// każdy porównywany w czasie stałym.
const candidates = signatureHeader.split(' ')
.map((part) => part.split(',')[1]) // usuń prefiks wersji "v1,"
.filter(Boolean);
const ok = candidates.some((provided) => {
const a = Buffer.from(provided, 'base64');
const b = Buffer.from(expected, 'base64');
return a.length === b.length && crypto.timingSafeEqual(a, b);
});
if (!ok) return res.status(400).send('Invalid signature');
// Zweryfikowane. Utrwal tanio, potem potwierdź.
enqueueRawEvent(id, rawBody).then(
() => res.status(200).send('OK'),
() => res.status(503).send('Queue unavailable'), // przejściowe -> dostawca ponawia
);
});
Jak działa weryfikacja: dostawca podpisuje każdy ładunek współdzielonym sekretem i wysyła podpis w nagłówku. Przeliczasz HMAC-SHA256 nad podpisaną treścią i porównujesz z nagłówkiem w czasie stałym przez crypto.timingSafeEqual. Jeśli się nie zgadzają, odrzucasz żądanie z kodem 400. Dokładne nazwy nagłówków i kodowanie podpisu różnią się między dostawcami, więc sprawdź ich dokumentację lub użyj ich helpera weryfikacyjnego. Liczy się kilka szczegółów operacyjnych:
- Podpisuj nad surowym ciałem. Podpis jest liczony względem dokładnych bajtów, które wysłał dostawca. Jeśli Twój framework parsuje i ponownie serializuje JSON przed weryfikacją, bajty mogą się zmienić (kolejność kluczy, białe znaki, escapowanie Unicode, końcowa nowa linia) i weryfikacja zawiedzie. Powyższy fragment używa
express.raw()do przechwycenia oryginalnych bajtów; zawsze weryfikuj względem niezmienionego ładunku. W route handlerach Next.js odczytajawait req.text()i wyłącz parsowanie ciała; w AWS Lambda za API Gateway uważaj na ciała zakodowane w base64 (event.isBase64Encoded). - Chroń przed replay za pomocą timestampu. Dostawcy dołączają nagłówek z timestampem i wplatają go w podpisaną treść; odrzucaj wszystko poza kilkoma minutami (tolerancja ~5 minut jest typowa), aby atakujący nie mógł przechwycić prawidłowego żądania i odtworzyć go dni później. Utrzymuj zegar serwera zsynchronizowany przez NTP, inaczej prawidłowe żądania mogą oblać tę kontrolę.
- Tylko porównanie w czasie stałym. Używaj
crypto.timingSafeEqual, nigdy===aniBuffer.comparedo decyzji, i najpierw zabezpiecz długość (timingSafeEqualrzuca wyjątkiem przy niezgodności długości). Naiwne===ujawnia przez czasowanie, ile wiodących bajtów się zgodziło, co przy wielu próbach może pozwolić atakującemu sfałszować podpis bajt po bajcie. - Wspieraj wiele aktywnych podpisów. Podczas rotacji sekretu dostawca może wysłać dwa podpisy w jednym nagłówku. Zaakceptuj żądanie, jeśli którykolwiek kandydat się weryfikuje. To pozwala rotować sekret bez przestoju: dodaj nowy sekret, poczekaj, aż weryfikuje się ruch i stary, i nowy, potem wycofaj stary.
- Trzymaj sekret w tajemnicy. Mieszka w zmiennej środowiskowej lub menedżerze sekretów, nigdy w repozytorium kodu. Każdy, kto go ma, może fałszować zdarzenia. Rotuj go, jeśli kiedykolwiek wycieknie.
Co, jeśli mój dostawca nie używa Standard Webhooks?
Nie każdy dostawca używa trójki webhook-id/webhook-timestamp/webhook-signature. To kształt weryfikacji się przenosi, nie nazwy nagłówków. Częste warianty, które napotkasz:
- Zwykły HMAC nad ciałem, podpis w jednym nagłówku. Styl Mailgun: dostawca wysyła
timestamp,tokenisignature, a Ty liczyszHMAC-SHA256(key, timestamp + token). To samo porównanie w czasie stałym, to samo okno replay na timestampie. - HMAC z podpisem nad samym ciałem. Niektórzy dostawcy podpisują tylko surowe ciało i wysyłają skrót hex w nagłówku (np.
X-Signature). Dekoduj hex zamiast base64; reszta jest identyczna. - Podpisy szyny powiadomień. Amazon SES nie wykonuje POST bezpośrednio; publikuje do SNS, które podpisuje każdą wiadomość certyfikatem X.509. Weryfikacja oznacza tam pobranie certyfikatu podpisującego z
SigningCertURL(zweryfikuj, że host to endpoint SNS wamazonaws.com, przed pobraniem), zweryfikowanie podpisu wiadomości i potwierdzenie subskrypcji. Użyj helpera walidacji wiadomości SNS z AWS SDK, zamiast pisać to ręcznie. - Allowlistowanie źródłowych adresów IP to słabe uzupełnienie, nie zamiennik. IP się zmieniają, a wyciekły URL plus możliwe do sfałszowania źródło nie jest tak silne jak podpis kryptograficzny. Weryfikuj podpis niezależnie.
Niezależnie od schematu inwarianty są te same: weryfikuj względem surowych bajtów, porównuj w czasie stałym, wymuszaj okno timestampu i odrzucaj przy każdej awarii.
Krok 3: Jak zarejestrować URL webhooka?
Skonfiguruj endpoint webhooka w panelu swojego dostawcy lub przez jego API. To tutaj mówisz dostawcy, który URL wywoływać i jakie typy zdarzeń chcesz tam otrzymywać. Kilka praktycznych wskazówek:
- Rejestruj endpoint per środowisko: osobny URL i sekret dla staging i produkcji, aby zdarzenia testowe nigdy nie trafiały do danych produkcyjnych.
- Subskrybuj tylko te zdarzenia, które faktycznie obsługujesz, by zmniejszyć szum i obciążenie.
- Używaj HTTPS, nigdy zwykłego HTTP, aby ładunku i nagłówków nie dało się odczytać w transmisji.
- Umieść endpoint na stabilnej, nieuwierzytelnionej-ale-chronionej-podpisem ścieżce. Nie chowaj go za uwierzytelnieniem sesyjnym swojej aplikacji, dostawca nie ma sesji. Weryfikacja podpisu jest uwierzytelnieniem.
- Jeśli Twój dostawca to wspiera, ogranicz źródło subskrypcji tak, aby tylko udokumentowane wyjściowe adresy IP dostawcy mogły sięgnąć ścieżki na Twoim brzegu/CDN, jako obronę w głąb na wierzchu podpisów.
Checklista konfiguracji endpointu
- Endpoint przyjmuje POST i zwraca
2xxw ciągu 5 sekund. - Podpis jest weryfikowany przed wysłaniem odpowiedzi.
- Weryfikacja działa na surowym, niezmodyfikowanym ciele.
- Wymuszone jest okno replay timestampu (~5 min).
- Porównanie jest w czasie stałym i z zabezpieczoną długością.
- Wiele podpisów jest akceptowane podczas rotacji.
- Sekret webhooka mieszka w zmiennej środowiskowej / menedżerze sekretów, nie w kodzie.
- Osobne endpointy i sekrety dla staging i produkcji.
- Endpoint jest serwowany przez HTTPS.
- Nieznane typy zdarzeń są potwierdzane kodem
200, nie oblewane.
Jak przetwarzać każdy typ zdarzenia?
Gdy ładunek jest zweryfikowany i pobrany z kolejki, skieruj go do handlera odpowiedniego dla typu zdarzenia. Trzy poniższe to te o realnych konsekwencjach: odbicia, skargi i dostarczenia.
Jak obsłużyć odbicie (bounce)?
Odbicie oznacza, że serwer odbiorcy odmówił przyjęcia wiadomości. Krytyczne rozróżnienie to twarde versus miękkie:
Kod dla zespołu technicznego (TypeScript)
async function handleBounce(event: CanonicalEvent) {
const { email, messageId } = event;
if (event.bounceClass === 'hard') {
// Trwała awaria - usuń ze wszystkich list
await suppressEmail(email, 'hard_bounce');
await removeFromAllLists(email);
} else {
// Miękkie odbicie - śledź i usuń po przekroczeniu progu
await incrementSoftBounce(email);
const count = await getSoftBounceCount(email);
if (count >= 3) {
await suppressEmail(email, 'soft_bounce_limit');
}
}
await updateEmailStatus(messageId, 'bounced');
}
Logika odzwierciedla dwa tryby awarii:
- Odbicie twarde (hard) to awaria trwała (adres nie istnieje, domena jest nieprawidłowa). Próbowanie ponownie nie ma sensu, więc suprymujesz natychmiast i usuwasz ze wszystkich list. Dalsze wysyłanie na adres z twardym odbiciem to jeden z najszybszych sposobów na uszkodzenie reputacji nadawcy, bo dostawcy skrzynek odczytują to jako sygnał, że nie dbasz o swoją listę.
- Odbicie miękkie (soft) to awaria tymczasowa (pełna skrzynka, chwilowo niedostępny serwer, zbyt duża wiadomość). Adres może wrócić do sprawności, więc nie suprymujesz przy pierwszym. Zamiast tego liczysz odbicia miękkie i suprymujesz dopiero po przekroczeniu progu (tu
>= 3). Ta tolerancja pozwala uniknąć odrzucenia dobrego adresu z powodu przejściowej czkawki, jednocześnie odcinając adres trwale nieosiągalny.
Obie ścieżki ostatecznie zasilają Twoją listę supresji, która jest jedynym źródłem prawdy sprawdzanym przed każdą wysyłką (zobacz Zarządzanie listą).
Co liczy się jako odbicie twarde, a co jako miękkie?
| Typ odbicia | Typowe przyczyny | Działanie |
|---|---|---|
| Twarde | Adres nie istnieje, nieprawidłowa domena, wyłączona skrzynka | Supresja przy pierwszym zdarzeniu |
| Miękkie | Pełna skrzynka, chwilowo niedostępny serwer, zbyt duża wiadomość, greylisting | Licz, suprymuj po 3 z rzędu |
Praktyczna zasada: nigdy nie traktuj pojedynczego miękkiego odbicia jako awarii i nigdy nie dawaj twardemu odbiciu drugiej szansy. Jeśli Twój dostawca raportuje podtyp lub kod SMTP, zaloguj go, abyś mógł później audytować przypadki graniczne.
Czytaj rozszerzony kod statusu SMTP, nie tylko twarde/miękkie
Flaga twarde/miękkie dostawcy to podsumowanie. Bazowa odpowiedź SMTP niesie rozszerzony kod statusu (RFC 3463, trójka class.subject.detail), który mówi dlaczego, a działanie na podstawie detalu to właśnie to, co odróżnia kompetentny potok odbić od prymitywnego. Pierwsza cyfra to klasa: 2.x.x sukces, 4.x.x przejściowe (miękkie), 5.x.x trwałe (twarde).
| Kod | Znaczenie | Poprawne działanie |
|---|---|---|
5.1.1 | Zła skrzynka docelowa (brak takiego użytkownika) | Twarde, suprymuj natychmiast |
5.1.2 | Zła domena docelowa | Twarde, suprymuj; rozważ oflagowanie domeny |
5.2.2 | Pełna skrzynka | Często raportowane jako twarde, ale w istocie przejściowe, traktuj jako miękkie i licz do ponowienia |
5.7.1 | Dostarczenie nieautoryzowane / zablokowane (polityka) | Zbadaj: może to blokada treści lub reputacji, a nie martwy adres |
5.7.26 | Awaria uwierzytelnienia (SPF/DKIM/DMARC) | To nie problem higieny listy, napraw uwierzytelnienie (zobacz niżej) |
4.2.2 | Pełna skrzynka (przejściowe) | Miękkie, licz |
4.7.x | Tymczasowa polityka / limitowanie tempa / greylisting | Miękkie, licz; wycofuj się (back off) |
Odbicia z powodu awarii uwierzytelnienia, takie jak 5.7.26, mają szczególne znaczenie: oznaczają, że serwer odbierający odrzucił Twoją pocztę, bo zawiodło dopasowanie Twojego SPF (RFC 7208), DKIM (RFC 6376) lub DMARC (RFC 7489). Zasuprymowanie odbiorcy byłoby tu dokładnie błędem, adres jest w porządku; to Twoje uwierzytelnienie jest zepsute. Odbicia z kodami związanymi z uwierzytelnieniem powinny wzywać Twój zespół, a nie czyścić listę. Zobacz Dostarczalność, jak naprawić bazowe rekordy.
Częsty błąd: złe zaszufladkowanie „pełnej skrzynki”
Wielu dostawców raportuje pełną skrzynkę (5.2.2) jako odbicie trwałe/twarde, bo kod odpowiedzi zaczyna się od 5. Pełna skrzynka jest niemal zawsze tymczasowa. Jeśli zasuprymujesz przy pierwszym, trwale tracisz odbiorcę, którego skrzynka chwilowo przekroczyła limit. Nadpisz klasyfikację dostawcy dla znanych przejściowych przyczyn 5.2.x i traktuj je jako miękkie.
Jak obsłużyć skargę (complaint)?
Skarga oznacza, że odbiorca kliknął „oznacz jako spam”. To najpoważniejszy sygnał, jaki możesz otrzymać, i nie dopuszcza żadnych niuansów, progów ani drugiej szansy:
Kod dla zespołu technicznego (TypeScript)
async function handleComplaint(event: CanonicalEvent) {
const { email } = event;
// Natychmiastowa supresja - bez wyjątków
await suppressEmail(email, 'complaint');
await removeFromAllLists(email);
await logComplaint(event); // Do analizy
}
Dlaczego traktowanie zero-tolerancji: skarga na spam to bezpośrednie, jednoznaczne stwierdzenie, że odbiorca nie chce Twojej poczty. Wysłanie mu czegokolwiek więcej, nawet wiadomości „przykro nam, że odchodzisz”, ryzykuje kolejną skargą. Wysokie wskaźniki skarg są mocno ważone przez dostawców skrzynek i należą do najszybszych dróg do folderu spam dla wszystkich Twoich odbiorców, nie tylko tego, który złożył skargę.
Progi nie są arbitralne. Wymagania dla nadawców masowych Gmaila, Yahoo i Microsoftu, które weszły w życie w lutym 2024, czynią to jednoznacznym dla każdego, kto wysyła do tych dostawców na dużą skalę (z grubsza 5000+ wiadomości dziennie do danego dostawcy liczy się jako nadawca masowy): musisz utrzymać wskaźnik skarg na spam, mierzony przez Postmaster Tools Google, poniżej 0,3%, a powinieneś celować znacznie niżej, praktyczny cel to poniżej 0,1% (jedna skarga na tysiąc wysłanych). Przekrocz 0,3%, a Gmail aktywnie dławi lub wrzuca do spamu Twoją pocztę. Te same wymagania nakazują też pocztę uwierzytelnioną (SPF + DKIM + DMARC) oraz jednoklikowy wypis zgodnie z RFC 8058 (nagłówek List-Unsubscribe plus List-Unsubscribe-Post: List-Unsubscribe=One-Click, obsługiwany przez POST). Skargi i wypisy to dwie strony tej samej monety: bezproblemowy wypis jest zaworem bezpieczeństwa, który utrzymuje wskaźnik skarg w ryzach. Zobacz Zgodność prawna i Niezawodność wysyłki, gdzie znajdziesz mechanikę nagłówków.
Dlatego suprymujesz natychmiast i bezwarunkowo, usuwasz z każdej listy i logujesz skargę, aby móc przeanalizować, która kampania lub wiadomość generuje skargi, i naprawić przyczynę źródłową.
Skargi nadchodzą późno i czasem zanonimizowane
Dwie realia operacyjne, pod które trzeba zaprojektować:
- Opóźnienie. Informacje zwrotne o skargach (często przez pętle zwrotne ARF, RFC 5965) mogą opóźniać się względem wysyłki o godziny lub dni. Twój wskaźnik skarg dla kampanii nie jest ostateczny, dopóki to okno nie minie. Nie ogłaszaj wysyłki „czystą” w chwili zakończenia dostarczania.
- Zanonimizowani odbiorcy. Niektóre pętle zwrotne redagują skarżący adres (Microsoftu, historycznie). Gdy adres brakuje lub jest zahaszowany, nie możesz suprymować bezpośrednio po adresie. To jeden z powodów, by osadzić identyfikator per odbiorca w niestandardowym nagłówku lub w metadanych wiadomości w chwili wysyłki, aby zredagowaną skargę można było mimo to powiązać z odbiorcą przez identyfikator wiadomości od dostawcy.
Jak obsłużyć potwierdzenie dostarczenia?
Kod dla zespołu technicznego (TypeScript)
async function handleDelivered(event: CanonicalEvent) {
const { messageId } = event;
await updateEmailStatus(messageId, 'delivered');
}
To najprostszy handler i tu identyfikator wiadomości, który zapisałeś w chwili wysyłki, zarabia na siebie: wyszukujesz oryginalny rekord wysyłki po tym ID i oznaczasz go jako delivered. Teraz Twój system może z autorytetem odpowiedzieć na pytanie „czy ten e-mail faktycznie dotarł?”. Jest to przydatne dla supportu, do pokazywania statusu dostarczenia w interfejsie oraz do wychwytywania wiadomości, które były sent, ale nigdy delivered, co jest oznaką cichego problemu z dostarczaniem.
Uważaj na dostarczanie poza kolejnością
Webhooki nie mają gwarancji, że nadejdą w kolejności, w jakiej zaszły zdarzenia. Ponowienia, równoległe workery dostarczające i rebalansowanie kolejki, wszystko to zmienia kolejność zdarzeń. Możesz otrzymać delivered po tym, jak już otrzymałeś opened dla tej samej wiadomości, albo ponowienie sent po delivered. Uczyń przejścia statusu monotonicznymi: zapisuj każde zdarzenie i licz bieżący status jako najdalej posunięty stan, jaki kiedykolwiek widziano dla tej wiadomości, nigdy przez ślepe nadpisywanie tym, jakie zdarzenie nadeszło ostatnie. Dostarczona wiadomość, która później otrzyma przeterminowane ponowienie sent, musi pozostać dostarczona.
Kod dla zespołu technicznego (TypeScript)
// Uszereguj stany, by spóźnione zdarzenie poza kolejnością nigdy nie cofnęło statusu.
const RANK: Record<string, number> = {
queued: 0, sent: 1, delivered: 2, opened: 3, clicked: 4,
};
// Terminalne stany awarii są śledzone na osobnej osi (bounced/complained),
// bo dostarczona wiadomość wciąż może stać się skarżona.
async function advanceStatus(messageId: string, next: string) {
const current = await getStatus(messageId);
if ((RANK[next] ?? -1) > (RANK[current] ?? -1)) {
await setStatus(messageId, next);
}
}
Dlaczego mój handler webhooka musi być idempotentny?
Webhooki mogą być wysyłane wielokrotnie. To nie błąd. To bezpośrednia konsekwencja modelu „najpierw potwierdź, ponów przy braku 2xx”. Jeśli Twoja odpowiedź 200 jest opóźniona, zgubiona w transmisji albo serwer zrestartuje się w trakcie przetwarzania, dostawca zakłada awarię i dostarcza to samo zdarzenie ponownie. Jeśli Twój handler nie jest idempotentny, to ponowne dostarczenie podwójnie zliczy otwarcie, podwójnie zasuprymuje albo dwa razy wyśle wiadomość następczą.
Warto być precyzyjnym: większość dostawców gwarantuje dostarczenie co najmniej raz (at-least-once), nie dokładnie raz. Dokładnie-raz to coś, co Ty budujesz na wierzchu co-najmniej-raz przez deduplikację. Każdy, kto mówi Ci, że jego webhooki są dokładnie-raz, opisuje cel, a nie gwarancję sieciową.
Rozwiązaniem jest deduplikacja po identyfikatorze zdarzenia, dokładnie odzwierciedlająca wzorzec klucza idempotencji ze strony wysyłkowej:
Kod dla zespołu technicznego (TypeScript)
async function processWebhook(event: CanonicalEvent) {
const eventId = event.id;
// Sprawdź, czy już przetworzone
if (await isEventProcessed(eventId)) {
return; // Pomiń duplikat
}
// Przetwórz zdarzenie
await handleEvent(event);
// Oznacz jako przetworzone
await markEventProcessed(eventId);
}
Każde zdarzenie niesie stabilne event.id. Przed wykonaniem jakiejkolwiek pracy sprawdź, czy już przetworzyłeś to ID. Jeśli tak, wyjdź wcześniej. W przeciwnym razie przetwórz je, a potem zapisz ID jako przetworzone.
Co, jeśli mój dostawca nie wysyła stabilnego ID zdarzenia?
Niektórzy dostawcy pomijają unikalne ID zdarzenia albo wysyłają takie, które zmienia się przy ponowieniu (co czyni je bezużytecznym do deduplikacji). Gdy tak się dzieje, syntetyzuj deterministyczny klucz z pól, które są stabilne między ponowieniami tego samego zdarzenia:
Kod dla zespołu technicznego (TypeScript)
import crypto from 'crypto';
// Zbuduj stabilny klucz deduplikacji, gdy dostawca nie daje wiarygodnego id zdarzenia.
// Użyj pól identycznych między ponowieniami tego samego logicznego zdarzenia.
function dedupeKey(e: CanonicalEvent): string {
const basis = [e.messageId, e.type, e.email, e.occurredAt.toISOString()].join('|');
return crypto.createHash('sha256').update(basis).digest('hex');
}
Wybierz pola, które jednoznacznie identyfikują zdarzenie, ale nie różnią się między ponowieniami (timestamp wystąpienia, nie timestamp dostarczenia). Zahaszuj je do klucza o stałej długości i użyj go jako klucza idempotencji.
Na jaki subtelny błąd uważać w idempotentnym przetwarzaniu?
Idealnie kroki „przetwórz” i „oznacz jako przetworzone” dzieją się w tej samej transakcji bazodanowej, aby awaria między nimi nie mogła zostawić zdarzenia w zepsutym stanie:
- Obsłużone, ale nieoznaczone: kolejne ponowienie przetworzy je ponownie, czyli dokładnie to podwójne przetwarzanie, którego chciałeś uniknąć.
- Oznaczone, ale nieobsłużone: zdarzenie zostaje po cichu porzucone i nigdy nie wykonane.
Owinięcie obu kroków w jedną transakcję (albo użycie upsert na ID zdarzenia jako pierwszego zapisu) usuwa oba tryby awarii. Najbardziej odporny wzorzec to uczynienie wstawienia deduplikującego pierwszym zapisem i pozwolenie, by naruszenie unikalnego ograniczenia zwarło resztę:
Kod dla zespołu technicznego (TypeScript)
async function processOnce(event: CanonicalEvent) {
// INSERT ... ON CONFLICT DO NOTHING. Jeśli wiersz już istnieje, rowCount === 0
// i wiemy, że inne dostarczenie już zajęło to zdarzenie.
const { rowCount } = await db.query(
`INSERT INTO processed_events (event_id, created_at)
VALUES ($1, now()) ON CONFLICT (event_id) DO NOTHING`,
[event.id],
);
if (rowCount === 0) return; // duplikat, już zajęte
// Wykonaj właściwą pracę w TEJ SAMEJ transakcji co zajęcie, gdzie to możliwe,
// aby awaria tutaj wycofała zajęcie i zdarzenie zostało czysto ponowione.
await handleEvent(event);
}
Idempotentne przetwarzanie sprawia, że agresywne ponowienia dostawcy są bezpieczne, a nie niebezpieczne. Przechowuj przetworzone ID zdarzeń z oknem retencji (na przykład 30 dni), aby tabela deduplikacji nie rosła bez końca; dostawca przestaje ponawiać na długo przed tym terminem. Proste zaplanowane zadanie usuwające wiersze starsze niż okno retencji utrzymuje ją w ryzach.
Co się dzieje, gdy mój endpoint zwróci błąd?
Jeśli Twój endpoint zwróci status inny niż 2xx (lub przekroczy timeout), webhooki będą ponawiane z wykładniczym backoffem:
- Ponowienie 1: około 30 sekund
- Ponowienie 2: około 1 minuta
- Ponowienie 3: około 5 minut
- (kontynuowane, z rosnącymi odstępami, przez około 24 godziny)
Dokładny harmonogram i całkowite okno różnią się między dostawcami, niektórzy ponawiają przez 24 godziny, niektórzy do trzech dni, niektórzy ograniczają liczbę prób zamiast czasu trwania. Przeczytaj udokumentowaną politykę swojego dostawcy i projektuj pod najkrótsze okno, jakie możesz napotkać, a nie najhojniejsze.
To dostawca działający jako dobry obywatel w Twoim imieniu: przejściowa awaria po Twojej stronie nie zgubi zdarzeń, bo dostawca próbuje przez około dzień. Ale ma to bezpośrednie konsekwencje dla tego, jak budujesz endpoint.
- To powód, dla którego idempotencja jest obowiązkowa. Każde ponowienie dostarcza to samo zdarzenie ponownie.
- Trwale zepsuty endpoint w końcu się podda. Musisz monitorować powtarzające się awarie webhooków i naprawiać je w oknie ponowień, inaczej utracisz te zdarzenia na stałe.
Zwróć uwagę na symetrię ze stroną wysyłkową z poprzedniego rozdziału: oba kierunki używają wykładniczego backoffu, oba ponawiają tylko awarie przejściowe i oba zatrzymują się po ograniczonym oknie. Zobacz Niezawodność wysyłki.
Co się dzieje, gdy dostawca się podda? Rekoncyliacja.
Okno ponowień jest skończone, więc dostatecznie długa awaria zgubi zdarzenia na dobre. Obroną jest rekoncyliacja: okresowy przegląd, który porównuje Twój widok świata z widokiem dostawcy i wypełnia luki. Dwa praktyczne mechanizmy:
- API eksportu / wyszukiwania zdarzeń dostawcy. Wielu dostawców udostępnia API do listowania zdarzeń w zakresie czasu. Uruchom zadanie, które dla każdej wiadomości utkniętej w
sentpoza rozsądnym SLA dostarczenia odpytuje historię zdarzeń dostawcy i stosuje wszystko, co przegapiłeś. - Większość dostawców trzyma też log w panelu i widok zdrowia endpointu. Większość dostawców udostępnia log dostarczeń webhooków pokazujący nieudane dostarczenia oraz ręczny przycisk „replay”. Po naprawieniu awarii odtwórz (replay) nieudane dostarczenia z tego okna. Większość dostawców publikuje też przewodnik po ingestii lub wzorcową implementację, sprawdź dokumentację webhooków swojego dostawcy.
Rekoncyliacja jest też Twoją siatką bezpieczeństwa wobec realiów co-najmniej-raz-ale-czasem-zero każdego systemu sieciowego. Traktuj webhooki jako szybką ścieżkę, a rekoncyliację jako zaplecze poprawności.
Wyłączone endpointy i bezpieczniki (circuit breakers)
Niektórzy dostawcy automatycznie wyłączają endpoint webhooka, który zawodzi przez dłuższy okres (na przykład wysoki wskaźnik błędów przez wiele kolejnych godzin), i przestają do niego wysyłać całkowicie, dopóki go ponownie nie włączysz. To ostra krawędź: wdrożenie, które psuje Twój endpoint na kilka godzin, może doprowadzić do wyłączenia webhooka, po czym po cichu nie otrzymujesz niczego, nawet gdy naprawisz kod. Monitoruj stan włączenia/zdrowia endpointu w panelu dostawcy i alertuj, jeśli wolumen dostarczeń niespodziewanie spadnie do zera.
Jakie są dobre praktyki dla produkcyjnego potoku webhooków?
- Szybko zwracaj 200: przetwarzaj asynchronicznie, aby uniknąć timeoutów.
- Bądź idempotentny: obsługuj duplikaty dostaw z gracją.
- Loguj wszystko: przechowuj surowe zdarzenia do debugowania.
- Alertuj przy awariach: monitoruj błędy przetwarzania webhooków.
- Kolejkuj do przetwarzania: używaj kolejki zadań do złożonej obsługi.
Każda z tych pozycji odpowiada problemowi, który inaczej napotkasz na produkcji:
- Szybko zwracaj 200 zapobiega timeoutowi po stronie dostawcy i wyzwoleniu opisanej wyżej burzy ponowień.
- Bądź idempotentny sprawia, że te ponowienia są nieszkodliwe.
- Loguj wszystko oznacza przechowywanie surowych ładunków zdarzeń. Kiedy trzy tygodnie później będziesz musiał zdebugować „dlaczego ten adres został zasuprymowany?”, surowe zdarzenie jest Twoją podstawą prawdy i pozwala odtworzyć przetwarzanie, jeśli handler miał błąd.
- Alertuj przy awariach ma znaczenie, bo po cichu padający potok webhooków oznacza, że Twoja lista supresji niepostrzeżenie gnije. Monitoruj błędy przetwarzania i wzywaj człowieka przy utrzymującej się awarii.
- Kolejkuj do przetwarzania oznacza, że dla czegokolwiek poza trywialną aktualizacją statusu zakolejkuj zweryfikowane zdarzenie do trwałej kolejki zadań. To ten sam argument trwałości, co po stronie wysyłkowej: przetrwa restarty i odseparowuje odbiór od przetwarzania.
Co monitorować i na co alertować?
Potok webhooków pada po cichu, więc to alerty, które podłączysz, utrzymują go w uczciwości. Sygnały o wysokiej wartości:
| Metryka | Alertuj, gdy | Dlaczego to ważne |
|---|---|---|
| Wskaźnik 5xx / timeoutów endpointu | Jakikolwiek utrzymujący się niezerowy | Jesteś wewnątrz okna ponowień i tracisz czas, zanim zdarzenia zostaną upuszczone |
| Wolumen przychodzących zdarzeń | Niespodziewanie spada do ~0 | Endpoint mógł zostać auto-wyłączony albo rejestracja została utracona |
| Liczba awarii podpisu | Skacze powyżej tła | Próby manipulacji albo błąd rotacji sekretu |
| Głębokość kolejki / opóźnienie przetwarzania | Rośnie bez opróżniania | Konsumenci utknęli; zdarzenia są odbierane, ale nie wykonywane |
| Wskaźnik odbić (kroczący) | Powyżej ~2% | Problem higieny listy lub incydent dostarczalności (zobacz Dostarczalność) |
| Wskaźnik skarg (kroczący) | Powyżej 0,1% (twardy limit 0,3%) | Zbliżasz się do progu nadawcy masowego z lutego 2024 |
| Rozmiar kolejki martwych listów (dead-letter) | Jakikolwiek wzrost | Zdarzenia, które wielokrotnie zawiodły w przetwarzaniu, wymagają uwagi człowieka |
Wysyłaj trwale nieprzetwarzalne zdarzenia do kolejki martwych listów (dead-letter), zamiast ponawiać je w nieskończoność. Ładunek, którego handler nie potrafi sparsować, nigdy się nie powiedzie; odizoluj go do inspekcji, zamiast pozwolić mu blokować potok.
Jakie są najczęstsze błędy przy webhookach?
- Wykonywanie właściwej pracy przed wysłaniem
200, co powoduje timeouty i burzę ponowień. - Zwracanie
200przed weryfikacją podpisu, co potwierdza sfałszowane zdarzenia. - Całkowite pomijanie weryfikacji podpisu, co zostawia endpoint otwarty na sfałszowane zdarzenia.
- Weryfikacja względem ponownie zserializowanego ciała zamiast surowych bajtów, przez co prawidłowe podpisy zawodzą.
- Porównywanie podpisów przez
===zamiast funkcji w czasie stałym. - Pominięcie kontroli replay timestampu, co pozostawia przechwycone żądania możliwymi do odtworzenia.
- Zapominanie o idempotencji, przez co ponowienia podwójnie zliczają lub podwójnie suprymują.
- Traktowanie pojedynczego miękkiego odbicia jako trwałego albo danie twardemu odbiciu drugiej szansy.
- Suprymowanie przy odbiciu z awarii uwierzytelnienia (
5.7.26) zamiast naprawy SPF/DKIM/DMARC. - Złe zaszufladkowanie „pełnej skrzynki” jako twardego odbicia.
- Nadpisywanie statusu ostatnim otrzymanym zdarzeniem zamiast najdalej posuniętym stanem (błąd poza kolejnością).
- Oblewanie nieobsługiwanych typów zdarzeń kodem innym niż 2xx, wyzwalając 24 godziny bezsensownych ponowień.
- Brak monitoringu potoku, przez co awarie niezauważone gniją na liście supresji.
Jak przetestować webhooki przed wdrożeniem na produkcję?
Webhooki są niewygodne w testowaniu, bo dostawca musi sięgnąć do publicznego URL, a Twój kod działa na localhost.
Do rozwoju lokalnego użyj narzędzia tunelującego, aby wystawić localhost. ngrok to popularny wybór; alternatywy to cloudflared (Cloudflare Tunnel) oraz własne CLI Twojego dostawcy, jeśli je oferuje.
ngrok http 3000
# Użyj wypisanego URL https://<random>.ngrok-free.app jako endpointu webhooka
Tunel tworzy publiczny adres kierowany do Twojego lokalnego portu, dając Ci tymczasowy publiczny URL, który możesz zarejestrować jako endpoint webhooka. Teraz prawdziwe zdarzenia od dostawcy docierają do Twojego lokalnego handlera, gdzie możesz ustawiać breakpointy i inspekcjonować ładunki. Pamiętaj, że URL tunelu zmienia się przy każdym restarcie w darmowym planie, więc rejestruj go ponownie, gdy się zmieni (zarezerwowana/statyczna domena tego unika).
Generowanie prawidłowo podpisanego żądania bez dostawcy
Nie zawsze chcesz zależeć od dostawcy, by testować. Ponieważ schemat podpisu to po prostu HMAC nad id.timestamp.body, możesz wytworzyć poprawnie podpisane żądanie lokalnie i wysłać je POST-em przez curl, co pozwala deterministycznie testować weryfikację, idempotencję i każdy handler:
Polecenia do weryfikacji
#!/usr/bin/env bash
# Podpisz ładunek w sposób Standard Webhooks i wyślij POST-em do lokalnego endpointu.
SECRET_B64="${EMAIL_WEBHOOK_SECRET#whsec_}" # część base64 z "whsec_..."
ID="msg_$(date +%s)"
TS="$(date +%s)"
BODY='{"type":"email.bounced","data":{"email":"nobody@example.com","bounce_type":"hard"}}'
SIGNED="${ID}.${TS}.${BODY}"
SIG=$(printf '%s' "$SIGNED" \
| openssl dgst -sha256 -mac HMAC -macopt "hexkey:$(printf '%s' "$SECRET_B64" | base64 -d | xxd -p -c 256)" -binary \
| base64)
curl -sS -X POST http://localhost:3000/webhooks/email \
-H "content-type: application/json" \
-H "webhook-id: $ID" \
-H "webhook-timestamp: $TS" \
-H "webhook-signature: v1,$SIG" \
--data-raw "$BODY"
Aby zweryfikować obsługę, wyślij zdarzenia testowe przez panel swojego dostawcy lub skryptem takim jak powyższy. Nie testuj tylko szczęśliwej ścieżki. Celowo przećwicz każdy handler i każdy tryb awarii:
- Wyzwól twarde odbicie i potwierdź, że adres jest suprymowany.
- Wyzwól miękkie odbicie trzy razy i potwierdź supresję dopiero przy trzecim.
- Wyzwól skargę i potwierdź natychmiastowe usunięcie.
- Wyślij to samo zdarzenie dwa razy i potwierdź, że Twoje sprawdzenie idempotencji pomija duplikat.
- Wyślij ładunek z błędnym podpisem i potwierdź, że odrzucasz go z kodem
400. - Wyślij ładunek z przeterminowanym timestampem i potwierdź, że ochrona przed replay go odrzuca.
- Wyślij
openedprzeddelivereddla jednej wiadomości i potwierdź, że status się nie cofa. - Wyślij odbicie z awarii uwierzytelnienia
5.7.26i potwierdź, że alertujesz, a nie suprymujesz. - Wprowadź wolny handler w jego ścieżkę timeoutu i potwierdź, że ponowienie jest przetwarzane nieszkodliwie.
Checklista przetwarzania i testów
- Każdy typ zdarzenia ma dedykowany, przetestowany handler.
- Twarde odbicia i skargi suprymują natychmiast; miękkie odbicia używają progu.
- „Pełna skrzynka” i odbicia z awarii uwierzytelnienia są klasyfikowane poprawnie.
- Przetwarzanie deduplikuje po
event.id(lub zsyntetyzowanym stabilnym kluczu). - „Przetwórz” i „oznacz jako przetworzone” są atomowe tam, gdzie to możliwe.
- Przejścia statusu są monotoniczne (brak cofania przy zdarzeniach poza kolejnością).
- Surowe ładunki zdarzeń są przechowywane do debugowania i odtwarzania.
- Awarie przetwarzania webhooków i warunki zerowego wolumenu wyzwalają alerty.
- Przypadki podwójnego dostarczenia, błędnego podpisu i replay są jawnie przetestowane.
Czy przechowywać pełną historię webhooków, czy tylko najnowszy status?
Przechowuj pełną historię zdarzeń, a nie tylko najnowszy status. Typowy schemat to append-only tabela email_events kluczowana po event.id dostawcy, przechowująca typ zdarzenia, ID powiązanej wiadomości, timestamp oraz surowy ładunek JSON, a bieżący status pojedynczej wiadomości wyprowadzasz z niej (albo trzymasz w osobnej tabeli aktualizowanej w miarę napływu zdarzeń). Większość dostawców publikuje też wzorcową implementację lub przewodnik po ingestii; sprawdź dokumentację webhooków swojego dostawcy.
Wykonalny schemat:
Pokaż przykład
-- Log zdarzeń append-only: podstawa prawdy, nigdy nie aktualizowany w miejscu.
CREATE TABLE email_events (
event_id TEXT PRIMARY KEY, -- id dostawcy lub zsyntetyzowany klucz deduplikacji
message_id TEXT NOT NULL, -- Twój zapisany id wiadomości od dostawcy
type TEXT NOT NULL, -- kanoniczny: sent/delivered/bounced/...
email TEXT NOT NULL,
occurred_at TIMESTAMPTZ NOT NULL,
received_at TIMESTAMPTZ NOT NULL DEFAULT now(),
raw JSONB NOT NULL -- dosłowny ładunek dostawcy
);
CREATE INDEX ON email_events (message_id);
CREATE INDEX ON email_events (email, type);
-- Wyprowadzony bieżący status per wiadomość (odtwarzalny z email_events w każdej chwili).
CREATE TABLE email_status (
message_id TEXT PRIMARY KEY,
status TEXT NOT NULL,
bounced BOOLEAN NOT NULL DEFAULT false,
complained BOOLEAN NOT NULL DEFAULT false,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
Przechowywanie pełnej historii zdarzeń daje Ci ślad audytowy, możliwość przeliczenia metryk po zmianie logiki oraz surowy materiał do debugowania trendów dostarczalności w czasie. Jeśli trzymasz tylko najnowszy status, tracisz możliwość odpowiedzi na pytanie „kiedy ten adres zaczął się odbijać?” albo odtworzenia metryki, którą za pierwszym razem zdefiniowałeś błędnie. Log append-only plus wyprowadzona tabela statusu to najlepsze z obu światów: log jest autorytatywny i odtwarzalny, tabela statusu jest szybka w odpytywaniu, a tabelę statusu zawsze można zregenerować z logu.
Jak długo przechowywać surowe zdarzenia?
Wyważ debugowalność, koszt i prywatność. Surowe ładunki zawierają adresy e-mail odbiorców (dane osobowe wedle RODO), więc retencja to także decyzja o zgodności, nie tylko o przechowywaniu, zobacz Zgodność prawna. Częsty wzorzec: trzymaj pełne surowe ładunki „na gorąco” przez 30–90 dni do debugowania i odtwarzania, a potem albo je usuń, albo zredukuj do strukturalnych kolumn (porzucając blob raw) na potrzeby długoterminowych metryk. Utrzymuj okno deduplikacji (np. 30 dni) co najmniej tak długie jak maksymalne okno ponowień dostawcy, aby spóźnione ponowienie wciąż się deduplikowało.
Co przeczytać dalej?
- Zarządzanie listą: co robić z danymi o odbiciach i skargach.
- Niezawodność wysyłki: logika ponawiania, gdy wysyłki zawodzą.
- Dostarczalność: jak odbicia i skargi wpływają na reputację nadawcy.
- Zgodność prawna: retencja danych odbiorców i obowiązki jednoklikowego wypisu.
- Spis treści
