Marketing

How to Verify an Email Webhook Signature Before Trusting a Bounce

Antonio Evans10 min read

A developer's desk viewed from above: a laptop showing a terminal output that reads 'signature mismatch, rejected', a printed webhook header dump with an HMAC line circled in pencil.
Key points

Key points

  1. 1Read the signature and timestamp headers, recompute the HMAC over the exact raw request bytes using your shared secret, and compare the two in constant time.
  2. 2Reject any request whose timestamp falls outside a short window (five minutes is a common choice) and deduplicate on the provider's event ID before touching your suppression list.
  3. 3Keep the previous secret valid during rotation, because a provider can send events signed with the old secret for minutes after you switch.
  4. 4Test with a known-good payload, a body with one changed byte, a stale timestamp, and a replayed event ID before you let the endpoint write anything permanent.

The signed payload contract

Every provider that signs webhooks publishes three things: a header or headers carrying the signature, a timestamp to anchor the payload in time, and an algorithm to recompute. Twilio SendGrid, for example, generates a private and public key pair and posts the signature in the X-Twilio-Email-Event-Webhook-Signature header, with the signed timestamp in X-Twilio-Email-Event-Webhook-Timestamp (SendGrid security features). SendGrid uses ECDSA rather than HMAC, so you verify with a public key instead of a shared secret, and a guide on building SendGrid integrations notes that the provider also supplies dual verification headers so you can bind the signature to a time (SendGrid webhooks guide).

Providers that use HMAC follow the same shape. The secret is a string you generate in the provider's dashboard. The signature is HMAC-SHA256(secret, raw_body) or, where a timestamp is included, HMAC-SHA256(secret, timestamp + "." + raw_body) or the timestamp concatenated directly with the body. The exact concatenation differs between providers, so the only reliable source is the provider's own documentation. The Stack Overflow thread on verifying SendGrid signatures shows a developer hashing the timestamp bytes concatenated with the body bytes and then verifying with ECDSA over SHA-256, which illustrates how easy it is to get the input string wrong when you copy an example (Stack Overflow).

A signed payload also carries event identity. SendGrid batches events into a JSON array, and each event includes sg_event_id and sg_message_id alongside the recipient address, a Unix timestamp and the event type, with bounce events adding a reason and SMTP status (Hookdeck SendGrid guide). You need that ID for replay defense even when the signature is valid, because a valid signature proves origin, not freshness.

Amazon SES approaches the same problem differently: it publishes bounce and complaint events to an Amazon SNS topic, and the AWS documentation states plainly that every SES sender must have a system in place for managing bounces and complaints (AWS SES notifications). If you consume those through an HTTP endpoint rather than an SQS queue, you inherit the job of validating the SNS message signature yourself. Either way, the decision rule is the same: no verification, no write.

Why a forged bounce is expensive

A bounce event usually triggers one of two actions: add the address to a suppression list, or increment a reputation counter that eventually pauses sending to that domain. Both are sticky. A suppression list entry that was never real quietly removes a paying customer from every future campaign until someone audits it by hand. Reputation damage is harder still, because inbox providers score your domain and IP over time, and Yahoo asks senders to keep complaint rates below 0.3% while also honoring unsubscribes within two days (Yahoo Sender Hub). Gmail applies the same 0.3% threshold to spam rates reported in Postmaster Tools (Gmail sender guidelines).

The Postmaster Tools spam rate dashboard measures manual spam reports among messages that reached the inbox, not every message Gmail filtered, and it is delayed, so a clean line does not prove placement is healthy by itself (Postmaster Tools V2 explained). That matters here because a forged bounce campaign can push you toward aggressive list pruning, which shrinks engaged volume and distorts the very metrics you use to judge deliverability.

Do the arithmetic on a small campaign. Suppose you send 200,000 messages and an attacker injects 500 forged hard bounces for addresses that actually exist and engage. If those 500 addresses generated a 2% click rate and 1% of clicks convert at $40 average order value, the silent loss is 500 x 0.02 x 0.01 x $40, or $4 per campaign, every campaign, forever, until someone finds the entries. Add the support cost of even a few customers asking why they stopped receiving receipts and the endpoint hardening pays for itself quickly.

