SteveSignalsDocs

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:

text
1Signals-Signature: t=<unix seconds>,v1=<hex>

Split the header

Split on ,. Read t (the timestamp, in unix seconds) and every v1 value.

Check the timestamp

Reject the request if the absolute difference between now and 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

HMAC-SHA256 of the signed payload, keyed with your signing secret (the whole whsec_… string, as text). Encode as lowercase hex.

Compare in constant time

Compare to each 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

MistakeFix
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:

bash
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/^.* //')
5
6curl -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.