Sign in to run requests with your own sandbox key. Sign in

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?

  1. Parse the header into t and the v… values.
  2. Reject if t is missing or further than your tolerance from now (60 seconds is a reasonable cap) — this defeats replay.
  3. Compute the HMAC over the raw request body bytes. Do not parse and re-serialise the JSON first; any reformatting changes the digest.
  4. Compare with a constant-time function.
  5. Only then parse the body and act on it — and dedupe on X-Cascades-Delivery, because retries re-deliver the same event.

Ruby

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
end

This 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.

Ruby
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
end

Python

Python
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

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:

cURL
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.

Was this page helpful?