---
title: Verify webhook signatures
description: Drop-in Ruby, Python and Node verifiers for X-Cascades-Signature, with timestamp tolerance, constant-time comparison and secret-rotation support.
canonical_url: "https://cascades.crscreditapi.com/docs/guides/verify-webhooks"
doc_version: "1.0.0"
last_updated: "2026-10-05T19:24:37Z"
source: docs/guides/verify-webhooks.md
---

# Verify webhook signatures

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

```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:

```bash
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](/docs/concepts/webhooks) — subscriptions, retries, rotation
- [Webhook events reference](/docs/api/webhooks) — payload per event

## Sitemap

See the full [sitemap](https://cascades.crscreditapi.com/sitemap.md) for all pages.
