Webhooky pro stav doručení e-mailu: jak ověřit podpis (HMAC) a zpracovat je idempotentně
Praktický návod na příjem webhooků email.status: ověření HMAC podpisu z nezparsovaného těla, ochrana proti opakování, rychlá odpověď a zpracování na pozadí. Příklady v Node.js a PHP.
Webhook je HTTP POST z naší strany na vaši adresu pokaždé, když se změní stav e-mailu: cílový server ho přijal, odložil, odmítl, nebo si příjemce stěžoval. Bez webhooku byste museli stav každé zprávy dotazovat přes API; s webhookem ho máte v databázi během sekund a můžete na něj reagovat (označit objednávku, poslat SMS jako zálohu, zablokovat adresu). Tenhle článek ukazuje, jak webhook přijmout tak, aby ho nešlo podvrhnout a aby vám duplicitní doručení nerozbilo data.
Co přijde
POST https://vas-web.cz/topemail-webhook
Content-Type: application/json
X-TopEmail-Event: email.status
X-TopEmail-Signature: sha256=3f1a…
{
"event": "email.status",
"id": "em_01jb7k3x9…",
"status": "delivered",
"to": "zakaznik@example.com",
"tags": {"order": "2026-1042", "type": "shipping"},
"at": "2026-11-12T09:41:07Z",
"reason": null
}
id je identifikátor e-mailu, který jste dostali v odpovědi na odeslání. tags jsou vaše vlastní značky, takže k objednávce se dostanete bez dalšího dotazu. status je jeden ze stavů popsaných v přehledu API: sent, delivered, deferred, bounced, complained, suppressed. U bounced a deferred je v reason odpověď cílového serveru.
Krok 1: ověření podpisu
Kdokoli, kdo zná vaši webhook URL, na ni může poslat JSON. Bez ověření by vám tak mohl „doručit" neexistující e-maily, nebo naopak označit adresu jako stěžovatele. Proto každý webhook podepisujeme: HMAC-SHA256 z těla požadavku s tajným klíčem webhooku, který najdete v dashboardu u nastavení webhooku. Podpis je v hlavičce X-TopEmail-Signature ve tvaru sha256=<hex>.
Tři zásady, na kterých implementace nejčastěji selže:
- Počítejte podpis z nezparsovaného těla. Pokud framework JSON nejdřív naparsuje a vy ho znovu serializujete, změní se pořadí klíčů nebo mezery a podpis nesedí.
- Porovnávejte v konstantním čase.
===nebo==prozradí podle doby odpovědi, kolik bajtů sedí. - Tajný klíč držte v konfiguraci, ne v kódu, a při podezření na únik ho v dashboardu vygenerujte znovu.
Node.js (Express)
const crypto = require('crypto')
const express = require('express')
const app = express()
// raw body: express.json() by telo naparsoval drive, nez ho overime
app.post('/topemail-webhook', express.raw({ type: 'application/json' }), async (req, res) => {
const sig = req.get('X-TopEmail-Signature') || ''
const expected = 'sha256=' + crypto
.createHmac('sha256', process.env.TOPEMAIL_WEBHOOK_SECRET)
.update(req.body) // Buffer s nezparsovanym telem
.digest('hex')
if (sig.length !== expected.length ||
!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) {
return res.status(401).end()
}
const event = JSON.parse(req.body.toString('utf8'))
await queue.add('email-status', event) // zpracovani na pozadi
res.status(200).end()
})
PHP
<?php
$raw = file_get_contents('php://input');
$sig = $_SERVER['HTTP_X_TOPEMAIL_SIGNATURE'] ?? '';
$expected = 'sha256=' . hash_hmac('sha256', $raw, getenv('TOPEMAIL_WEBHOOK_SECRET'));
if (!hash_equals($expected, $sig)) {
http_response_code(401);
exit;
}
$event = json_decode($raw, true);
// ulozit do fronty / tabulky a hned odpovedet
http_response_code(200);
Krok 2: odpovězte rychle, zpracujte později
Očekáváme odpověď 2xx do 10 sekund. Když ji nedostaneme, nebo dostaneme 5xx, doručení opakujeme s rostoucím odstupem po dobu 24 hodin. To je ochrana pro vás (výpadek vašeho serveru nic neztratí), ale má to důsledek: pokud váš handler trvá dlouho, protože v něm voláte další API nebo generujete PDF, může se stát, že odpovíte až po našem opakovaném pokusu, a stejnou událost dostanete dvakrát.
Řešení je vždy stejné: ověřit podpis, uložit událost do fronty nebo tabulky, vrátit 200. Skutečné zpracování (aktualizace objednávky, odeslání SMS, notifikace podpory) dělejte na pozadí.
Krok 3: idempotence
Webhook může přijít dvakrát (opakování po pomalé odpovědi) a může přijít v jiném pořadí, než události vznikly (dvě změny stavu během sekundy, paralelní doručení). Handler proto musí být idempotentní: zpracování téže události podruhé nesmí nic změnit.
Nejjednodušší je tabulka přijatých událostí s unikátním klíčem:
CREATE TABLE email_events (
email_id TEXT NOT NULL,
status TEXT NOT NULL,
at TIMESTAMPTZ NOT NULL,
payload JSONB NOT NULL,
PRIMARY KEY (email_id, status, at)
);
Vložení duplicity selže na primárním klíči (nebo použijte INSERT … ON CONFLICT DO NOTHING) a vy ji tiše ignorujete. Pro pořadí si u e-mailu držte „nejvyšší" dosažený stav: delivered po sent je postup, sent po delivered je opožděná událost, kterou zahodíte. Stavy bounced, complained a suppressed jsou konečné a přepisují cokoli.
Na co reagovat a na co ne
Nemusíte zpracovávat každý stav. V praxi stačí:
delivered: označit v objednávce, že potvrzení dorazilo (hodí se podpoře, když zákazník tvrdí opak).bounced: adresa je neplatná. Zobrazit zákazníkovi při dalším přihlášení výzvu k opravě, nebo poslat SMS jako zálohu, pokud máte číslo.complained: příjemce označil e-mail jako spam. Pokud se to děje u transakčních e-mailů, je něco špatně s obsahem; podívejte se, co posíláte.deferred: obvykle jen pro informaci; zkoušíme to znovu sami.
Adresy po bounced a complained dáváme na blokovaný seznam automaticky, takže to řešit nemusíte; proč to děláme, vysvětlujeme v článku o bounce a suppression listu.
Testování
Na localhost se webhook nedostane. Použijte tunel (ngrok, localtunnel nebo Cloudflare Tunnel), nastavte vzniklou veřejnou adresu jako webhook URL a pošlete testovací e-mail na vlastní adresu. Pro rychlé prohlédnutí syrového payloadu bez vlastního kódu poslouží webhook.site. Nezapomeňte, že u tunelu musíte podpis ověřovat úplně stejně; testovací prostředí není důvod ověření vypnout.
Časté chyby
- Ověření podpisu z naparsovaného a znovu serializovaného JSON. Podpis nikdy nesedí, vývojář ověření „dočasně" vypne, a zapomene.
- Pomalý handler bez fronty. Opakování vytvoří duplicity, které bez idempotence vedou k dvojím SMS nebo dvojím záznamům.
- Veřejná URL bez ověření. Kdokoli vám může podvrhnout stavy.
- Zpracování podle pořadí příchodu místo podle logiky stavů.
- Jeden webhook pro testovací i produkční účet. Oddělte je, ušetříte si noční ladění.
Shrnutí
Ověřte HMAC z nezparsovaného těla v konstantním čase, odpovězte 200 do pár set milisekund, zpracujte na pozadí a každou událost ukládejte s unikátním klíčem. Čtyři pravidla, hodina práce, a stav každého e-mailu máte spolehlivě ve své databázi. Podrobnosti k formátu najdete v přehledu API a SMTP.
Transakční e-maily z vaší domény přes API nebo SMTP, 0,06 Kč za e-mail, prvních 1 000 e-mailů měsíčně zdarma. Registrace zdarma, bez platební karty.
Registrovat zdarma →