There is a second-order risk. If your bounce handler trusts unverified input and writes to a shared suppression table, an attacker who can reach the endpoint from anywhere on the internet has a cheap denial-of-service primitive against your deliverability. Gmail and Yahoo both treat sender behavior as a permanent record: Gmail states that a sender classified as a bulk sender stays classified, and enforcement escalates from temporary errors to permanent rejections for non-compliant traffic (Gmail sender guidelines FAQ). Authentication protects your outbound domain, but it does nothing for the integrity of inbound event data.

Implementing verification over the raw body

The most common bug is parsing first and verifying second. JSON parsers reorder keys, normalize whitespace and rewrite escapes, so the bytes you hash no longer match the bytes the provider signed. Capture the raw body before any middleware touches it, then verify.

A minimal Express handler looks like this. It verifies, then hands off:

const crypto = require("crypto");

const secret = process.env.EMAIL_WEBHOOK_SECRET;
const WINDOW_SECONDS = 300;

function verify(rawBody, signatureHeader, timestampHeader) {
  if (!signatureHeader || !timestampHeader) return { ok: false, why: "missing_headers" };
  const ts = Number(timestampHeader);
  if (!Number.isFinite(ts)) return { ok: false, why: "bad_timestamp" };
  const skew = Math.abs(Math.floor(Date.now() / 1000) - ts);
  if (skew > WINDOW_SECONDS) return { ok: false, why: "stale_timestamp", skew };

  const expected = crypto
    .createHmac("sha256", secret)
    .update(timestampHeader + "." + rawBody)
    .digest("hex");

  const given = Buffer.from(signatureHeader, "utf8");
  const want = Buffer.from(expected, "utf8");
  if (given.length !== want.length) return { ok: false, why: "length_mismatch" };
  const ok = crypto.timingSafeEqual(given, want);
  return ok ? { ok: true } : { ok: false, why: "signature_mismatch" };
}

Three details carry the security. First, use crypto.timingSafeEqual, which requires equal-length buffers, so compare lengths before calling it. Second, treat the timestamp check as a separate failure with its own reason code, because stale timestamps during an incident tell you something different from a forged signature. Third, keep the raw body in memory only as long as verification needs it, and never log it at full length, since it contains recipient addresses.

Provider differences matter. SendGrid sends a JSON array of events and batches per roughly 30 seconds or 768 KB, with a 10-second response timeout and retries with exponential backoff for up to 24 hours (Hookdeck SendGrid guide). Because batching is time based, one POST can mix event types. Verify once per request, then iterate inside.

CheckWhat it provesFailure actionTypical window
Signature over raw bodyPayload came from the provider and was not altered401, alert on rate spikenone
Timestamp within windowPayload is fresh, not replayed from a capture401, log skew value5 minutes
Event ID not seen beforeRetry or deliberate replay will not double-write200, drop silently24 hours to 7 days
Event type allowlistOnly bounce, complaint, unsubscribe reach the handler200, ignorepermanent
Recipient domain sanityThe address belongs to a domain you actually mail200, quarantinepermanent

The last two rows are cheap insurance. An allowlist on event type means an injected open or click event cannot inflate engagement scores, and a domain check catches an attacker using your endpoint to insert unrelated addresses into a suppression list.

Replay defense and idempotency

Signature verification alone does not stop replay. An attacker who captures a valid request can resend it byte for byte, and it will pass the signature check until the timestamp window closes. That window is your primary defense; keep it short. Five minutes is workable for providers with under a minute of delivery latency, and SendGrid's batching cadence means most events arrive quickly. If you see legitimate events arriving after the window, widen to ten minutes and investigate the cause rather than disabling the check.

