Skip to main content

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.

Headers​

Each signed webhook carries three headers:

HeaderDescription
svix-idThe 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-timestampUnix time in milliseconds (Svix proper uses seconds).
svix-signatureBase64 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.
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.
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.

LanguagePackage
.NETCM.Email.WebhookVerification
Pythoncm-email-webhook-verification
Rustcm-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.
// 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​

FailureCause
Missing headersOne or more of the three headers is absent.
Invalid timestampsvix-timestamp is not a valid number.
Expired timestampsvix-timestamp is outside the tolerance window.
Invalid signatureThe 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.
  • 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.