”Every request fails signature verification”
By far the most common issue, and almost always the same root cause: the body you hash isn’t the body we hashed.Diagnosis
Add a temporary log on your server:| Observation | Likely cause |
|---|---|
rawBodyLength === 0 | You called req.json() first, which consumed the stream. Read the raw body first. |
rawBodyFirst80 starts with { "event": (with extra spaces) | Your framework parsed and re-serialized. Capture raw bytes, not a parsed object. |
signatureLen !== 67 | Header truncated or a proxy is mangling it. Should be v0= + 64 hex chars. |
timestamp === undefined | Reverse proxy is stripping X-Spectrum-* headers. Add them to your allow-list. |
Fix by framework
| Framework | Trick |
|---|---|
| Express | Use express.raw({ type: 'application/json' }) instead of express.json() |
| FastAPI | await request.body() and don’t type the parameter as a Pydantic model |
| Hono | await c.req.text() before c.req.json() |
| Next.js Route Handler | await req.text() and parse with JSON.parse(text) |
| Cloudflare Workers | await request.text() |
”Most requests verify but some sporadically fail”
Two likely culprits:- Server clock skew. If your server’s clock drifts more than ~5 minutes from real UTC, every delivery looks “stale” and your timestamp check rejects it. Run
ntpd/chronydand confirm withdate -u. - You’re loading the wrong secret in some environments. Log
SECRET.length === 64on startup — if it’s ever false, you’ve loaded an env var from the wrong source.
”I never receive anything”
Walk through this checklist in order:Confirm the webhook is registered
Confirm the URL is reachable from the public internet
https://, resolves to a public address, and doesn’t redirect — otherwise the URL guard drops every delivery before it leaves our worker. See “Every delivery is dropped immediately” below.Confirm the platform is enabled and connected
Confirm the message is actually inbound to your project
Send the test message from a phone number that isn’t your own to the line attached to the project.
”Every delivery is dropped immediately”
The webhook is registered and the platform is sending events, but nothing reaches your server — and our logs show the delivery never left the worker. This is almost always the URL guard: we validate the destination before every attempt and fail closed, so a URL that doesn’t meet the delivery requirements drops every event without a singlePOST. Registration doesn’t catch this — it only checks URL syntax — so the webhook looks healthy in the list.
Check the registered URL against each rule:
| Symptom | Cause | Fix |
|---|---|---|
URL starts with http:// | HTTPS is required; plaintext is rejected | Re-register with an https:// URL |
URL is localhost / 127.0.0.1 / a private or internal address | Blocked as SSRF — must resolve to a public IP | Expose the endpoint publicly (ngrok in dev, a real host in prod) |
URL 301/302-redirects (trailing slash, http→https bounce, LB redirect) | We send redirect: "manual" and never follow a 3xx | Register the final URL the redirect points to |
| Hostname doesn’t resolve | DNS lookup fails; the guard fails closed | Fix DNS, or register a resolvable host |
”I receive duplicates”
This is expected behavior under at-least-once delivery. The two scenarios that cause it:- Your handler succeeded but timed out before responding. We retried, you processed twice.
- Your handler returned
5xxafter partially processing. We retried, you re-ran the partial work.
Fix
Dedupe at the top of your handler usingX-Spectrum-Webhook-Id plus payload.message.id as a composite key:
”Deliveries time out”
If you’re seeing your endpoint logged as “took >30s,” it triggers a retry on our side and a likely duplicate processing on yours.Diagnosis
Look at what your handler is doing synchronously:Fix
Acknowledge first, process asynchronously:”ngrok URL keeps changing”
Free ngrok tunnels get a new URL every restart. That URL won’t be registered with us, so deliveries 404 immediately.Fix
-
For local development, kill the old webhook and re-register every time you restart ngrok:
- Better: use a reserved subdomain (paid plan) so the URL is stable across restarts.
- Best for long-lived dev: deploy a tiny forwarder (Cloudflare Worker, Vercel function) that POSTs to your local machine over a stable tunnel.
”The signing secret leaked”
Treat it like any other credential leak.- Rotate the secret using the delete-and-recreate flow in Managing webhooks.
- If you suspect the project credentials also leaked, rotate them too:
photon projects regenerate-secret <id>. - Audit recent inbound events for anomalies (events for spaces you don’t recognize, suspicious sender ids, payloads with unusual content).
”I want to test verification without spamming real messages”
Build a test request locally that mimics a real delivery:test-verify.ts
bun test-verify.ts), copy the printed curl, and paste it to test your verifier offline.
”How do I see what we sent you?”
We don’t currently expose a delivery log to customers. If you need to debug a specific event, the fastest path is:- Reach out to support with the webhook id, the approximate UTC timestamp, and what you observed on your side.
- We can confirm whether the delivery left our worker, what status code came back, and how many retries it took.
”Multiple webhook URLs receive the event in different orders”
That’s expected. Deliveries to multiple URLs run in parallel — there’s no ordering guarantee across URLs. If your downstream consumers need to coordinate, designate one URL as the primary and have it fan out internally rather than registering several with us.Still stuck?
Send us:- Your project id (the one in
photon projects show). - The webhook id or the URL.
- A timestamp range (UTC).
- One example body and signature you couldn’t verify (with the secret redacted).