The timestamp window has a gap: retries. SendGrid retries failed deliveries for up to 24 hours (Hookdeck SendGrid guide). A retry sent hours after the event may carry either a fresh signature with a fresh timestamp or the original signed payload, depending on the provider. If it carries the original timestamp, your window will reject a legitimate retry. The practical resolution is to accept stale timestamps only when the signature is valid and the event ID has not been processed, and to treat stale-but-valid as a distinct outcome that gets queued rather than dropped. That trades a small replay surface for not losing bounces during your own outages.

Idempotency belongs at write time, not before verification. The standard pattern is a table with a unique constraint on the provider's event ID, inserted before any side effect, with the row deleted if the handler throws so the next delivery can proceed (Hookdeck idempotency guide). Hookdeck notes that most webhook providers guarantee at least once delivery, so duplicates are expected rather than exceptional. A separate glossary on the topic draws the same distinction: retries assure delivery, idempotency keys assure that repeated requests do not duplicate effects, and the two mechanisms serve different purposes (retries and idempotency keys).

For bounce handling, the unique key should be the event ID, not the recipient address. Two different messages can bounce for the same address on the same day, and you may want both records for diagnostics even though the suppression write happens once. Structure it as two operations: insert the event row with a unique constraint (this is the idempotency gate), then apply the suppression upsert, which is naturally idempotent.

Handling secrets during rotation

Rotation is where most verification implementations break in production. You rotate the secret, and for the next few minutes your endpoint rejects legitimate events signed with the old one, or you keep both and accidentally accept a request signed with a secret you meant to retire.

The safe pattern is a list, ordered, with an overlap period:

  1. Generate the new secret in the provider dashboard and add it to your configuration as the primary, keeping the old secret as secondary.
  2. Deploy, so both secrets are accepted.
  3. Wait at least twice your timestamp window plus the provider's documented retry latency. For SendGrid, that retry window runs to 24 hours, so a full day of overlap is the honest answer.
  4. Verify from logs that no requests have matched the secondary secret for a full window.
  5. Remove the secondary and redeploy.

Record which secret matched in your logs, as a short label such as primary or secondary, never the secret itself. That single field makes step 4 mechanical instead of a guess. If your provider offers multiple active signing keys with key IDs in headers, use the key ID to select the secret and skip the trial-and-error loop entirely.

Rotation failureSymptomFix
No overlap periodSpike in signature mismatches right after deployKeep old secret as secondary for one full retry window
Overlap never closedAny request matching the retired secret is still acceptedLog secret label, remove secondary after quiet period
Secret in source controlRotation requires a code change and a full releaseStore in a secrets manager, reload without redeploy
One secret per environmentStaging events verify against production secretSeparate secrets per environment, separate endpoint paths

Building the reject-and-alert path

A verification failure is a security event, not a 500. Return a 400 or 401 with a short reason code, write a structured log line with the reason, the source IP, the timestamp skew and the event count if the body parsed enough to tell, and increment a counter. Alert on rate, not on single events, because scanners and misconfigured staging environments will hit your public endpoint occasionally.

Set thresholds you can defend. A reasonable starting policy: warn when verification failures exceed 10 per hour, page when they exceed 1% of total webhook volume over 15 minutes, and page immediately on any failure whose signature length matches but whose bytes do not, since that pattern suggests a deliberate attempt rather than noise. Track stale timestamps separately; a sudden run of stale-but-valid signatures usually means your own queue backed up, not an attack.

Never return a 200 to a failed verification. Some providers stop retrying on 4xx and some stop on any non-2xx after a limit, so the choice changes delivery behavior, but a 200 tells the provider the event was handled. SendGrid's 10-second timeout plus retry schedule means a fast 401 costs you nothing you wanted anyway (Hookdeck SendGrid guide).

Alert content matters as much as the alert. Include the deployed secret label, the endpoint path, the failure reason histogram for the last hour, and a link to the suppression table row count so the responder can see whether anything was written before the check was added. If you deploy through a platform where an AI agent writes the handler, keep the verification module in a small, auditable file with its own tests, and have the agent log the secret label rather than the secret.

Testing with known-good and tampered payloads

Build a test suite around four cases, all generated locally: valid signature, one changed byte in the body, stale timestamp, and a replayed event ID. Generate the valid signature in the test itself with the same secret, so the test never depends on a captured production payload.

