Webhook Signature Verification
All webhook event deliveries from Rook are cryptographically signed using HMAC-SHA256 to ensure authenticity, payload integrity, and replay protection.
Webhook Headers
Every webhook POST request delivered to your registered endpoint includes two verification headers:
| Header | Description | Example Value |
|---|---|---|
| Rook-Timestamp | Unix epoch timestamp (seconds) when the event signature was generated. | 1756832940 |
| Rook-Signature | Hex-encoded HMAC-SHA256 digest prefixed by v1=. May contain multiple comma-separated signatures during secret rotation. | v1=9f8a3c2e1b4d5e6f7a8b... |
Verification Algorithm
- Extract Headers: Read
Rook-TimestampandRook-Signature. Reject the delivery (HTTP 400/401) if either is missing. - Replay Window Defense: Compare the timestamp to the current clock. Reject if
|currentTime - timestamp| > 300 seconds(5 minutes). - Prepare Signed Payload: Concatenate
timestamp + "." + rawRequestBody. Do not parse, trim, or re-encode the raw JSON payload bytes. - Compute HMAC-SHA256: Calculate HMAC using your webhook secret (starts with
whsec_...) and hex-encode in lowercase. - Constant-Time Comparison: Compare the computed hex digest against each
v1=token inRook-Signatureusing constant-time equality. Accept if any matches.
Verification Code Example (TypeScript)
import crypto from 'node:crypto';
export function verifyRookWebhook(
rawBody: string,
timestampHeader: string,
signatureHeader: string,
webhookSecret: string
): boolean {
// 1. Validate replay window (300 seconds)
const timestamp = parseInt(timestampHeader, 10);
const now = Math.floor(Date.now() / 1000);
if (isNaN(timestamp) || Math.abs(now - timestamp) > 300) {
return false;
}
// 2. Prepare signed material: timestamp.body
const signedPayload = `{timestamp}.{rawBody}`;
// 3. Compute HMAC-SHA256
const computedDigest = crypto
.createHmac('sha256', webhookSecret)
.update(signedPayload, 'utf8')
.digest('hex');
// 4. Split comma-separated signatures for rotation support
const signatures = signatureHeader
.split(',')
.map((s) => s.trim())
.filter((s) => s.startsWith('v1='))
.map((s) => s.slice(3));
// 5. Constant-time match
return signatures.some((sig) => {
if (sig.length !== computedDigest.length) return false;
return crypto.timingSafeEqual(Buffer.from(sig, 'hex'), Buffer.from(computedDigest, 'hex'));
});
}Secret Rotation Handling
During secret rotation in the Rook Dashboard, deliveries temporarily contain two signatures separated by commas (the previous secret and the newly provisioned secret). Your endpoint should accept the request if either signature verifies cleanly.