Tutorial: Verify webhook signatures

This tutorial gets you from "Cardda just hit my endpoint" to "I know this is a real, fresh, untampered-with payload" in 20 lines of code.

The reference description of the signature scheme lives at Webhooks; this tutorial focuses on building and testing a verifier in the language you actually use.

What you have to verify

Cardda sends three things you care about:

HeaderPurpose
X-Cardda-TimestampUnix seconds. Reject if too old (replay attack).
X-Cardda-Signaturehex(HMAC-SHA256(secret, "<timestamp>.<raw_body>"))
X-Cardda-Event-IdUUID. Use for idempotency, not for security.

Your verifier must check the timestamp, the signature, and deduplicate by event id.

Reference implementation — Node.js

import crypto from "node:crypto";

const MAX_AGE_SECONDS = 5 * 60;

export function verifyCarddaWebhook({ rawBody, headers, secret }) {
  const signature = (headers["x-cardda-signature"] ?? "").toString();
  const timestamp = (headers["x-cardda-timestamp"] ?? "").toString();
  const eventId   = (headers["x-cardda-event-id"] ?? "").toString();

  if (!signature || !timestamp || !eventId) {
    throw new VerifyError("missing_headers");
  }

  const ageSec = Math.abs(Date.now() / 1000 - Number(timestamp));
  if (!Number.isFinite(ageSec) || ageSec > MAX_AGE_SECONDS) {
    throw new VerifyError("stale_or_invalid_timestamp");
  }

  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${timestamp}.${rawBody.toString("utf8")}`)
    .digest("hex");

  const sigBuf = Buffer.from(signature, "hex");
  const expBuf = Buffer.from(expected, "hex");
  if (sigBuf.length !== expBuf.length || !crypto.timingSafeEqual(sigBuf, expBuf)) {
    throw new VerifyError("bad_signature");
  }

  return { eventId, timestamp: Number(timestamp), body: JSON.parse(rawBody.toString("utf8")) };
}

export class VerifyError extends Error {}

Express handler:

import express from "express";
import { verifyCarddaWebhook, VerifyError } from "./verify.js";

const app = express();

app.post("/webhooks/cardda",
  express.raw({ type: "application/json" }),
  async (req, res) => {
    try {
      const { eventId, body } = verifyCarddaWebhook({
        rawBody: req.body,
        headers: req.headers,
        secret: process.env.CARDDA_WEBHOOK_SECRET,
      });
      const inserted = await db.processedEvents.insertIfAbsent({ id: eventId });
      if (inserted) await processEvent(body);
      res.status(200).end("ok");
    } catch (e) {
      if (e instanceof VerifyError) return res.status(400).end(e.message);
      console.error(e);
      res.status(500).end("oops");
    }
  }
);

Reference implementation — Python (Flask)

import hashlib, hmac, json, os, time
from flask import Flask, request

app = Flask(__name__)
SECRET = os.environ["CARDDA_WEBHOOK_SECRET"]
MAX_AGE = 5 * 60

@app.post("/webhooks/cardda")
def receive():
    raw = request.get_data()  # raw bytes — DO NOT use request.json
    sig = request.headers.get("X-Cardda-Signature", "")
    ts  = request.headers.get("X-Cardda-Timestamp", "")
    evt = request.headers.get("X-Cardda-Event-Id", "")

    if not (sig and ts and evt):
        return "missing headers", 400

    try:
        age = abs(time.time() - int(ts))
    except ValueError:
        return "bad timestamp", 400
    if age > MAX_AGE:
        return "stale", 400

    expected = hmac.new(SECRET.encode(), f"{ts}.{raw.decode()}".encode(), hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, sig):
        return "bad signature", 401

    if not insert_if_absent(evt):
        return "ok (dup)", 200

    process_event(json.loads(raw.decode()))
    return "ok", 200

Reference implementation — Ruby (Rails)

class Webhooks::CarddaController < ApplicationController
  skip_before_action :verify_authenticity_token

  MAX_AGE = 5.minutes

  def receive
    raw = request.raw_post
    sig = request.headers["X-Cardda-Signature"].to_s
    ts  = request.headers["X-Cardda-Timestamp"].to_s
    evt = request.headers["X-Cardda-Event-Id"].to_s

    return head :bad_request if [sig, ts, evt].any?(&:blank?)
    return head :bad_request if (Time.now.to_i - ts.to_i).abs > MAX_AGE.to_i

    expected = OpenSSL::HMAC.hexdigest("SHA256", ENV.fetch("CARDDA_WEBHOOK_SECRET"), "#{ts}.#{raw}")
    return head :unauthorized unless ActiveSupport::SecurityUtils.secure_compare(sig, expected)

    return head :ok unless ProcessedWebhook.find_or_create_by(event_id: evt).previously_new_record?

    HandleCarddaEventJob.perform_later(JSON.parse(raw))
    head :ok
  end
end

Testing your verifier

The hardest part of webhooks is testing them. Three patterns work well:

a. Sign locally and POST yourself

import hmac, hashlib, time, requests, json

SECRET = "test_secret"
body = json.dumps({"event": "ping"}).encode()
ts = str(int(time.time()))
sig = hmac.new(SECRET.encode(), f"{ts}.{body.decode()}".encode(), hashlib.sha256).hexdigest()

requests.post(
    "http://localhost:3000/webhooks/cardda",
    data=body,
    headers={
        "Content-Type": "application/json",
        "X-Cardda-Signature": sig,
        "X-Cardda-Timestamp": ts,
        "X-Cardda-Event-Id": "00000000-0000-0000-0000-000000000001",
    },
)

b. Use the dashboard's "Send test event"

In the Cardda dashboard, the webhook delivery log has a Send test event button that signs with your real secret and points at your registered URL.

c. Replay a real failed delivery

The dashboard shows the last 100 deliveries per webhook. Each can be replayed with Replay — same body, fresh timestamp, fresh signature.

Common pitfalls

  • Parsing the body before computing the HMAC. JSON parsers do not preserve key order or whitespace. You must sign the raw bytes exactly as received. In Express, use express.raw. In Flask, request.get_data(). In Rails, request.raw_post.
  • Comparing strings with == instead of constant-time. Vulnerable to timing attacks. Use crypto.timingSafeEqual / hmac.compare_digest / ActiveSupport::SecurityUtils.secure_compare.
  • Trusting Date headers. Use X-Cardda-Timestamp, not Date. Proxies rewrite Date.
  • Skipping idempotency. Cardda may deliver the same event twice (deploy, network blip). Without a dedup table you can double-charge / double-create.

Related