import hmac, hashlib, json, time

def sign(secret: str, ts: str, body: bytes) -> str:
    msg = ts.encode() + b"." + body
    return hmac.new(secret.encode(), msg, hashlib.sha256).hexdigest()

body = json.dumps([{"event": "bounce", "email": "[email protected]",
                    "sg_event_id": "test-1", "timestamp": int(time.time())}]).encode()
ts = str(int(time.time()))
sig = sign("test-secret", ts, body)

Assert four things. The valid case returns 200 and writes one suppression row. The tampered case returns 401 and writes nothing. The stale case returns 401 with reason stale_timestamp. The replay case returns 200 but the suppression table row count stays at one, and the raw event log count stays at one because the second insert hits the unique constraint.

Add two integration checks. First, a differential test that runs the body through your JSON middleware and compares JSON.stringify(parsed) to the original bytes; if they differ, your raw body capture is broken for some payload shapes, and you want to know before an escaped unicode character in a display name causes a mismatch. Second, a canary that sends a real test event from the provider to your production endpoint weekly and alerts if verification fails, which catches a rotated secret or a proxy that rewrites bodies.

Where this crosses into follow-up work, a team running several providers and a model-assisted triage step on top of the event stream ends up with per-provider adapters and a small amount of glue that normalizes event shapes. Quanta Inference gives you one OpenAI-compatible endpoint for text models if you want an agent to summarize a bounce spike or draft the incident note, with prepaid billing in US dollars (https://quantaintelligence.ai/inference).

FAQ

FAQ

Do I hash the parsed JSON or the raw request body?

Always the raw request body bytes exactly as received. JSON parsers can reorder keys, change whitespace and normalize escapes, which changes the hash. Capture the body before any middleware parses it and verify against that buffer.

What timestamp window should I allow?

Start with five minutes. That is short enough to make replay impractical against a captured request and long enough to absorb normal delivery latency and clock drift. Monitor the stale-timestamp rate for a week before tightening or widening it, and widen rather than disable the check if legitimate events are being rejected.

How do I handle retries that arrive after the timestamp window closes?

Accept them only when the signature is valid and the event ID has not been processed, and route them through a distinct code path that queues rather than drops. SendGrid retries for up to 24 hours, so a strict window with no exception will lose events during your own outages.

What is the difference between a signature check and an idempotency key?

The signature proves the payload came from the provider and was not altered. The idempotency key, typically the provider's event ID stored under a unique constraint, proves you have not already acted on it. You need both, because a valid signature on a replayed request will pass every time.

How long should I keep the old signing secret during rotation?

At least as long as the provider's retry window, which can be 24 hours for some providers. Log which secret matched using a label so you can confirm the secondary has gone quiet before removing it. If the provider supports multiple keys with key IDs in the headers, select the secret by key ID and the overlap becomes explicit.

Sources

Sources

  1. Getting Started with the Event Webhook Security Features - Twilio
  2. SendGrid Webhooks: Track Opens, Clicks & Bounces in Real-Time
  3. Verifying SendGrid's Signed Event Webhook? - Stack Overflow
  4. Guide to SendGrid Webhooks Features and Best Practices
  5. Setting up event notifications for Amazon SES - AWS Documentation
  6. Sender Best Practices - Yahoo Sender Hub
  7. Email sender guidelines - Gmail Help
  8. Understanding Google Postmaster Tools V2 Spam Rate ...
  9. Email sender guidelines FAQ - Gmail Help
  10. How to Implement Webhook Idempotency - Hookdeck
  11. Webhook Retries and Idempotency Keys - Best Digital Marketing Agency
About the author

About the author

Antonio Evans
Antonio Evans

Antonio Evans is the founder of Quanta Intelligence and a fractional CTO who builds HIPAA-ready AI systems for health and life-science companies. Before that he was technical co-founder of FriendsEAT for twenty years, CEO of Socialdraft, and a data engineer at IBM. Antonio lives with his dog Santino in Lisbon, Portugal.

All articles