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_webhookMCP tool. - Constant-time comparison: always compare digests with
timingSafeEqual(Node),hmac.compare_digest(Python), orhmac.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.