Webhooks
Verify signatures
The exact algorithm Signals uses to sign webhooks, and the common mistakes.
Every webhook request (one run webhook) is signed with your endpoint's secret. Verify the signature before you trust the body. Anyone can POST to a public URL. Only Signals can produce a valid signature.
The algorithm
The Signals-Signature header looks like this:
1Signals-Signature: t=<unix seconds>,v1=<hex>
Split the header
,. Read t (the timestamp, in unix seconds) and every v1 value.Check the timestamp
t is more than 300 seconds. This stops replays.Build the signed payload
${t}.${rawBody}: the timestamp, a dot, then the exact bytes of the request body.Compute the HMAC
whsec_… string, as text). Encode as lowercase hex.Compare in constant time
v1 value with a constant-time function. Accept the request if any value matches.Two signatures after a rotation
For 24 hours after you click Rotate secret, the header carries two v1= values: first signed with the new secret, then with the old one. Accept the request if any matches. That lets you deploy the new secret without dropping events. After 24 hours there is one value again.
Common mistakes
| Mistake | Fix |
|---|---|
| Verifying parsed JSON. Re-serialized JSON rarely gives the same bytes. | Use the raw request body, exactly as it arrived. |
| A middleware or proxy changes whitespace, line endings or encoding. | Verify before any middleware that touches the body. Do not trim, decode or re-encode. |
| Using the old secret after Rotate secret. | Update the secret in your app. During the first 24 hours the old one still matches, so the mistake shows up on day two. |
Using == to compare signatures. | Use a constant-time compare function. |
Signing only the body, or only t. | Sign t, a dot, then the body. |
Stripping the whsec_ prefix or base64-decoding the secret. | The key is the full whsec_… string as text. Do not strip the prefix or base64-decode it. |
| A server clock that drifts more than 5 minutes. | Sync the clock with NTP. Drift makes every request fail the timestamp check. |
Verifying only the first v1. | Check every v1 value. |
Test your endpoint
Build a signature yourself, send it to your endpoint with curl, and expect 200:
1SECRET='whsec_XXXX'2BODY='{"version":3,"webhookId":"manual-test-1","type":"run.completed","eventType":"reviews","sentAt":"2026-01-01T00:00:00.000Z","run":{"id":"manual-run-1","trackerId":"manual-tracker-1","projectId":null,"source":"reviews","completedAt":"2026-01-01T00:00:00.000Z","competitor":{"name":"Example","domain":"example.com","subjectKey":"example"}},"pageSize":100,"events":{"reviews.new":{"total":0,"totalPages":0,"firstPageToken":null},"reviews.updated":{"total":0,"totalPages":0,"firstPageToken":null}}}'3T=$(date +%s)4SIG=$(printf '%s.%s' "$T" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex | sed 's/^.* //')56curl -i https://your-app.com/signals-hook \7 -H 'Content-Type: application/json' \8 -H "Signals-Event-Version: 3" \9 -H "Signals-Webhook-Id: manual-test-1" \10 -H "Signals-Signature: t=$T,v1=$SIG" \11 --data-binary "$BODY"
The body has no tokens, so your page loop has nothing to fetch. Then change one character of the body and expect 400. The easiest real check is Send test event: see Set up a webhook.