Integrations / Reference

HMAC verification

Every Crispy webhook delivery is signed with HMAC-SHA256. Always verify the signature before trusting the body. Below are copy-pasteable verifiers in Node.js, Python, and Go, plus an idempotency helper that uses the Webhook-Event-Id header to short-circuit retries.

The wire format

  • Header: Webhook-Signature: v1,t=<unix_ts>,s=<hex_sha256>
  • Header: Webhook-Event-Id: <UUID> (stable across retries)
  • Body input to HMAC: v1.<unix_ts>.<raw_body> (literal string concatenation, not JSON)
  • Algorithm: HMAC-SHA256, hex-encoded
  • Rotation: the wire always carries a single primary signature. During rotation, your verifier should try the primary secret first, then the secondary. Both secrets are surfaced via the get_webhook MCP tool.
  • Constant-time comparison: always compare digests with timingSafeEqual (Node), hmac.compare_digest (Python), or hmac.Equal (Go). Never use == on signature strings.
  • Use the raw body: most HTTP frameworks parse JSON before your handler runs. You must access the raw bytes, not the parsed object, otherwise the digest won't match.

Node.js (TypeScript)

No dependencies beyond Node's built-in crypto. Works in standalone Node, Express, Fastify, and Next.js route handlers (use await req.text() in App Router).

import { createHmac, timingSafeEqual } from "crypto";

/**
 * Verify a Crispy v1 webhook signature.
 *
 * Header format: `Webhook-Signature: v1,t=<unix_ts>,s=<hex_sha256>`
 * Body input to HMAC: `v1.<unix_ts>.<raw_body>`
 *
 * Pass both signing_secret and signing_secret_secondary during rotation.
 * Read both from the get_webhook MCP tool or your dashboard.
 */
export function verifyV1Webhook(
  rawBody: string,
  header: string | undefined,
  primarySecret: string,
  secondarySecret?: string | null
): boolean {
  if (!header) return false;
  const trimmed = header.trim();
  if (!trimmed.startsWith("v1,")) return false;

  // Parse: v1,t=<ts>,s=<hex>
  let ts: number | null = null;
  let sig: string | null = null;
  for (const part of trimmed.slice(3).split(",")) {
    const [key, value] = part.split("=").map((s) => s.trim());
    if (key === "t") ts = Number(value);
    if (key === "s" && /^[a-f0-9]+$/i.test(value)) sig = value;
  }
  if (ts === null || !Number.isFinite(ts) || !sig) return false;

  const expectedPrimary = createHmac("sha256", primarySecret)
    .update(`v1.${ts}.${rawBody}`)
    .digest("hex");
  if (timingSafeHexEqual(expectedPrimary, sig)) return true;

  if (secondarySecret) {
    const expectedSecondary = createHmac("sha256", secondarySecret)
      .update(`v1.${ts}.${rawBody}`)
      .digest("hex");
    if (timingSafeHexEqual(expectedSecondary, sig)) return true;
  }
  return false;
}

function timingSafeHexEqual(a: string, b: string): boolean {
  if (a.length !== b.length) return false;
  try {
    const bufA = Buffer.from(a, "hex");
    const bufB = Buffer.from(b, "hex");
    if (bufA.length === 0) return false;
    return timingSafeEqual(bufA, bufB);
  } catch {
    return false;
  }
}

// --- Idempotency ---
// Webhook-Event-Id is identical across retries. Store delivered IDs for 7 days
// and short-circuit duplicates. Below is a Redis-backed example; swap for your
// store of choice.
async function alreadyProcessed(
  redis: { set: (k: string, v: string, opts: { NX: true; EX: number }) => Promise<string | null> },
  eventId: string
): Promise<boolean> {
  // Returns "OK" on first insert, null if key already exists.
  const result = await redis.set(`webhook:${eventId}`, "1", { NX: true, EX: 7 * 24 * 60 * 60 });
  return result === null;
}

// --- Express handler ---
import type { Request, Response } from "express";

export async function handleCrispyWebhook(req: Request, res: Response) {
  // IMPORTANT: rawBody must be the unparsed body string. In Express, register
  // express.raw({ type: "application/json" }) on this route, not express.json().
  const rawBody = (req.body as Buffer).toString("utf8");
  const eventId = req.header("Webhook-Event-Id");
  const signature = req.header("Webhook-Signature");

  const ok = verifyV1Webhook(
    rawBody,
    signature ?? undefined,
    process.env.CRISPY_WEBHOOK_SECRET!,
    process.env.CRISPY_WEBHOOK_SECRET_SECONDARY ?? null
  );
  if (!ok) {
    res.status(401).send("invalid signature");
    return;
  }

  if (!eventId || (await alreadyProcessed(redisClient, eventId))) {
    res.status(200).send("ok"); // idempotent: ack but do nothing
    return;
  }

  const payload = JSON.parse(rawBody);
  // ... do the actual work
  res.status(200).send("ok");
}

