---
title: "Webhook Verification"
description: "Verify that a webhook came from CM.com and was not altered, and handle duplicate or replayed deliveries."
canonical: "https://docs.cm.com/email-gateway-api/guides/webhooks/webhook-verification"
---

# Webhook Verification

> Verify that a webhook came from CM.com and was not altered, and handle duplicate or replayed deliveries.

Source: https://docs.cm.com/email-gateway-api/guides/webhooks/webhook-verification

Verify every webhook before you process it. Verification confirms that the request came from the CM.com Email Gateway and that the body was not altered. Without it, your application cannot tell a real event from a forged one.

The timestamp check limits how long a captured request stays valid. It does not stop an exact copy of a valid request from being accepted again inside that window. See [Replay protection and duplicate events](#replay-protection-and-duplicate-events).

## Headers

Each signed webhook carries three headers:

| Header | Description |
| - | - |
| `svix-id` | The ID of the email message (a UUID). Every event for the same message, such as sent, delivered and bounced, carries the same `svix-id`. **Do not use it to deduplicate.** Use `data.id` from the payload. |
| `svix-timestamp` | Unix time in **milliseconds** (Svix proper uses seconds). |
| `svix-signature` | Base64 HMAC-SHA512, no `v1,` prefix. |

## Prerequisites

- Your **Webhook Secret**, found under **Settings → Integration** in the Email App.
- A publicly reachable HTTPS endpoint that accepts `POST` requests.
- Access to the **raw, unparsed request body**. Re-serialised JSON produces a signature mismatch.

## Verification steps

1. Check that `svix-id`, `svix-timestamp` and `svix-signature` are all present.
2. Check that `svix-timestamp` is within your tolerance window (we recommend 5 minutes). This rejects old requests. It does not reject an exact copy of a recent one.
3. Compute the signature and compare it with `svix-signature` using a constant-time comparison.

```text
signature = base64( HMAC_SHA512( key = UTF8(secret), data = svix-id + "." + svix-timestamp + "." + rawBodyBytes ) )
```

- The key is your Webhook Secret used as raw UTF-8. Do not base64-decode it or strip a `whsec_` prefix.
- Sign the **exact bytes** received.
- A stock Svix library rejects these signatures because of the millisecond timestamp. Use the SDKs below or the example in this page.

```js
const crypto = require('crypto');

const TOLERANCE_MS = 5 * 60 * 1000;

function verify(secret, headers, rawBody /* Buffer */) {
  const id = headers['svix-id'];
  const ts = headers['svix-timestamp'];
  const sig = headers['svix-signature'];
  if (!id || !ts || !sig) return false;
  if (!(Math.abs(Date.now() - Number(ts)) <= TOLERANCE_MS)) return false;

  const mac = crypto.createHmac('sha512', secret)
    .update(`${id}.${ts}.`)
    .update(rawBody)
    .digest('base64');
  const got = Buffer.from(sig);
  const want = Buffer.from(mac);
  return got.length === want.length && crypto.timingSafeEqual(got, want);
}
```

## SDKs

Official SDKs handle header checks, timestamp validation and signature comparison. The tolerance window is configurable when you create the validator.

| Language | Package |
| - | - |
| .NET | `CM.Email.WebhookVerification` |
| Python | `cm-email-webhook-verification` |
| Rust | `cm-email-webhook-verification` |

## Replay protection and duplicate events

The signature covers `svix-id`, `svix-timestamp` and the raw body, so nobody without your Webhook Secret can change a webhook or give it a newer timestamp. An exact copy of a valid webhook can still be accepted again until it falls outside your tolerance window.

Delivery is also at-least-once, so the same event can arrive more than once, for example when we retry a delivery. Make your handler idempotent by storing the `data.id` of every event you process and skipping repeats:

- `data.id` is unique per event and stays the same when we retry that event.
- `svix-id` is the email message ID, shared by all events for that message. Skipping on it would drop real events.
- Events can arrive out of order. Compare `createdAt` before an older event overwrites newer state.

```js
// Use a shared store in production, for example a database unique key or Redis SET NX
const processedEventIds = new Set();

function handle(event) {
  if (processedEventIds.has(event.data.id)) return; // already handled, still respond 2xx
  processedEventIds.add(event.data.id);

  // Process the event
}
```

Call this only after the request has passed verification.

## Respond

- On success, return a `2xx` status. Do slow work asynchronously.
- On failure, return `401`. Do not reveal why verification failed in the response.

## Failure types

| Failure | Cause |
| - | - |
| Missing headers | One or more of the three headers is absent. |
| Invalid timestamp | `svix-timestamp` is not a valid number. |
| Expired timestamp | `svix-timestamp` is outside the tolerance window. |
| Invalid signature | The computed signature does not match `svix-signature`. |

## Best practices

- Verify before you process anything.
- Keep the secret out of source control. Rotate it if it leaks.
- Handle duplicates. Store the `data.id` of processed events, not `svix-id`, and skip repeats. Delivery is at-least-once. See [Replay protection and duplicate events](#replay-protection-and-duplicate-events).
- Keep server clocks synced with NTP. Clock drift is a common cause of expired timestamps.

## Troubleshooting

**Invalid signature**

- Wrong secret.
- Body modified before verification, for example by framework parsing or a proxy. Verify against the raw bytes.
- A proxy altered the headers.

**Expired timestamp**

- Clock drift on your server.
- Slow queue processing between receipt and verification. Verify on receipt, then queue.
- Replaying a captured request during testing.

Inbound email webhooks use the same scheme. See [Verify and Delivery](https://docs.cm.com/email-gateway-api/guides/receiving-emails/verify-and-delivery).
