Webhook Signature Verification
How to verify webhook signatures to ensure events are from WarmySender.
Why Verify Signatures?
Without verification, anyone who discovers your endpoint URL could send fake events. Signature verification ensures only WarmySender can send valid events.
How It Works:
1) When you create a webhook, WarmySender shows a signing secret (it starts with whsec_) once, with a Copy secret button. You will not see this secret again. Save it before you close the panel.
1b) To replace a secret you missed: Settings > Webhooks, click the key icon on that row (Rotate secret), confirm, and copy the new secret. Only a workspace owner or admin can do this. The previous secret stops working immediately. The same replacement is available on the API as POST /api/v1/webhooks/{id}/rotate-secret (the secret is in that response once, and list/get never include it).
2) Every webhook delivery includes an X-Warmy-Signature header.
3) The header format is: t=<timestamp>,v1=<hex_signature>
4) To verify, reconstruct the signature using your secret and compare.
Test webhook:
The Test button on the row (and POST /api/v1/webhooks/{id}/test) queues a webhook.test event to that address. It is signed with the same X-Warmy-Signature header as every other event. Each try, including every retry, is signed at the moment it is sent: t= is the current time in milliseconds and v1= is computed from that time and the body. A retry does not reuse the original header, so a 5-minute freshness check does not reject a retry that was signed when it was sent. If your address does not accept the delivery, WarmySender tries again, up to 10 tries in total. The wait before each retry grows: 5 minutes, 15 minutes, 1 hour, 3 hours, 6 hours, 12 hours, 1 day, 2 days, then 3 days.
If deliveries keep failing:
After 50 events in a row each use up all 10 tries, the webhook is paused so it stops calling an address that is not accepting events. On that row, an owner or admin clicks Turn back on. That starts the count over. Pausing does not delete the endpoint.
Verification Steps:
Step 1: Extract timestamp and signature from X-Warmy-Signature header:
- Parse t= to get the timestamp (Unix milliseconds)
- Parse v1= to get the hex signature
Step 2: Build the signature payload:
- Concatenate: timestamp + "." + raw_request_body
- Example: "1710892810000.{"type":"reply.received",...}"
Step 3: Compute HMAC-SHA256:
- Use your webhook secret as the key
- Hash the signature payload from Step 2
- Get the hex digest
Step 4: Compare signatures:
- Use timing-safe comparison (NOT simple string equality)
- Reject if signatures do not match
Step 5 (Optional): Check timestamp freshness:
- Reject events older than 5 minutes to prevent replay attacks
- Compare: current_time - timestamp < 300000ms
Node.js Example:
const crypto = require('crypto');
function verify(secret, signatureHeader, body) {
const [tPart, vPart] = signatureHeader.split(',');
const timestamp = tPart.replace('t=', '');
const signature = vPart.replace('v1=', '');
const payload = timestamp + '.' + body;
const expected = crypto.createHmac('sha256', secret).update(payload).digest('hex');
return crypto.timingSafeEqual(Buffer.from(signature, 'hex'), Buffer.from(expected, 'hex'));
}
Python Example:
import hmac, hashlib
def verify(secret, signature_header, body):
parts = dict(p.split('=', 1) for p in signature_header.split(','))
payload = parts['t'] + '.' + body
expected = hmac.new(secret.encode(), payload.encode(), hashlib.sha256).hexdigest()
return hmac.compare_digest(parts['v1'], expected)
Common Mistakes:
- Using the raw bytes of the body, not the UTF-8 string — always use the raw request body as a string.
- Using simple == comparison instead of timing-safe — vulnerable to timing attacks.
- Forgetting to save the webhook secret on creation — it is shown only once. If you missed it, use Rotate secret on that webhook. Do not expect the secret in a support reply or an email.
- Parsing/modifying the JSON body before verification — verify against the raw body, then parse.