Python

Standard library only (hmac + hashlib). Flask example included; the verifier itself is framework-agnostic.

import hmac
import hashlib
from typing import Optional

def verify_v1_webhook(
    raw_body: bytes,
    header: Optional[str],
    primary_secret: str,
    secondary_secret: Optional[str] = None,
) -> bool:
    """
    Verify a Crispy v1 webhook signature.

    Header format: Webhook-Signature: v1,t=<unix_ts>,s=<hex_sha256>
    Body input to HMAC: f"v1.{ts}.{raw_body}"

    Pass both signing_secret and signing_secret_secondary during rotation.
    Read both from the get_webhook MCP tool or your dashboard.
    """
    if not header or not header.strip().startswith("v1,"):
        return False

    ts: Optional[int] = None
    sig: Optional[str] = None
    for part in header.strip()[3:].split(","):
        if "=" not in part:
            return False
        key, value = part.split("=", 1)
        key, value = key.strip(), value.strip()
        if key == "t":
            try:
                ts = int(value)
            except ValueError:
                return False
        elif key == "s":
            if not all(c in "0123456789abcdefABCDEF" for c in value) or not value:
                return False
            sig = value

    if ts is None or sig is None:
        return False

    body_input = f"v1.{ts}.".encode("utf-8") + raw_body

    expected_primary = hmac.new(
        primary_secret.encode("utf-8"), body_input, hashlib.sha256
    ).hexdigest()
    # constant-time comparison
    if hmac.compare_digest(expected_primary, sig):
        return True

    if secondary_secret:
        expected_secondary = hmac.new(
            secondary_secret.encode("utf-8"), body_input, hashlib.sha256
        ).hexdigest()
        if hmac.compare_digest(expected_secondary, sig):
            return True

    return False


# --- Idempotency ---
# Webhook-Event-Id is identical across retries. Store delivered IDs for 7 days
# and short-circuit duplicates. Redis SET NX EX example:

import redis as redis_lib  # pip install redis

def already_processed(r: redis_lib.Redis, event_id: str) -> bool:
    # SET key value NX EX 604800 -> returns True on first insert, None if exists
    result = r.set(f"webhook:{event_id}", "1", nx=True, ex=7 * 24 * 60 * 60)
    return result is None


# --- Flask handler ---
from flask import Flask, request, abort
import os
import json

app = Flask(__name__)
r = redis_lib.Redis.from_url(os.environ["REDIS_URL"])

@app.route("/webhooks/crispy", methods=["POST"])
def handle_crispy_webhook():
    # IMPORTANT: request.get_data() returns the raw bytes; do not call .json
    # before verification, otherwise a tampered body will be silently parsed.
    raw_body = request.get_data()
    signature = request.headers.get("Webhook-Signature")
    event_id = request.headers.get("Webhook-Event-Id")

    ok = verify_v1_webhook(
        raw_body,
        signature,
        os.environ["CRISPY_WEBHOOK_SECRET"],
        os.environ.get("CRISPY_WEBHOOK_SECRET_SECONDARY"),
    )
    if not ok:
        abort(401)

    if not event_id or already_processed(r, event_id):
        return ("", 200)  # idempotent: ack but do nothing

    payload = json.loads(raw_body)
    # ... do the actual work
    return ("", 200)

Go

Standard library only for verification. The handler example uses github.com/redis/go-redis/v9 for idempotency; swap for your store of choice.

package crispy

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"fmt"
	"io"
	"net/http"
	"os"
	"regexp"
	"strconv"
	"strings"
	"time"

	"github.com/redis/go-redis/v9"
)

var hexRe = regexp.MustCompile(`^[a-fA-F0-9]+$`)

