Skip to content
Pidgely
  • webhooks
  • security
  • developers

Email Webhooks: Verifying Signatures and Handling Retries

How to receive email event webhooks safely: verify Standard Webhooks signatures, block replays, handle retries and process events idempotently, with code.

Pidgely teamPublished 4 min readMarkdown version

On this page
  1. What email webhooks carry
  2. The Standard Webhooks format
  3. Verifying the signature
  4. Replay protection
  5. Retries and responding correctly
  6. Idempotency and ordering
  7. Secret rotation
  8. Common mistakes
  9. Checklist
  10. Frequently asked questions

To receive email event webhooks safely, verify each request's signature over the raw body, reject old timestamps to block replays, respond with a 2xx fast after storing the event, and process events idempotently because retries make delivery at-least-once. With the Standard Webhooks specification, verification means computing an HMAC-SHA256 over webhook-id.webhook-timestamp.body with your secret and comparing it, in constant time, with the v1 value in the webhook-signature header. Skipping any of these steps lets attackers forge events like "bounced" or "complained" that silently suppress real users.

What email webhooks carry

An email platform sends webhooks for events in a message's lifecycle:

  • email.sent / email.delivered: accepted by the receiving server
  • email.deferred: temporary failure, still retrying
  • email.bounced: permanent failure, with status code and diagnostic
  • email.complained: the recipient reported spam (via a feedback loop)
  • email.unsubscribed: one-click or link unsubscribe
  • email.opened / email.clicked: engagement, where tracking is enabled

Your application uses these to update user records, stop mailing invalid addresses and trigger alerts. Because several of these events cause irreversible actions, the endpoint is a security boundary.

The Standard Webhooks format

Standard Webhooks is an open specification for signing and delivering webhooks consistently across providers. A request looks like this:

HTTP
POST /webhooks/email HTTP/1.1
Host: api.example.com
Content-Type: application/json
webhook-id: msg_2Kx9fQ1aZ7
webhook-timestamp: 1791813802
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=

{"type":"email.bounced","created_at":"2026-10-12T14:03:22Z","data":{"email_id":"em_8f2a","to":"jane@example.com","bounce":{"type":"hard","status":"5.1.1"}}}
  • webhook-id: unique per message, stable across retries. Use it for deduplication.
  • webhook-timestamp: Unix seconds when the attempt was sent.
  • webhook-signature: one or more space-separated signatures, each prefixed with a version (v1, for HMAC-SHA256). Multiple signatures appear during secret rotation.

The secret is usually shown as whsec_ followed by base64. Strip the prefix and base64-decode it to get the HMAC key.

Verifying the signature

The signed content is the three values joined by periods:

text
{webhook-id}.{webhook-timestamp}.{raw body}

Node.js

JavaScript
import crypto from "node:crypto";

const TOLERANCE_SECONDS = 5 * 60;

export function verifyWebhook(rawBody, headers, secret) {
  const id = headers["webhook-id"];
  const ts = headers["webhook-timestamp"];
  const sigHeader = headers["webhook-signature"];
  if (!id || !ts || !sigHeader) throw new Error("missing headers");

  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - Number(ts)) > TOLERANCE_SECONDS) {
    throw new Error("timestamp outside tolerance");
  }

  const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
  const expected = crypto
    .createHmac("sha256", key)
    .update(`${id}.${ts}.${rawBody}`)
    .digest();

  for (const part of sigHeader.split(" ")) {
    const [version, sig] = part.split(",");
    if (version !== "v1" || !sig) continue;
    const given = Buffer.from(sig, "base64");
    if (given.length === expected.length && crypto.timingSafeEqual(given, expected)) {
      return JSON.parse(rawBody);
    }
  }
  throw new Error("no valid signature");
}

Python

Python
import base64, hashlib, hmac, json, time

TOLERANCE = 300

def verify_webhook(raw_body: bytes, headers: dict, secret: str) -> dict:
    msg_id = headers["webhook-id"]
    ts = headers["webhook-timestamp"]
    if abs(time.time() - int(ts)) > TOLERANCE:
        raise ValueError("timestamp outside tolerance")
    key = base64.b64decode(secret.removeprefix("whsec_"))
    signed = f"{msg_id}.{ts}.".encode() + raw_body
    expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()
    for part in headers["webhook-signature"].split(" "):
        version, _, sig = part.partition(",")
        if version == "v1" and hmac.compare_digest(sig, expected):
            return json.loads(raw_body)
    raise ValueError("no valid signature")

