Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.photon.codes/docs/llms.txt

Use this file to discover all available pages before exploring further.

You saw the verifier as a copy-paste in the Quickstart, and you saw the X-Spectrum-Signature header itself in Events. This page is the why — what each line of that verifier is doing and how to port it to a different stack. Anyone on the internet can POST to your webhook URL. The signature is what tells you a request actually came from Spectrum and wasn’t tampered with in transit. Skip this page only if you trust your network perimeter to do that for you — most production systems shouldn’t. The recipe is small, but four details have to be exactly right or every legitimate request will be rejected. We cover each below.

The recipe

For every incoming request:
  1. Capture the raw body bytes before any JSON parser touches them.
  2. Reject the timestamp if it’s more than 5 minutes from your current clock.
  3. Recompute the HMAC locally: HMAC-SHA256(signingSecret, "v0:" + timestamp + ":" + rawBody).
  4. Compare in constant time against the X-Spectrum-Signature header.
If any check fails, return 401 Unauthorized and stop processing.
sig = 'v0=' + hmacSha256Hex(signingSecret, 'v0:' + timestamp + ':' + rawBody)

Working examples

Pick the stack that matches your server. Each example is complete and copy-pasteable. Replace SPECTRUM_SIGNING_SECRET with the secret you saved when registering the webhook.
import { Hono } from 'hono';
import { createHmac, timingSafeEqual } from 'node:crypto';

const app = new Hono();
const SECRET = process.env.SPECTRUM_SIGNING_SECRET!;
const TOLERANCE_SEC = 5 * 60;

app.post('/spectrum-webhook', async (c) => {
  const rawBody = await c.req.text();
  const timestamp = c.req.header('X-Spectrum-Timestamp');
  const signature = c.req.header('X-Spectrum-Signature');

  if (!timestamp || !signature) return c.text('missing headers', 400);

  const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
  if (!Number.isFinite(age) || age > TOLERANCE_SEC) {
    return c.text('stale timestamp', 400);
  }

  const expected =
    'v0=' +
    createHmac('sha256', SECRET)
      .update(`v0:${timestamp}:${rawBody}`)
      .digest('hex');

  const a = Buffer.from(expected);
  const b = Buffer.from(signature);
  if (a.length !== b.length || !timingSafeEqual(a, b)) {
    return c.text('bad signature', 401);
  }

  const payload = JSON.parse(rawBody);
  await handleEvent(c.req.header('X-Spectrum-Event'), payload);
  return c.text('ok', 200);
});

export default { port: 3000, fetch: app.fetch };

The four details that have to be exact

1. Use the raw body bytes, not the parsed JSON

The signature was computed over the exact bytes that travel on the wire. If you let your framework parse the body to JSON and then re-stringify it before hashing, the bytes change — different key order, different whitespace, different unicode escaping — and verification fails.
FrameworkWhat to call
Expressexpress.raw({ type: 'application/json' }) middleware
Honoawait c.req.text() (call this before c.req.json())
FastAPIawait request.body() (don’t type the parameter as a Pydantic model)
Bun.serveawait req.text()
Cloudflare Workersawait request.text()
Calling request.json() first and request.text() second gives you an empty body — most frameworks consume the underlying stream once. Always read raw text first.

2. Compare in constant time

A simple === comparison leaks information about how many leading bytes match through response timing. Over many requests, that’s enough to recover the secret. Use the constant-time comparison built into your language:
LanguageFunction
Node / Buncrypto.timingSafeEqual(Buffer.from(a), Buffer.from(b))
Pythonhmac.compare_digest(a, b)
Gohmac.Equal(a, b)
RubyRack::Utils.secure_compare(a, b) (or OpenSSL.fixed_length_secure_compare)
PHPhash_equals($a, $b)
timingSafeEqual requires both buffers to be the same length, otherwise it throws. Compare lengths first and return false if they differ — same outcome (reject), no exception.

3. Reject stale timestamps

Even with valid signatures, an attacker who captures a delivery from your logs (or a misconfigured proxy) could replay it forever. Bound that window:
const age = Math.abs(now - timestamp);
if (age > 5 * 60) reject();
5 minutes is the recommended tolerance — generous enough to absorb clock drift between our worker and your server, tight enough to make captured-and-replayed attacks impractical. Tighten or loosen it based on your threat model.

4. Get the hex casing and prefix right

The signature header is lowercase hex prefixed with v0=. Three subtle ways to break this:
  • Output uppercase hex (A1B2...) and compare against the lowercase header → never matches.
  • Forget the v0= prefix → length differs by 3, never matches.
  • Sign ${timestamp}:${body} (without the v0: prefix in the input) → completely different HMAC.
The v0= versions both the header prefix and the signing input prefix. They’re the same v0 for a reason — when we ever roll out a new scheme, both will bump together to v1, and old verifiers can detect “this signature is in a format I don’t understand” by checking the prefix.

Why this is enough — the security model

The signing secret is the only piece that doesn’t travel on each request. It was returned exactly once when you registered the webhook, and it lives in your secrets manager. When you compute HMAC(secret, body) and compare to what arrived, you’re proving:
  • Authenticity. Only someone with the secret can produce a signature that matches. That’s Spectrum (and you).
  • Integrity. Any byte changed in transit changes the HMAC completely. A modified body never matches the original signature.
  • Header integrity. The timestamp is part of the signed input, so it can’t be modified either.
Combined with the staleness check, you also get:
  • Freshness. Captured deliveries can’t be replayed beyond the 5-minute window.
This is the same scheme used by Stripe (v1=), Slack (v0=, identical to ours), GitHub (sha256=), and most webhook providers. The construction is well-studied and survives Kerckhoffs’s principle: even if every detail of the formula is public (it is — you’re reading it now), the scheme is secure as long as the secret stays secret.

Common verification failures

SymptomCauseFix
Every request returns bad signatureBody was parsed and re-serialized before hashingCapture the raw body bytes first
Sporadic bad signatureServer clock skewConfirm NTP is running; loosen the tolerance window if you’re on a constrained host
bad signature only in productionSecret loaded from the wrong env varLog SECRET.length === 64 (it should always be true)
timingSafeEqual throwsBuffers have different lengthsCompare length first, return false if mismatched
missing headers from real requestsReverse proxy stripping X-Spectrum-*Add to your proxy’s header allow-list

Reusing our verifier

Spectrum’s own delivery worker uses an internal verifyPhotonWebhook function that mirrors the algorithm above. We round-trip our signer against this verifier in tests on every commit, so any bug in the algorithm would be caught before release. You can copy the same shape into your code:
export const verifyPhotonWebhook = (
  rawBody: string,
  signingSecret: string,
  signature: string,
  timestamp: string
): boolean => {
  const expected =
    'v0=' +
    createHmac('sha256', signingSecret)
      .update(`v0:${timestamp}:${rawBody}`)
      .digest('hex');
  if (expected.length !== signature.length) return false;
  return timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
};
Pair it with the staleness check and you have a complete verifier.

Where to next

A verified delivery is half the job. The other half is what your handler does with it — and what happens when your handler is slow, down, or buggy. That’s the next chapter.

Delivery and retries

Retry policy, timeouts, idempotency, and what every HTTP status code means to the worker.

Managing webhooks

Operate at scale — register, list, delete, and rotate signing secrets (also testable in the API reference).