// VerifyV1Webhook verifies a Crispy v1 webhook signature.
//
// Header format: Webhook-Signature: v1,t=<unix_ts>,s=<hex_sha256>
// Body input to HMAC: "v1.<unix_ts>.<raw_body>"
//
// Pass both signingSecret and signingSecretSecondary during rotation. Read both
// from the get_webhook MCP tool or your dashboard.
func VerifyV1Webhook(rawBody []byte, header string, primary string, secondary string) bool {
	header = strings.TrimSpace(header)
	if !strings.HasPrefix(header, "v1,") {
		return false
	}

	var ts int64
	var sig string
	var haveTs, haveSig bool
	for _, part := range strings.Split(header[3:], ",") {
		kv := strings.SplitN(strings.TrimSpace(part), "=", 2)
		if len(kv) != 2 {
			return false
		}
		key, value := strings.TrimSpace(kv[0]), strings.TrimSpace(kv[1])
		switch key {
		case "t":
			n, err := strconv.ParseInt(value, 10, 64)
			if err != nil {
				return false
			}
			ts = n
			haveTs = true
		case "s":
			if value == "" || !hexRe.MatchString(value) {
				return false
			}
			sig = value
			haveSig = true
		}
	}
	if !haveTs || !haveSig {
		return false
	}

	bodyInput := []byte(fmt.Sprintf("v1.%d.", ts))
	bodyInput = append(bodyInput, rawBody...)

	expectedPrimary := computeHmac(bodyInput, primary)
	if hmacEqual(expectedPrimary, sig) {
		return true
	}
	if secondary != "" {
		expectedSecondary := computeHmac(bodyInput, secondary)
		if hmacEqual(expectedSecondary, sig) {
			return true
		}
	}
	return false
}

func computeHmac(body []byte, secret string) string {
	h := hmac.New(sha256.New, []byte(secret))
	h.Write(body)
	return hex.EncodeToString(h.Sum(nil))
}

// hmacEqual decodes both as hex then uses hmac.Equal for constant-time compare.
func hmacEqual(expected string, got string) bool {
	if len(expected) != len(got) {
		return false
	}
	expectedBytes, err := hex.DecodeString(expected)
	if err != nil {
		return false
	}
	gotBytes, err := hex.DecodeString(got)
	if err != nil {
		return false
	}
	return hmac.Equal(expectedBytes, gotBytes)
}

// --- Idempotency ---
// Webhook-Event-Id is identical across retries. Store delivered IDs for 7 days
// and short-circuit duplicates.

func alreadyProcessed(r *redis.Client, eventID string) (bool, error) {
	// SET key value NX EX 604800 -> "OK" on first insert, "" if exists.
	ok, err := r.SetNX(ctxBackground(), "webhook:"+eventID, "1", 7*24*time.Hour).Result()
	if err != nil {
		return false, err
	}
	return !ok, nil
}

// --- net/http handler ---

func HandleCrispyWebhook(w http.ResponseWriter, req *http.Request) {
	// IMPORTANT: read the raw body BEFORE parsing JSON. The HMAC is computed
	// over the unparsed bytes.
	rawBody, err := io.ReadAll(req.Body)
	if err != nil {
		http.Error(w, "read error", http.StatusBadRequest)
		return
	}
	defer req.Body.Close()

	signature := req.Header.Get("Webhook-Signature")
	eventID := req.Header.Get("Webhook-Event-Id")

	ok := VerifyV1Webhook(
		rawBody,
		signature,
		os.Getenv("CRISPY_WEBHOOK_SECRET"),
		os.Getenv("CRISPY_WEBHOOK_SECRET_SECONDARY"),
	)
	if !ok {
		http.Error(w, "invalid signature", http.StatusUnauthorized)
		return
	}

	if eventID == "" {
		w.WriteHeader(http.StatusOK)
		return
	}
	dup, err := alreadyProcessed(redisClient, eventID)
	if err == nil && dup {
		w.WriteHeader(http.StatusOK) // idempotent: ack but do nothing
		return
	}

	// ... json.Unmarshal(rawBody, &payload); do the actual work
	w.WriteHeader(http.StatusOK)
}

Common mistakes

  • Verifying the parsed JSON instead of the raw body. The HMAC is over v1.<ts>.<raw_body>; once JSON-parsed and re-stringified, whitespace and key order will diverge.
  • Using == for comparison. Timing leaks let attackers brute-force signatures one byte at a time. Always use constant-time helpers.
  • Skipping the timestamp. The t= field is part of the signed input. If you drop it from the HMAC body input, every signature mismatches.
  • Not handling rotation. When you rotate signing secrets, both old and new are valid for the overlap window. A verifier that only tries the primary will reject deliveries during the rollover.
  • Treating Webhook-Event-Id as optional. Always store delivered IDs for 7 days. Network blips will cause Crispy to retry; without dedup, downstream systems will see duplicate side effects.