[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"blog-608112e6-49bf-4c8e-9013-f2d3a0b64cd9":3},{"id":4,"body":5,"uuid":6,"created_at":7,"updated_at":8,"brand":9,"header":10,"short_body":11,"image":12,"published":13,"published_at":14,"tags":15},17,"\u003Cp>Email webhooks are HTTP callbacks that your email provider sends to your application the moment something happens to a message you dispatched — it was delivered, it bounced, the recipient opened it, or they marked it as spam. Instead of repeatedly asking the provider \"what happened to that email?\", your provider pushes each event to a URL you control, in near real time. For any SaaS product that depends on transactional email — password resets, receipts, verification codes — email webhooks are the only practical way to know whether your messages actually reached people.\u003C\u002Fp>\n\u003Cp>Here's the core problem they solve: when your code calls your provider's API and gets a \u003Ccode>200 OK\u003C\u002Fcode>, that response only confirms the message was \u003Cem>accepted for sending\u003C\u002Fem>. Everything that determines whether the user actually receives it — the recipient's mail server, spam filtering, greylisting, reputation checks — happens downstream, asynchronously, after your request has already returned. Delivery webhooks are how that downstream half of the email lifecycle reports back to you.\u003C\u002Fp>\n\u003Cp>This guide is a practical, developer-focused walkthrough of how email webhooks work: the event types you'll receive, how to set up and secure a webhook endpoint, how to verify signatures so nobody can forge events, and how to handle retries and idempotency so duplicate deliveries don't corrupt your data. There are working code examples you can adapt directly.\u003C\u002Fp>\n\u003Ch2>What Are Email Webhooks?\u003C\u002Fh2>\n\u003Cp>An email webhook is a user-defined HTTP callback. You register a URL with your email provider; whenever an event occurs for one of your messages, the provider makes an HTTP \u003Ccode>POST\u003C\u002Fcode> request to that URL with a JSON payload describing the event. Your application receives it, verifies it, and reacts.\u003C\u002Fp>\n\u003Cp>The contrast that makes webhooks valuable is \u003Cstrong>push vs. pull\u003C\u002Fstrong>:\u003C\u002Fp>\n\u003Cul>\n\u003Cli>\u003Cstrong>Polling (pull):\u003C\u002Fstrong> your app periodically calls the provider's API asking \"any updates on these 10,000 messages?\" This is slow, wasteful, rate-limited, and always behind.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Webhooks (push):\u003C\u002Fstrong> the provider calls \u003Cem>you\u003C\u002Fem> the instant an event happens. No polling loop, no wasted requests, near-real-time data.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Cp>A Postwing webhook payload looks like this:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-json\">{\n  &quot;event_id&quot;: &quot;a1b2c3d4-0001-4f3a-9c2e-7b6d5e4f3a21&quot;,\n  &quot;event&quot;: &quot;delivered&quot;,\n  &quot;message_id&quot;: &quot;&lt;2f1c8e90-...@yourdomain.com&gt;&quot;,\n  &quot;email&quot;: &quot;user@example.com&quot;,\n  &quot;timestamp&quot;: &quot;2026-06-24T09:41:13.482921+00:00&quot;,\n  &quot;data&quot;: {\n    &quot;smtp_code&quot;: 250,\n    &quot;mx_host&quot;: &quot;mx1.recipient-domain.com&quot;\n  }\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>Every payload has the same top-level shape: a unique \u003Ccode>event_id\u003C\u002Fcode> (for idempotency — see below), the \u003Ccode>event\u003C\u002Fcode> type, the \u003Ccode>message_id\u003C\u002Fcode> of the original email, the recipient \u003Ccode>email\u003C\u002Fcode>, an ISO-8601 \u003Ccode>timestamp\u003C\u002Fcode>, and an event-specific \u003Ccode>data\u003C\u002Fcode> object (for example \u003Ccode>smtp_code\u003C\u002Fcode>\u002F\u003Ccode>mx_host\u003C\u002Fcode> on delivery events, or \u003Ccode>reason\u003C\u002Fcode> on a \u003Ccode>dropped\u003C\u002Fcode>\u002F\u003Ccode>unsubscribed\u003C\u002Fcode> event). Your handler reads \u003Ccode>event\u003C\u002Fcode>, ties it back to the original message via \u003Ccode>message_id\u003C\u002Fcode>, and updates state — mark the email delivered, suppress a bounced address, flag a complaint, and so on.\u003C\u002Fp>\n\u003Ch3>Email webhooks vs. delivery webhooks\u003C\u002Fh3>\n\u003Cp>These terms are often used interchangeably, but it's worth being precise. \u003Cstrong>Email webhooks\u003C\u002Fstrong> is the umbrella term for every event callback your provider can send, including engagement events like opens and clicks. \u003Cstrong>Delivery webhooks\u003C\u002Fstrong> refers specifically to the deliverability-related lifecycle events — \u003Ccode>delivered\u003C\u002Fcode>, \u003Ccode>bounced\u003C\u002Fcode>, \u003Ccode>deferred\u003C\u002Fcode>, \u003Ccode>dropped\u003C\u002Fcode>, \u003Ccode>complained\u003C\u002Fcode> — that tell you whether the message reached the inbox. Delivery webhooks are the subset that matters most for transactional email, because for a password reset you care far more about \u003Cem>did it arrive\u003C\u002Fem> than \u003Cem>did they open it\u003C\u002Fem>.\u003C\u002Fp>\n\u003Ch2>Why Email Webhooks Matter for Transactional Email\u003C\u002Fh2>\n\u003Cp>Transactional emails sit on the critical path of your product: account verification, password resets, payment receipts, login codes. When one fails, the user is blocked, and you usually find out via a support ticket — or not at all.\u003C\u002Fp>\n\u003Cp>Without email webhooks, your visibility ends at the API response. With them, you regain control of the downstream half:\u003C\u002Fp>\n\u003Cul>\n\u003Cli>\u003Cstrong>Catch failures fast.\u003C\u002Fstrong> A bounce or drop event arrives within seconds, so you can retry, alert, or fall back to SMS before the user gives up.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Protect your sender reputation.\u003C\u002Fstrong> Every \u003Ccode>bounced\u003C\u002Fcode> and \u003Ccode>complained\u003C\u002Fcode> event lets you auto-suppress that address. Repeatedly mailing bad addresses is the fastest way to tank deliverability.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Drive product logic.\u003C\u002Fstrong> \"Resend if not delivered within 5 minutes\", \"show a 'check your inbox' banner only after \u003Ccode>delivered\u003C\u002Fcode>\", or \"escalate to SMS on bounce\" all require live delivery data.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Feed monitoring and analytics.\u003C\u002Fstrong> Webhook events are the raw material for delivery rate, bounce rate, and complaint rate dashboards.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Cp>Industry deliverability guidance from sources like the \u003Ca href=\"https:\u002F\u002Fwww.m3aawg.org\u002F\">M3AAWG sender best practices\u003C\u002Fa> and ISP postmaster pages (Google, Microsoft) consistently emphasizes promptly processing bounces and complaints — and webhooks are the mechanism that makes that automatic rather than manual.\u003C\u002Fp>\n\u003Ch2>Email Delivery Event Types Explained\u003C\u002Fh2>\n\u003Cp>The lifecycle is universal, even if event names vary by provider. Postwing emits the following seven events, each mapping to a real transition in the email's lifecycle.\u003C\u002Fp>\n\u003Ctable>\n\u003Cthead>\n\u003Ctr>\n\u003Cth>Event\u003C\u002Fth>\n\u003Cth>What it means\u003C\u002Fth>\n\u003Cth>Typical action\u003C\u002Fth>\n\u003C\u002Ftr>\n\u003C\u002Fthead>\n\u003Ctbody>\n\u003Ctr>\n\u003Ctd>\u003Ccode>delivered\u003C\u002Fcode>\u003C\u002Ftd>\n\u003Ctd>Recipient's mail server accepted the message (\u003Ccode>250 OK\u003C\u002Fcode>)\u003C\u002Ftd>\n\u003Ctd>Mark delivered; this is your success signal\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Ccode>deferred\u003C\u002Fcode>\u003C\u002Ftd>\n\u003Ctd>Temporary failure; Postwing will retry (greylisting, throttling, 4xx)\u003C\u002Ftd>\n\u003Ctd>Wait; only worry if it persists\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Ccode>bounced\u003C\u002Fcode>\u003C\u002Ftd>\n\u003Ctd>Permanent failure (5xx \u002F no such mailbox); not delivered\u003C\u002Ftd>\n\u003Ctd>Suppress address; stop sending\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Ccode>complained\u003C\u002Fcode>\u003C\u002Ftd>\n\u003Ctd>Permanent failure attributed to a spam\u002Freputation\u002Fpolicy block\u003C\u002Ftd>\n\u003Ctd>Immediately suppress; never re-send\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Ccode>dropped\u003C\u002Fcode>\u003C\u002Ftd>\n\u003Ctd>Postwing didn't attempt send because the address is on your suppression list\u003C\u002Ftd>\n\u003Ctd>Investigate; check suppression list\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Ccode>opened\u003C\u002Fcode>\u003C\u002Ftd>\n\u003Ctd>Recipient opened the email (tracking pixel loaded)\u003C\u002Ftd>\n\u003Ctd>Engagement analytics only\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Ccode>unsubscribed\u003C\u002Fcode>\u003C\u002Ftd>\n\u003Ctd>Recipient used the unsubscribe mechanism\u003C\u002Ftd>\n\u003Ctd>Honor suppression\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003C\u002Ftbody>\n\u003C\u002Ftable>\n\u003Cp>Note that Postwing does not emit a \u003Ccode>clicked\u003C\u002Fcode> event — it tracks opens (a tracking pixel), not individual link clicks. Both \u003Ccode>bounced\u003C\u002Fcode> and \u003Ccode>complained\u003C\u002Fcode> are permanent delivery failures; Postwing splits them out so a reputation\u002Fpolicy block (which you should treat as a complaint signal) is distinguishable from an ordinary hard bounce.\u003C\u002Fp>\n\u003Cp>A few important nuances:\u003C\u002Fp>\n\u003Ch3>Hard bounce vs. soft bounce\u003C\u002Fh3>\n\u003Cp>A \u003Cstrong>hard bounce\u003C\u002Fstrong> is permanent — the address doesn't exist, the domain is invalid, or the server permanently rejects it. Suppress immediately. A \u003Cstrong>soft bounce\u003C\u002Fstrong> is temporary — mailbox full, server down, message too large. Providers usually retry soft bounces internally and only emit a \u003Ccode>bounced\u003C\u002Fcode> event once they give up. Treat a single soft bounce as noise and a pattern of them as a reputation warning.\u003C\u002Fp>\n\u003Ch3>Why \"opened\" is unreliable\u003C\u002Fh3>\n\u003Cp>The \u003Ccode>opened\u003C\u002Fcode> event fires when a tracking pixel (a 1×1 image) loads. But Apple Mail Privacy Protection, corporate image proxies, and privacy-focused clients pre-fetch or block these pixels. That means opens are \u003Cstrong>over-counted\u003C\u002Fstrong> for some recipients and \u003Cstrong>never recorded\u003C\u002Fstrong> for others. Use opens for rough engagement trends, never for deliverability decisions or per-user logic. For transactional email, \u003Ccode>delivered\u003C\u002Fcode> is the event that matters.\u003C\u002Fp>\n\u003Ch3>Complaints are the most dangerous event\u003C\u002Fh3>\n\u003Cp>A \u003Ccode>complained\u003C\u002Fcode> event means an ISP's feedback loop reported that the user hit \"spam\". Your complaint rate is one of the strongest reputation signals an ISP watches. Keep it below \u003Cstrong>0.1%\u003C\u002Fstrong>; a sustained rate above \u003Cstrong>0.3%\u003C\u002Fstrong> will get you throttled or blocked. Every complaint should trigger immediate, permanent suppression.\u003C\u002Fp>\n\u003Ch2>How to Set Up a Webhook Endpoint\u003C\u002Fh2>\n\u003Cp>Setting up email webhooks is a four-step process: build an endpoint, register it, verify incoming requests, and process events safely. Let's walk through each.\u003C\u002Fp>\n\u003Ch3>Step 1: Build the endpoint\u003C\u002Fh3>\n\u003Cp>Create an HTTPS route in your app that accepts \u003Ccode>POST\u003C\u002Fcode> requests and returns quickly. The golden rule: \u003Cstrong>acknowledge fast, process later.\u003C\u002Fstrong> Your handler should validate the request, enqueue the event for background processing, and immediately return \u003Ccode>200\u003C\u002Fcode>. If you do heavy work synchronously — database writes, downstream API calls — you risk timing out, which causes the provider to retry and double-deliver.\u003C\u002Fp>\n\u003Cp>Here's a minimal but production-shaped handler in Python (Flask). Note imports are at the top of the file:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-python\">import hmac\nimport hashlib\nimport json\nimport os\nimport time\n\nfrom flask import Flask, request, abort, jsonify\n\napp = Flask(__name__)\n\nWEBHOOK_SECRET = os.environ[&quot;POSTWING_WEBHOOK_SECRET&quot;].encode()\nTOLERANCE_SECONDS = 300  # reject events older than 5 minutes\n\n\ndef verify_signature(payload: bytes, timestamp: str, signature: str) -&gt; bool:\n    # Reject stale requests to prevent replay attacks.\n    try:\n        if abs(time.time() - int(timestamp)) &gt; TOLERANCE_SECONDS:\n            return False\n    except (TypeError, ValueError):\n        return False\n\n    signed_content = timestamp.encode() + b&quot;.&quot; + payload\n    expected = hmac.new(WEBHOOK_SECRET, signed_content, hashlib.sha256).hexdigest()\n    # Constant-time comparison prevents timing attacks.\n    return hmac.compare_digest(expected, signature)\n\n\n@app.route(&quot;\u002Fwebhooks\u002Femail&quot;, methods=[&quot;POST&quot;])\ndef email_webhook():\n    payload = request.get_data()  # raw bytes — needed for signature check\n    timestamp = request.headers.get(&quot;X-Webhook-Timestamp&quot;, &quot;&quot;)\n    signature = request.headers.get(&quot;X-Webhook-Signature&quot;, &quot;&quot;)\n\n    if not verify_signature(payload, timestamp, signature):\n        abort(401)\n\n    event = json.loads(payload)\n\n    # Acknowledge immediately, then process in the background.\n    enqueue_event(event)\n    return jsonify({&quot;status&quot;: &quot;ok&quot;}), 200\n\n\ndef enqueue_event(event: dict) -&gt; None:\n    # Push to a queue (Celery, RQ, SQS, etc.) for async processing.\n    # Keep this fast; do the real work in a worker.\n    ...\n\n\nif __name__ == &quot;__main__&quot;:\n    app.run(port=8080)\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Ch3>Step 2: Register the URL with your provider\u003C\u002Fh3>\n\u003Cp>In your provider's dashboard or API, add your endpoint URL (e.g. \u003Ccode>https:\u002F\u002Fapi.yourapp.com\u002Fwebhooks\u002Femail\u003C\u002Fcode>) and select which events you want to receive. Subscribe only to events you actually use — there's no reason to receive \u003Ccode>opened\u003C\u002Fcode> if you're only acting on deliverability. During development, use a tunneling tool like \u003Ca href=\"https:\u002F\u002Fngrok.com\u002F\">ngrok\u003C\u002Fa> to expose your local endpoint so you can test against real provider events.\u003C\u002Fp>\n\u003Ch3>Step 3: Verify signatures (covered next)\u003C\u002Fh3>\n\u003Cp>Never trust an unauthenticated webhook. See the section below.\u003C\u002Fp>\n\u003Ch3>Step 4: Process events asynchronously\u003C\u002Fh3>\n\u003Cp>Your background worker consumes the queued event and applies your business logic — update message status, suppress addresses, increment metrics. This keeps your HTTP handler fast and decouples acknowledgment from processing.\u003C\u002Fp>\n\u003Ch2>Securing Email Webhooks: Signature Verification\u003C\u002Fh2>\n\u003Cp>Your webhook endpoint is a public URL. Anyone who discovers it can \u003Ccode>POST\u003C\u002Fcode> fake events — forging a \u003Ccode>delivered\u003C\u002Fcode> for a message that bounced, or injecting bogus complaint events. \u003Cstrong>Signature verification\u003C\u002Fstrong> is how you confirm that each request genuinely came from your provider and wasn't tampered with in transit.\u003C\u002Fp>\n\u003Ch3>How signature verification works\u003C\u002Fh3>\n\u003Col>\n\u003Cli>Your provider and you share a \u003Cstrong>signing secret\u003C\u002Fstrong> (issued when you create the webhook).\u003C\u002Fli>\n\u003Cli>For each request, the provider computes an \u003Cstrong>HMAC\u003C\u002Fstrong> (usually SHA-256) over the request body — often combined with a timestamp — using that secret.\u003C\u002Fli>\n\u003Cli>The provider sends the resulting signature in a header (e.g. \u003Ccode>X-Webhook-Signature\u003C\u002Fcode>).\u003C\u002Fli>\n\u003Cli>Your endpoint recomputes the HMAC over the \u003Cstrong>raw\u003C\u002Fstrong> received body with the same secret and compares.\u003C\u002Fli>\n\u003Cli>If they match, the request is authentic and untampered. If not, reject with \u003Ccode>401\u003C\u002Fcode>.\u003C\u002Fli>\n\u003C\u002Fol>\n\u003Cp>Every Postwing webhook request carries these headers:\u003C\u002Fp>\n\u003Ctable>\n\u003Cthead>\n\u003Ctr>\n\u003Cth>Header\u003C\u002Fth>\n\u003Cth>Purpose\u003C\u002Fth>\n\u003C\u002Ftr>\n\u003C\u002Fthead>\n\u003Ctbody>\n\u003Ctr>\n\u003Ctd>\u003Ccode>X-Webhook-Signature\u003C\u002Fcode>\u003C\u002Ftd>\n\u003Ctd>Hex HMAC-SHA256 of \u003Ccode>\"{timestamp}.{raw_body}\"\u003C\u002Fcode>, keyed by your endpoint secret\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Ccode>X-Webhook-Timestamp\u003C\u002Fcode>\u003C\u002Ftd>\n\u003Ctd>Unix epoch seconds when the request was signed (use for replay protection)\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Ccode>X-Webhook-Event\u003C\u002Fcode>\u003C\u002Ftd>\n\u003Ctd>The event type (e.g. \u003Ccode>delivered\u003C\u002Fcode>), so you can route without parsing the body\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Ccode>X-Webhook-Delivery\u003C\u002Fcode>\u003C\u002Ftd>\n\u003Ctd>The unique delivery\u002Fevent id — identical to \u003Ccode>event_id\u003C\u002Fcode> in the body, for idempotency\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003C\u002Ftbody>\n\u003C\u002Ftable>\n\u003Cp>The signing secret is shown when you create the endpoint (and again on retrieve); rotate it any time and Postwing signs with the new value immediately. The \u003Ccode>verify_signature\u003C\u002Fcode> function in the Flask example above shows the full pattern. Three details are critical:\u003C\u002Fp>\n\u003Cul>\n\u003Cli>\u003Cstrong>Use the raw body.\u003C\u002Fstrong> Compute the HMAC over the exact bytes received, \u003Cem>before\u003C\u002Fem> JSON parsing. Re-serializing the parsed JSON changes whitespace\u002Fkey order and breaks the signature.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Use constant-time comparison.\u003C\u002Fstrong> \u003Ccode>hmac.compare_digest\u003C\u002Fcode> (not \u003Ccode>==\u003C\u002Fcode>) prevents timing attacks that could leak the correct signature byte by byte.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Include a timestamp tolerance.\u003C\u002Fstrong> Reject requests whose timestamp is older than a few minutes. This stops \u003Cstrong>replay attacks\u003C\u002Fstrong>, where an attacker captures a valid signed request and re-sends it later.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Ch3>Node.js \u002F Express example\u003C\u002Fh3>\n\u003Cpre>\u003Ccode class=\"language-javascript\">const crypto = require(&quot;crypto&quot;);\nconst express = require(&quot;express&quot;);\n\nconst app = express();\nconst SECRET = process.env.POSTWING_WEBHOOK_SECRET;\nconst TOLERANCE = 300; \u002F\u002F seconds\n\n\u002F\u002F Capture the raw body for signature verification.\napp.use(&quot;\u002Fwebhooks\u002Femail&quot;, express.raw({ type: &quot;application\u002Fjson&quot; }));\n\nfunction verify(rawBody, timestamp, signature) {\n  if (Math.abs(Date.now() \u002F 1000 - Number(timestamp)) &gt; TOLERANCE) return false;\n  const signed = `${timestamp}.${rawBody}`;\n  const expected = crypto\n    .createHmac(&quot;sha256&quot;, SECRET)\n    .update(signed)\n    .digest(&quot;hex&quot;);\n  const a = Buffer.from(expected);\n  const b = Buffer.from(signature || &quot;&quot;);\n  return a.length === b.length &amp;&amp; crypto.timingSafeEqual(a, b);\n}\n\napp.post(&quot;\u002Fwebhooks\u002Femail&quot;, (req, res) =&gt; {\n  const timestamp = req.header(&quot;X-Webhook-Timestamp&quot;);\n  const signature = req.header(&quot;X-Webhook-Signature&quot;);\n\n  if (!verify(req.body, timestamp, signature)) {\n    return res.status(401).send(&quot;invalid signature&quot;);\n  }\n\n  const event = JSON.parse(req.body.toString());\n  enqueueEvent(event); \u002F\u002F async processing\n  res.status(200).json({ status: &quot;ok&quot; });\n});\n\napp.listen(8080);\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Ch3>Defense in depth\u003C\u002Fh3>\n\u003Cp>Signature verification is the primary control, but layer on more where you can:\u003C\u002Fp>\n\u003Cul>\n\u003Cli>\u003Cstrong>HTTPS only\u003C\u002Fstrong> — never accept webhooks over plain HTTP.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>IP allowlisting\u003C\u002Fstrong> — if your provider publishes static source IP ranges, restrict your endpoint to them.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>A hard-to-guess URL path\u003C\u002Fstrong> — include a random token segment so the endpoint isn't trivially discoverable.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Ch2>Retries and Idempotency\u003C\u002Fh2>\n\u003Cp>Webhook delivery is best-effort over an unreliable network, so two things are guaranteed to happen eventually: your endpoint will be \u003Cstrong>unavailable\u003C\u002Fstrong> sometimes, and you will receive the \u003Cstrong>same event more than once\u003C\u002Fstrong>. Handling both correctly is what separates a toy integration from a reliable one.\u003C\u002Fp>\n\u003Ch3>How retries work\u003C\u002Fh3>\n\u003Cp>If your endpoint doesn't respond with a \u003Ccode>2xx\u003C\u002Fcode> status within the timeout (your server is down, slow, or returns an error), the provider treats the delivery as failed and \u003Cstrong>retries\u003C\u002Fstrong> later with increasing backoff. Postwing's schedule is \u003Cstrong>1 min, 5 min, 30 min, 2 h, 6 h, 24 h\u003C\u002Fstrong>; after the last retry the delivery is marked permanently failed and is no longer attempted (you can inspect failed deliveries via the API or dashboard). The per-request timeout is 10 seconds.\u003C\u002Fp>\n\u003Cp>Implications for your handler:\u003C\u002Fp>\n\u003Cul>\n\u003Cli>\u003Cstrong>Return \u003Ccode>200\u003C\u002Fcode> only when you've safely accepted the event\u003C\u002Fstrong> (enqueued or persisted). If you return \u003Ccode>200\u003C\u002Fcode> and then crash before saving, that event is lost forever — the provider considers it delivered.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Return a non-\u003Ccode>2xx\u003C\u002Fcode> to trigger a retry\u003C\u002Fstrong> when you genuinely can't process the event yet (e.g. your queue is down). A \u003Ccode>500\u003C\u002Fcode> or \u003Ccode>503\u003C\u002Fcode> tells the provider to try again.\u003C\u002Fli>\n\u003Cli>\u003Cstrong>Respond fast.\u003C\u002Fstrong> Slow responses look like failures and cause retries even when nothing is wrong. This is why you acknowledge first and process async.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Ch3>Why idempotency is non-negotiable\u003C\u002Fh3>\n\u003Cp>Because of retries, you \u003Cstrong>will\u003C\u002Fstrong> receive duplicate events. If your handler isn't idempotent, a retried \u003Ccode>bounced\u003C\u002Fcode> event could double-count your bounce metric, a duplicate \u003Ccode>complained\u003C\u002Fcode> could fire two suppression emails to your team, and a re-delivered billing-related event could trigger duplicate side effects.\u003C\u002Fp>\n\u003Cp>\u003Cstrong>Idempotency\u003C\u002Fstrong> means processing the same event twice produces the same result as processing it once. The standard implementation uses a unique event ID:\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-python\">import json\n\nfrom flask import Flask, request, abort, jsonify\n\napp = Flask(__name__)\n\n\ndef already_processed(event_id: str) -&gt; bool:\n    # Atomically record the event id; return True if it existed already.\n    # Redis: SET event:&lt;id&gt; 1 NX EX 604800  -&gt; returns None if key exists.\n    # SQL:   INSERT ... ON CONFLICT DO NOTHING; check affected rows.\n    ...\n\n\ndef handle_event(event: dict) -&gt; None:\n    event_type = event[&quot;event&quot;]\n    if event_type == &quot;bounced&quot;:\n        suppress_address(event[&quot;email&quot;], reason=&quot;bounce&quot;)\n    elif event_type == &quot;complained&quot;:\n        suppress_address(event[&quot;email&quot;], reason=&quot;complaint&quot;)\n    elif event_type == &quot;delivered&quot;:\n        mark_delivered(event[&quot;message_id&quot;])\n\n\ndef process_webhook(event: dict) -&gt; None:\n    event_id = event[&quot;event_id&quot;]  # provider-supplied unique id\n\n    if already_processed(event_id):\n        return  # duplicate — safely ignore\n\n    handle_event(event)\n\n\ndef suppress_address(email: str, reason: str) -&gt; None:\n    ...\n\n\ndef mark_delivered(message_id: str) -&gt; None:\n    ...\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>The key is making \u003Ccode>already_processed\u003C\u002Fcode> \u003Cstrong>atomic\u003C\u002Fstrong> — a single database \u003Ccode>INSERT ... ON CONFLICT DO NOTHING\u003C\u002Fcode> or a Redis \u003Ccode>SET ... NX\u003C\u002Fcode> — so two concurrent duplicate deliveries can't both pass the check. Store processed event IDs with a TTL (7 days covers most retry windows).\u003C\u002Fp>\n\u003Ch3>Handling out-of-order events\u003C\u002Fh3>\n\u003Cp>Retries also mean events can arrive \u003Cstrong>out of order\u003C\u002Fstrong> — a \u003Ccode>deferred\u003C\u002Fcode> retried after the later \u003Ccode>delivered\u003C\u002Fcode> already landed. Don't assume strict ordering. Make state transitions monotonic: once a message is marked delivered, a late \u003Ccode>deferred\u003C\u002Fcode> event shouldn't downgrade it. Where order matters, key your logic off the event's own \u003Ccode>timestamp\u003C\u002Fcode>, not arrival time.\u003C\u002Fp>\n\u003Ch2>Common Email Webhook Mistakes to Avoid\u003C\u002Fh2>\n\u003Cp>Even teams that wire up email webhooks often undermine them. Watch for these.\u003C\u002Fp>\n\u003Ch3>1. Skipping signature verification\u003C\u002Fh3>\n\u003Cp>The most common — and most dangerous — mistake. An unverified endpoint is a public API that anyone can feed fake events. Always verify the HMAC signature before trusting a single field.\u003C\u002Fp>\n\u003Ch3>2. Doing heavy work synchronously\u003C\u002Fh3>\n\u003Cp>If your handler writes to multiple tables and calls downstream APIs before returning \u003Ccode>200\u003C\u002Fcode>, it will be slow, time out, and get retried — multiplying the very work that made it slow. Acknowledge first, process in a worker.\u003C\u002Fp>\n\u003Ch3>3. Parsing the body before verifying\u003C\u002Fh3>\n\u003Cp>Frameworks that auto-parse JSON discard the raw bytes you need for the HMAC. Capture the raw body first (\u003Ccode>express.raw\u003C\u002Fcode>, \u003Ccode>request.get_data()\u003C\u002Fcode>), verify, \u003Cem>then\u003C\u002Fem> parse.\u003C\u002Fp>\n\u003Ch3>4. Not handling duplicates\u003C\u002Fh3>\n\u003Cp>Without idempotency, retries silently corrupt your metrics and trigger duplicate side effects. Dedupe on the provider's \u003Ccode>event_id\u003C\u002Fcode> with an atomic check.\u003C\u002Fp>\n\u003Ch3>5. Returning \u003Ccode>200\u003C\u002Fcode> on failure\u003C\u002Fh3>\n\u003Cp>If your queue is down, returning \u003Ccode>200\u003C\u002Fcode> tells the provider \"got it\" and the event is gone forever. Return \u003Ccode>5xx\u003C\u002Fcode> so it retries; return \u003Ccode>200\u003C\u002Fcode> only after you've durably accepted the event.\u003C\u002Fp>\n\u003Ch3>6. Trusting \u003Ccode>opened\u003C\u002Fcode> for delivery decisions\u003C\u002Fh3>\n\u003Cp>Opens are inflated by some clients and invisible from others (Apple MPP, image proxies). Never gate transactional logic on \u003Ccode>opened\u003C\u002Fcode>. Use \u003Ccode>delivered\u003C\u002Fcode>.\u003C\u002Fp>\n\u003Ch3>7. Not auto-suppressing bounces and complaints\u003C\u002Fh3>\n\u003Cp>If a \u003Ccode>bounced\u003C\u002Fcode> or \u003Ccode>complained\u003C\u002Fcode> event doesn't immediately add the address to a suppression list, you'll keep mailing bad addresses and destroy your reputation. Automate it in the handler.\u003C\u002Fp>\n\u003Ch3>8. No replay protection\u003C\u002Fh3>\n\u003Cp>Without a timestamp tolerance, a captured valid request can be replayed indefinitely. Reject events older than a few minutes.\u003C\u002Fp>\n\u003Ch2>Webhooks vs. Polling: A Quick Comparison\u003C\u002Fh2>\n\u003Ctable>\n\u003Cthead>\n\u003Ctr>\n\u003Cth>Aspect\u003C\u002Fth>\n\u003Cth>Email webhooks (push)\u003C\u002Fth>\n\u003Cth>Polling (pull)\u003C\u002Fth>\n\u003C\u002Ftr>\n\u003C\u002Fthead>\n\u003Ctbody>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>Latency\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>Near real-time (seconds)\u003C\u002Ftd>\n\u003Ctd>As slow as your poll interval\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>Efficiency\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>Provider calls you only on events\u003C\u002Ftd>\n\u003Ctd>Constant requests, mostly empty\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>Scalability\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>Scales with event volume\u003C\u002Ftd>\n\u003Ctd>Scales with message count × frequency\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>Rate limits\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>Not an issue\u003C\u002Ftd>\n\u003Ctd>You'll hit them at scale\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>Setup complexity\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>Endpoint + verification + idempotency\u003C\u002Ftd>\n\u003Ctd>Just an API loop\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>\u003Cstrong>Reliability burden\u003C\u002Fstrong>\u003C\u002Ftd>\n\u003Ctd>You must handle retries\u002Fduplicates\u003C\u002Ftd>\n\u003Ctd>Provider handles consistency\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003C\u002Ftbody>\n\u003C\u002Ftable>\n\u003Cp>For anything beyond a hobby project, webhooks win decisively. Polling only makes sense as a fallback to reconcile events you might have missed during downtime — pull recent events via the API to backfill, then resume relying on the push stream.\u003C\u002Fp>\n\u003Ch2>Frequently Asked Questions\u003C\u002Fh2>\n\u003Ch3>What are email webhooks?\u003C\u002Fh3>\n\u003Cp>Email webhooks are HTTP callbacks your email provider sends to a URL you control whenever something happens to a message — it was delivered, bounced, opened, or marked as spam. Instead of polling the provider's API for status, you receive each event in near real time as a JSON \u003Ccode>POST\u003C\u002Fcode> request. They're the standard way to track transactional email delivery and react to failures automatically.\u003C\u002Fp>\n\u003Ch3>What is the difference between email webhooks and delivery webhooks?\u003C\u002Fh3>\n\u003Cp>\"Email webhooks\" is the general term for all event callbacks, including engagement events like opens and clicks. \"Delivery webhooks\" specifically refers to deliverability lifecycle events — \u003Ccode>delivered\u003C\u002Fcode>, \u003Ccode>bounced\u003C\u002Fcode>, \u003Ccode>deferred\u003C\u002Fcode>, \u003Ccode>dropped\u003C\u002Fcode>, and \u003Ccode>complained\u003C\u002Fcode> — that tell you whether a message reached the inbox. For transactional email, delivery webhooks are the subset that matters most.\u003C\u002Fp>\n\u003Ch3>How do I verify an email webhook is authentic?\u003C\u002Fh3>\n\u003Cp>Verify the signature. Your provider signs each request with an HMAC (usually SHA-256) computed over the raw request body using a shared secret, and sends the result in a header. On your side, recompute the HMAC over the exact raw bytes received with the same secret and compare using a constant-time function like \u003Ccode>hmac.compare_digest\u003C\u002Fcode>. If they don't match, reject the request with \u003Ccode>401\u003C\u002Fcode>. Also enforce a timestamp tolerance to block replay attacks.\u003C\u002Fp>\n\u003Ch3>What email events can webhooks track?\u003C\u002Fh3>\n\u003Cp>Postwing sends \u003Ccode>delivered\u003C\u002Fcode> (accepted by the recipient server), \u003Ccode>deferred\u003C\u002Fcode> (temporary failure, will retry), \u003Ccode>bounced\u003C\u002Fcode> (permanent failure), \u003Ccode>complained\u003C\u002Fcode> (permanent failure from a spam\u002Freputation\u002Fpolicy block), \u003Ccode>dropped\u003C\u002Fcode> (not attempted because the address is suppressed), \u003Ccode>opened\u003C\u002Fcode> (tracking pixel loaded), and \u003Ccode>unsubscribed\u003C\u002Fcode>. The deliverability events — delivered, bounced, deferred, complained — are the most important for transactional mail. (Postwing tracks opens but not link clicks, so there is no \u003Ccode>clicked\u003C\u002Fcode> event.)\u003C\u002Fp>\n\u003Ch3>How should I handle webhook retries and duplicates?\u003C\u002Fh3>\n\u003Cp>Assume you'll receive every event more than once. Make your handler idempotent by deduplicating on the provider's unique \u003Ccode>event_id\u003C\u002Fcode> using an atomic operation (a SQL \u003Ccode>INSERT ... ON CONFLICT DO NOTHING\u003C\u002Fcode> or Redis \u003Ccode>SET ... NX\u003C\u002Fcode>) before processing. Return \u003Ccode>200\u003C\u002Fcode> only after you've durably accepted the event; return a \u003Ccode>5xx\u003C\u002Fcode> to trigger a retry if you can't process it yet. Store processed IDs with a TTL covering the provider's retry window (typically 7 days).\u003C\u002Fp>\n\u003Ch3>Why is my webhook endpoint receiving duplicate events?\u003C\u002Fh3>\n\u003Cp>Because webhook delivery is best-effort. If your endpoint is slow, errors, or times out, the provider can't tell whether you received the event, so it retries — sometimes after you actually did process it. Network glitches and provider-side reties also cause duplicates. This is expected behavior; the fix is idempotent processing, not trying to eliminate duplicates.\u003C\u002Fp>\n\u003Ch3>Should I trust the \"opened\" event for tracking delivery?\u003C\u002Fh3>\n\u003Cp>No. The \u003Ccode>opened\u003C\u002Fcode> event relies on a tracking pixel that's blocked or pre-fetched by Apple Mail Privacy Protection, corporate image proxies, and privacy-focused clients, so opens are simultaneously over-counted and under-counted. Use opens for rough engagement trends only. For knowing whether a transactional email reached the user, rely on the \u003Ccode>delivered\u003C\u002Fcode> event.\u003C\u002Fp>\n\u003Ch3>Do I need webhooks if my provider has a dashboard?\u003C\u002Fh3>\n\u003Cp>A dashboard shows you aggregate trends, but it can't drive your application logic. Webhooks let your code react automatically — suppress a bounced address, resend on failure, escalate to SMS, or update a message's status in your own database. If you need real-time, per-message reactions (and for transactional email you do), you need webhooks, not just a dashboard.\u003C\u002Fp>\n\u003Ch2>Conclusion\u003C\u002Fh2>\n\u003Cp>Email webhooks close the visibility gap between \"the provider accepted my message\" and \"the user actually received it.\" That second half of the lifecycle — delivery, bounces, deferrals, complaints — happens downstream and asynchronously, invisible to your application unless you're listening. Webhooks are how you listen.\u003C\u002Fp>\n\u003Cp>The pattern is consistent regardless of provider: build a fast HTTPS endpoint, \u003Cstrong>verify every signature\u003C\u002Fstrong> with a constant-time HMAC check and a timestamp tolerance, \u003Cstrong>acknowledge immediately\u003C\u002Fstrong> and process events in a background worker, and make that processing \u003Cstrong>idempotent\u003C\u002Fstrong> so retries and duplicates never corrupt your data. Get those four things right and you have a webhook integration that's secure, reliable, and ready to drive real product logic — auto-suppressing bad addresses, resending failed messages, and feeding your monitoring dashboards.\u003C\u002Fp>\n\u003Cp>Start this week: subscribe to \u003Ccode>delivered\u003C\u002Fcode>, \u003Ccode>bounced\u003C\u002Fcode>, and \u003Ccode>complained\u003C\u002Fcode>, verify signatures, dedupe on \u003Ccode>event_id\u003C\u002Fcode>, and auto-suppress bounces and complaints. That alone puts you ahead of most SaaS products and protects your most critical user flows from failing in silence.\u003C\u002Fp>\n\u003Ch2>Track Email Delivery Events with Postwing\u003C\u002Fh2>\n\u003Cp>\u003Ca href=\"https:\u002F\u002Fpostwing.app\">Postwing\u003C\u002Fa> is a transactional email platform built for developers and SaaS companies, with email webhooks as a first-class feature. You get signed webhook events for every lifecycle stage — \u003Ccode>delivered\u003C\u002Fcode>, \u003Ccode>deferred\u003C\u002Fcode>, \u003Ccode>bounced\u003C\u002Fcode>, \u003Ccode>complained\u003C\u002Fcode>, \u003Ccode>dropped\u003C\u002Fcode>, \u003Ccode>opened\u003C\u002Fcode>, and \u003Ccode>unsubscribed\u003C\u002Fcode> — each with an HMAC-SHA256 signature for verification, a unique \u003Ccode>event_id\u003C\u002Fcode> for idempotency, and automatic retries with backoff so you never silently lose events.\u003C\u002Fp>\n\u003Cp>Bounces and complaints feed automatic suppression, so your reputation stays protected without manual list hygiene, and built-in delivery, bounce, and complaint dashboards turn your webhook stream into the metrics that matter. Because Postwing accepts \u003Cstrong>USDC payments on Base\u003C\u002Fstrong>, international founders and crypto-native teams can pay without card requirements or traditional payment-rail friction.\u003C\u002Fp>\n\u003Cp>Stop guessing whether your emails arrived. \u003Cstrong>\u003Ca href=\"https:\u002F\u002Fpostwing.app\">Start tracking email delivery events with Postwing →\u003C\u002Fa>\u003C\u002Fstrong>\u003C\u002Fp>","608112e6-49bf-4c8e-9013-f2d3a0b64cd9","2026-06-24T18:28:18.055777+03:00","2026-07-15T23:56:58.573364+03:00","postwing","Webhooks Explained: Tracking Email Delivery Events","Email webhooks push delivery, bounce, open, and complaint events to your app in real time. Learn endpoint setup, signature verification, and retries with code.","https:\u002F\u002Fapi.postwing.app\u002Fmedia\u002Fblog\u002Frecord_608112e6-49bf-4c8e-9013-f2d3a0b64cd9\u002Fwebhooks-explained.png",true,"2026-07-24T09:00:00+03:00",[16,17],"email webhooks","delivery webhooks"]