Official Standard Webhooks libraries exist for many languages; prefer them when available.

Get the raw body

Most frameworks parse JSON before your handler runs. Configure the route to give you the unparsed bytes (for example express.raw({ type: "application/json" }) in Express, or request.get_data() in Flask). Verifying a re-serialized object fails whenever whitespace or key order differs.

Replay protection

A valid signed request captured from logs or a proxy would stay valid forever without additional checks. Two layers stop replays:

  1. Timestamp tolerance: reject requests more than a few minutes from your clock. Because the timestamp is part of the signed content, an attacker cannot change it.
  2. Seen-ID store: record each processed webhook-id (in Redis with a TTL longer than the tolerance, or in your database) and ignore duplicates.

Keep your server clock synchronized with NTP; a drifting clock causes false rejections.

Retries and responding correctly

Senders retry when your endpoint times out, refuses the connection or returns a non-2xx status, usually with exponential backoff over hours or days. To work well with retries:

  • Respond fast. Verify, write the event to a durable queue or table, return 200 or 204. Do the heavy work in a background job.
  • Return 2xx for events you deliberately ignore, such as unknown event types, or they will be retried until the sender gives up.
  • Return 4xx for bad signatures, and alert on a spike, which can indicate a misconfigured secret or an attack.
  • Return 5xx only for real temporary failures, so the retry is meaningful.

Idempotency and ordering

At-least-once delivery means duplicates. Retries also mean events can arrive out of order: a delivered can arrive after a bounced for a different attempt, or an opened before delivered.

SQL
CREATE TABLE webhook_events (
  webhook_id   text PRIMARY KEY,
  type         text NOT NULL,
  email_id     text NOT NULL,
  occurred_at  timestamptz NOT NULL,
  payload      jsonb NOT NULL,
  received_at  timestamptz NOT NULL DEFAULT now()
);

INSERT INTO webhook_events (webhook_id, type, email_id, occurred_at, payload)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (webhook_id) DO NOTHING;

Then design state transitions that tolerate any order: use the event's own created_at, not arrival time, and make terminal states (bounced, complained) win over earlier ones.

Secret rotation

Rotate signing secrets periodically and whenever one may have leaked. During rotation, the sender signs with both old and new secrets, producing two v1 entries in the header. Your verifier accepts a request if any signature matches, so you can deploy the new secret without dropping events, then retire the old one.

Common mistakes

  • Verifying a parsed and re-serialized body.
  • Using == for comparison instead of a constant-time function.
  • No timestamp check, allowing replays.
  • Doing slow work inline, causing timeouts and retry storms.
  • Treating duplicate events as new, double-counting bounces.
  • Assuming order, so a late delivered overwrites a bounced.
  • Secret in source control.

Checklist

  • [ ] Raw body captured before parsing
  • [ ] HMAC-SHA256 over id.timestamp.body, constant-time compare
  • [ ] Timestamp tolerance and seen-ID store
  • [ ] Fast 2xx after durable storage; async processing
  • [ ] Idempotent writes keyed on webhook-id
  • [ ] Order-independent state transitions
  • [ ] Secret rotation supported

Pidgely signs every event webhook with the Standard Webhooks scheme, retries failed deliveries with backoff, and keeps the webhook-id stable across retries, so the verification code above works without changes.

Frequently asked questions

How do I verify a Standard Webhooks signature?

Concatenate the webhook-id header, the webhook-timestamp header and the raw request body with periods, compute an HMAC-SHA256 with your base64-decoded secret, base64-encode the result and compare it in constant time with each v1 signature in the webhook-signature header.

Why do I need the raw body to verify the signature?

The signature covers the exact bytes that were sent. Parsing and re-serializing JSON can change whitespace, key order or number formatting, which makes a valid signature fail.

How do I prevent webhook replay attacks?

Reject requests whose webhook-timestamp is too far from your current time (a few minutes is typical), and record processed webhook-id values so a captured request cannot be replayed within that window.

What should my webhook endpoint return?

Return a 2xx status quickly after verifying and durably storing the event. Do slow work asynchronously. Non-2xx responses and timeouts cause the sender to retry.

Will I receive the same event more than once?

Yes, potentially. Webhook delivery is at-least-once, so retries after timeouts or network errors can deliver duplicates. Deduplicate using the webhook-id.

Pidgely team

Published

We build Pidgely, the email API that gets you to the inbox. We write about what we learn delivering email, citing the standards and mailbox provider guidelines behind every claim.

Also available in: