Skip to Content
WebhooksSigning & Verification

Signing & Verification

Every delivery is signed so you can prove it came from Spectrum and wasn’t tampered with or replayed. Verify the X-Spectrum-Signature header on every request before you act on it, using the signing key you received when the webhook was created.

How signing works

Spectrum signs each delivery with HMAC-SHA256 using your webhook’s signing key. The signature travels in the X-Spectrum-Signature header:

X-Spectrum-Signature: t=<unix>,v1=<hmac-sha256-hex>
  • t: the Unix timestamp (seconds) when the delivery was signed.
  • v1: the HMAC-SHA256, hex-encoded, of the string <t>.<body> where body is the raw, unmodified request bytes.

To verify, recompute the HMAC yourself and compare.

Sign over the raw request body, byte-for-byte. If your framework parses JSON and you re-serialize it, the bytes will differ and the signature won’t match. Capture the raw body before any JSON parsing.

Verifying a signature

The steps, in any language:

  1. Read the raw request body as bytes.
  2. Parse t and v1 from X-Spectrum-Signature.
  3. Reject the request if t is more than ~5 minutes from now (replay protection).
  4. Compute HMAC_SHA256(signingKey, t + "." + rawBody) and hex-encode it.
  5. Compare against v1 using a constant-time comparison.
  6. Optionally, dedupe on X-Spectrum-Event-Id. See idempotency.

Node.js (Express)

import express from 'express'; import crypto from 'node:crypto'; const SIGNING_KEY = process.env.SPECTRUM_WEBHOOK_SIGNING_KEY!; const TOLERANCE_SECONDS = 5 * 60; const app = express(); // Capture the raw body, verification must run over the exact bytes. app.use('/webhooks/spectrum', express.raw({ type: 'application/json' })); function verify(rawBody: Buffer, header: string | undefined): boolean { if (!header) return false; const parts = Object.fromEntries( header.split(',').map((p) => p.split('=') as [string, string]), ); const t = Number(parts.t); const v1 = parts.v1; if (!t || !v1) return false; // Reject stale/replayed deliveries. if (Math.abs(Date.now() / 1000 - t) > TOLERANCE_SECONDS) return false; const expected = crypto .createHmac('sha256', SIGNING_KEY) .update(`${t}.${rawBody.toString('utf8')}`) .digest('hex'); const a = Buffer.from(expected); const b = Buffer.from(v1); return a.length === b.length && crypto.timingSafeEqual(a, b); } app.post('/webhooks/spectrum', (req, res) => { if (!verify(req.body as Buffer, req.header('X-Spectrum-Signature'))) { return res.status(401).send('invalid signature'); } const event = JSON.parse((req.body as Buffer).toString('utf8')); // ... dedupe on req.header('X-Spectrum-Event-Id') and process event ... res.sendStatus(200); });

Rotating a signing key

Rotate a key from the webhook’s settings in the dashboard when it may have leaked, or if you lost the copy shown at creation. As with creation, the new key is displayed once, so copy and store it immediately.

Rotation supports a grace window so you can roll the new key out without dropping in-flight deliveries:

Grace windowEffect
Default (24 hours)The old key keeps working for 24 hours.
ImmediateThe old key stops working right away.
Up to 7 daysThe longest grace window you can choose.

During the grace window, deliveries are signed with the new key and also carry an X-Spectrum-Signature-Previous header signed with the old key. Once the window ends, only the new key is used.

Recommended rotation flow: rotate with a grace window → update your receiver to accept either X-Spectrum-Signature or X-Spectrum-Signature-Previous → deploy the new key → once traffic is fully on the new key, drop the old one.

Destination URLs are validated when you save a webhook, so deliveries only ever go to a public HTTPS endpoint you control.