Skip to main content

Verify webhook signatures

Anyone who knows your endpoint URL can post to it. Always verify the Reeds-Signature header before you trust an event.

How the signature works​

Every request carries this header:

Reeds-Signature: t=1793016302,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
  • t is the Unix time (seconds) when Reeds sent the request.
  • v1 is the hex HMAC-SHA256 of the string t + "." + raw body, keyed with your endpoint's signing secret (whsec_test_... or whsec_live_...).

Verify in four steps:

  1. Split the header into t and the v1 value or values.
  2. Build the signed string: t, a dot, then the raw request body.
  3. Compute the HMAC-SHA256 of it with your whsec_ secret and hex-encode it.
  4. Compare it to each v1 with a constant-time comparison. Accept if any matches. Then reject the request if t is more than 5 minutes from your clock.
info

Every attempt, retries included, is signed afresh with the time it is sent. A retry that arrives a day later still passes the 5-minute check.

Your signing secret​

The signing secret belongs to the webhook endpoint. It is not your client secret, and the two never substitute for each other. Find it in Dashboard → Developers, next to the endpoint URL. Store it like a password.

A Test endpoint's secret starts with whsec_test_ and a Live endpoint's with whsec_live_, so you can tell at a glance which one your server holds.

Complete examples​

You can't verify in cURL, but you can compute the signature by hand with openssl to debug a mismatch. Save the exact body you received to body.json.

SECRET='whsec_test_xxxxxxxxxxxx'
T=1793016302

# HMAC-SHA256 of "t.body", hex encoded (cat keeps the body byte for byte)
{ printf '%s.' "$T"; cat body.json; } \
| openssl dgst -sha256 -hmac "$SECRET" -hex

Compare the output with the v1 value in the header. If they differ, the body you saved has changed in some way (whitespace, key order, encoding) or you are using the wrong secret.

Rotating the signing secret​

When you rotate the secret in the dashboard, Reeds keeps the old one valid for 24 hours. During that time each request carries two signatures, one for each secret:

Reeds-Signature: t=1793016302,v1=<signed with the new secret>,v1=<signed with the old secret>

Accept the request if any v1 matches. The examples above already do this. Deploy the new secret within the 24 hours, and the old one stops working after that.

Only the current secret and the one before it ever sign. If you rotate again within the 24 hours, the oldest secret stops at once.

Common mistakes​

MistakeFix
Verifying the parsed JSON, or JSON re-serialised after parsingVerify the exact bytes Reeds sent. Re-serialising changes spacing and key order, and the signature no longer matches.
A body parser runs before your handlerUse a raw-body route (express.raw, php://input, request.get_data()).
Using your client secret (rcs_...)Use the endpoint's whsec_ secret.
Comparing with ==Use a constant-time compare.
Using a Live secret for Test eventsEach mode has its own endpoint and secret. Test secrets start whsec_test_, Live ones whsec_live_.
Skipping the timestamp checkReject a t more than 5 minutes old. This stops replays.
Your server's clock is wrongKeep your server clock synced with NTP.

Next steps​