You saw the verifier as a copy-paste in the Quickstart, and you saw theDocumentation 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.
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:- Capture the raw body bytes before any JSON parser touches them.
- Reject the timestamp if it’s more than 5 minutes from your current clock.
- Recompute the HMAC locally:
HMAC-SHA256(signingSecret, "v0:" + timestamp + ":" + rawBody). - Compare in constant time against the
X-Spectrum-Signatureheader.
401 Unauthorized and stop processing.
Working examples
Pick the stack that matches your server. Each example is complete and copy-pasteable. ReplaceSPECTRUM_SIGNING_SECRET with the secret you saved when registering the webhook.
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.| Framework | What to call |
|---|---|
| Express | express.raw({ type: 'application/json' }) middleware |
| Hono | await c.req.text() (call this before c.req.json()) |
| FastAPI | await request.body() (don’t type the parameter as a Pydantic model) |
| Bun.serve | await req.text() |
| Cloudflare Workers | await request.text() |
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:
| Language | Function |
|---|---|
| Node / Bun | crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b)) |
| Python | hmac.compare_digest(a, b) |
| Go | hmac.Equal(a, b) |
| Ruby | Rack::Utils.secure_compare(a, b) (or OpenSSL.fixed_length_secure_compare) |
| PHP | hash_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:4. Get the hex casing and prefix right
The signature header is lowercase hex prefixed withv0=. 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 thev0:prefix in the input) → completely different HMAC.
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 computeHMAC(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.
- Freshness. Captured deliveries can’t be replayed beyond the 5-minute window.
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
| Symptom | Cause | Fix |
|---|---|---|
Every request returns bad signature | Body was parsed and re-serialized before hashing | Capture the raw body bytes first |
Sporadic bad signature | Server clock skew | Confirm NTP is running; loosen the tolerance window if you’re on a constrained host |
bad signature only in production | Secret loaded from the wrong env var | Log SECRET.length === 64 (it should always be true) |
timingSafeEqual throws | Buffers have different lengths | Compare length first, return false if mismatched |
missing headers from real requests | Reverse proxy stripping X-Spectrum-* | Add to your proxy’s header allow-list |
Reusing our verifier
Spectrum’s own delivery worker uses an internalverifyPhotonWebhook 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:
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).