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
tis the Unix time (seconds) when Reeds sent the request.v1is the hex HMAC-SHA256 of the stringt + "." + raw body, keyed with your endpoint's signing secret (whsec_test_...orwhsec_live_...).
Verify in four steps:
- Split the header into
tand thev1value or values. - Build the signed string:
t, a dot, then the raw request body. - Compute the HMAC-SHA256 of it with your
whsec_secret and hex-encode it. - Compare it to each
v1with a constant-time comparison. Accept if any matches. Then reject the request iftis more than 5 minutes from your clock.
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
- cURL
- Node.js
- PHP
- Python
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.
import crypto from 'node:crypto';
const TOLERANCE_SECONDS = 300;
export function verifyReedsSignature(rawBody, header, secret) {
const parts = header.split(',').map((p) => p.trim().split('='));
const t = parts.find(([k]) => k === 't')?.[1];
const signatures = parts.filter(([k]) => k === 'v1').map(([, v]) => v);
if (!t || signatures.length === 0) return false;
const age = Math.abs(Date.now() / 1000 - Number(t));
if (!Number.isFinite(age) || age > TOLERANCE_SECONDS) return false;
const expected = crypto
.createHmac('sha256', secret)
.update(`${t}.${rawBody}`)
.digest('hex');
return signatures.some(
(s) =>
s.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(s), Buffer.from(expected)),
);
}
// Express: use the raw body, not the parsed one.
// app.post('/webhooks/reeds', express.raw({ type: 'application/json' }), (req, res) => {
// const ok = verifyReedsSignature(req.body.toString('utf8'), req.get('Reeds-Signature') || '', process.env.REEDS_WEBHOOK_SECRET);
// if (!ok) return res.sendStatus(400);
// ...
// });
<?php
function verify_reeds_signature(string $rawBody, string $header, string $secret, int $tolerance = 300): bool {
$t = null;
$signatures = [];
foreach (explode(',', $header) as $part) {
[$k, $v] = array_pad(explode('=', trim($part), 2), 2, '');
if ($k === 't') $t = $v;
if ($k === 'v1') $signatures[] = $v;
}
if (!$t || !ctype_digit($t) || count($signatures) === 0) return false;
if (abs(time() - (int)$t) > $tolerance) return false;
$expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret);
foreach ($signatures as $s) {
if (hash_equals($expected, $s)) return true;
}
return false;
}
$raw = file_get_contents('php://input'); // not json_decode / $_POST
$ok = verify_reeds_signature($raw, $_SERVER['HTTP_REEDS_SIGNATURE'] ?? '', getenv('REEDS_WEBHOOK_SECRET'));
if (!$ok) {
http_response_code(400);
exit;
}
import hashlib
import hmac
import time
TOLERANCE_SECONDS = 300
def verify_reeds_signature(raw_body: bytes, header: str, secret: str) -> bool:
parts = [p.strip().split("=", 1) for p in header.split(",") if "=" in p]
t = next((v for k, v in parts if k == "t"), None)
signatures = [v for k, v in parts if k == "v1"]
if not t or not t.isdigit() or not signatures:
return False
if abs(time.time() - int(t)) > TOLERANCE_SECONDS:
return False
expected = hmac.new(
secret.encode(), t.encode() + b"." + raw_body, hashlib.sha256
).hexdigest()
return any(hmac.compare_digest(expected, s) for s in signatures)
# Flask: request.get_data() is the raw body.
# ok = verify_reeds_signature(request.get_data(), request.headers.get("Reeds-Signature", ""), WEBHOOK_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
| Mistake | Fix |
|---|---|
| Verifying the parsed JSON, or JSON re-serialised after parsing | Verify the exact bytes Reeds sent. Re-serialising changes spacing and key order, and the signature no longer matches. |
| A body parser runs before your handler | Use 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 events | Each mode has its own endpoint and secret. Test secrets start whsec_test_, Live ones whsec_live_. |
| Skipping the timestamp check | Reject a t more than 5 minutes old. This stops replays. |
| Your server's clock is wrong | Keep your server clock synced with NTP. |