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>wherebodyis 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:
- Read the raw request body as bytes.
- Parse
tandv1fromX-Spectrum-Signature. - Reject the request if
tis more than ~5 minutes from now (replay protection). - Compute
HMAC_SHA256(signingKey, t + "." + rawBody)and hex-encode it. - Compare against
v1using a constant-time comparison. - 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 window | Effect |
|---|---|
| Default (24 hours) | The old key keeps working for 24 hours. |
| Immediate | The old key stops working right away. |
| Up to 7 days | The 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.