Guides
Verify webhook signatures
Drop-in Ruby, Python and Node verifiers for X-Cascades-Signature, with timestamp tolerance, constant-time comparison and secret-rotation support.
Every webhook delivery carries X-Cascades-Signature: t=<unix timestamp>,v1=<hex> where v1 is HMAC-SHA256(signing_secret, "<t>.<raw body>"). During the 24 hours after a secret rotation the header also carries v0=<hex>, signed with the previous secret. Each verifier below accepts the request if any v?= value matches, so it handles steady state and rotation without changes.
What must the verifier do?
- Parse the header into
tand thev…values. - Reject if
tis missing or further than your tolerance from now (60 seconds is a reasonable cap) — this defeats replay. - Compute the HMAC over the raw request body bytes. Do not parse and re-serialise the JSON first; any reformatting changes the digest.
- Compare with a constant-time function.
- Only then parse the body and act on it — and dedupe on
X-Cascades-Delivery, because retries re-deliver the same event.
Ruby
require "openssl"
def verify_cascades_signature(header, raw_body, secret, tolerance: 60)
parts = header.to_s.split(",").map { |p| p.split("=", 2) }.to_h
ts = parts["t"].to_i
return false if ts == 0
return false if (Time.now.to_i - ts).abs > tolerance
expected = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{ts}.#{raw_body}")
parts.any? do |k, v|
k.start_with?("v") && OpenSSL.secure_compare(expected, v)
end
endThis is plain Ruby: OpenSSL.secure_compare ships with Ruby 3.0 and later, so it runs in a Rails app and outside one. In a Rails controller, the raw body is request.raw_post.
class CascadesWebhooksController < ApplicationController
skip_before_action :verify_authenticity_token
def create
unless verify_cascades_signature(request.headers["X-Cascades-Signature"], request.raw_post, ENV.fetch("CASCADES_WEBHOOK_SECRET"))
return head :unauthorized
end
delivery_id = request.headers["X-Cascades-Delivery"]
ProcessCascadesEventJob.perform_later(delivery_id, JSON.parse(request.raw_post)) unless Event.exists?(delivery_id: delivery_id)
head :ok
end
endPython
import hmac
import hashlib
import time
def verify_cascades_signature(header, raw_body, secret, tolerance=60):
parts = dict(p.split("=", 1) for p in header.split(","))
ts = int(parts.get("t", 0))
if ts == 0 or abs(int(time.time()) - ts) > tolerance:
return False
signed = f"{ts}.{raw_body}".encode()
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return any(
k.startswith("v") and hmac.compare_digest(expected, v)
for k, v in parts.items()
)With Flask, raw_body is request.get_data(as_text=True); with FastAPI, (await request.body()).decode().
Node.js
const crypto = require("crypto");
function verifyCascadesSignature(header, rawBody, secret, tolerance = 60) {
const parts = Object.fromEntries(header.split(",").map(p => p.split("=")));
const ts = parseInt(parts.t, 10);
if (!ts) return false;
if (Math.abs(Math.floor(Date.now() / 1000) - ts) > tolerance) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${ts}.${rawBody}`)
.digest("hex");
const expBuf = Buffer.from(expected, "hex");
return Object.entries(parts).some(([k, v]) => {
if (!k.startsWith("v")) return false;
const sigBuf = Buffer.from(v, "hex");
return sigBuf.length === expBuf.length && crypto.timingSafeEqual(expBuf, sigBuf);
});
}With Express, mount the route with express.raw({ type: "application/json" }) so req.body is the untouched Buffer, and pass req.body.toString("utf8").
Testing your verifier
Sign a body yourself with the secret from POST /webhook_subscriptions and post it to your endpoint:
BODY='{"id":"test","event":"execution.completed","created_at":"2026-09-09T14:03:21Z","data":{}}'
TS=$(date +%s)
SIG=$(printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$CASCADES_WEBHOOK_SECRET" | awk '{print $2}')
curl -X POST https://hooks.partner.example/cascades \
-H "Content-Type: application/json" \
-H "X-Cascades-Event: execution.completed" \
-H "X-Cascades-Delivery: 1" \
-H "X-Cascades-Signature: t=$TS,v1=$SIG" \
--data-raw "$BODY"Then trigger a test-mode run and watch GET /webhook_deliveries for the real delivery's status and last_response_status.
Related
- Webhooks — subscriptions, retries, rotation
- Webhook events reference — payload per event