# 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.

Source: https://pidgely.com/blog/email-webhooks-signature-verification/ · Published: 2026-10-12 · Pidgely

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](https://www.standardwebhooks.com/) 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

```js
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.

