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's live today vs. roadmap. The only webhook in production right now is PLH SMS verification codes (see Verification Codes via Webhook). It ships only the two security headers (X-Cardda-Signature and X-Cardda-Timestamp); there is no X-Cardda-Event-Id header today — you must dedup on the payload's id field (the SMS's UUID). The header-based dedup described below is part of the general-purpose webhook system on the roadmap; if you are integrating PLH SMS today, drop the X-Cardda-Event-Id check and use payload.id instead. We keep the header-based examples here so your verifier is ready when the general-purpose system ships.

What you have to verify

Cardda's webhook envelope is built around three pieces of metadata. The first two are live today; the third is part of the roadmap general-purpose contract — your verifier should be lenient about it until that ships.

HeaderLive today?Purpose
X-Cardda-Timestamp✅ YesUnix seconds. Reject if too old (replay attack).
X-Cardda-Signature✅ Yeshex(HMAC-SHA256(secret, "<timestamp>.<raw_body>"))
X-Cardda-Event-Id🛣 RoadmapUUID. Will be the canonical dedup key once the general-purpose webhook system ships. For PLH SMS today, dedup on payload.id instead.

Your verifier must always check the timestamp and the signature, and deduplicate — either by X-Cardda-Event-Id (future) or by payload.id (today).

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();
  // X-Cardda-Event-Id is NOT sent today by the live PLH SMS webhook —
  // it is part of the roadmap general-purpose contract. Read it leniently:
  // when absent (PLH today), fall back to payload.id below.
  const eventIdHeader = (headers["x-cardda-event-id"] ?? "").toString();

  if (!signature || !timestamp) {
    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");
  }

  const body = JSON.parse(rawBody.toString("utf8"));
  // Prefer the header (future) for the dedup key, fall back to payload.id (PLH today).
  // Mirror the Python/Rails examples: reject when neither source is present, otherwise
  // an undefined dedupKey would either collapse unrelated malformed deliveries onto the
  // same key or fail to insert at all.
  const dedupKey = eventIdHeader || body.id;
  if (!dedupKey) {
    throw new VerifyError("no_dedup_key");
  }
  return { dedupKey, timestamp: Number(timestamp), body };
}

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 { dedupKey, body } = verifyCarddaWebhook({
        rawBody: req.body,
        headers: req.headers,
        secret: process.env.CARDDA_WEBHOOK_SECRET,
      });
      const inserted = await db.processedEvents.insertIfAbsent({ id: dedupKey });
      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", "")
    # X-Cardda-Event-Id is roadmap, NOT sent by the live PLH SMS webhook today.
    # Read it leniently and fall back to payload["id"] for dedup below.
    evt_header = request.headers.get("X-Cardda-Event-Id", "")

    if not (sig and ts):
        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

    payload = json.loads(raw.decode())
    # Prefer the header (future) for the dedup key, fall back to payload["id"] (PLH today).
    dedup_key = evt_header or payload.get("id")
    if not dedup_key:
        return "no dedup key (missing both header and payload id)", 400
    if not insert_if_absent(dedup_key):
        return "ok (dup)", 200

    process_event(payload)
    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
    # X-Cardda-Event-Id is roadmap, NOT sent by the live PLH SMS webhook today.
    # Read it leniently and fall back to payload["id"] for dedup below.
    evt_header = request.headers["X-Cardda-Event-Id"].to_s

    return head :bad_request if [sig, ts].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}")
    # secure_compare raises on length mismatch — guard first so a malformed
    # signature returns 401, not 500.
    return head :unauthorized if sig.bytesize != expected.bytesize
    return head :unauthorized unless ActiveSupport::SecurityUtils.secure_compare(sig, expected)

    payload = JSON.parse(raw)
    # Prefer the header (future) for the dedup key, fall back to payload["id"] (PLH today).
    dedup_key = evt_header.presence || payload["id"]
    return head :bad_request if dedup_key.blank?

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

    HandleCarddaEventJob.perform_later(payload)
    head :ok
  rescue JSON::ParserError
    head :bad_request
  end
end

Testing your verifier

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

a. Sign locally and POST yourself

This snippet mirrors the live PLH SMS webhook (only the two security headers, payload carries a top-level id):

import hmac, hashlib, time, requests, json

SECRET = "test_secret"
# PLH SMS payloads include a top-level `id` — your verifier uses it for dedup.
body = json.dumps({"id": "00000000-0000-0000-0000-000000000001", "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,
        # No "X-Cardda-Event-Id" — PLH does not send it today.
        # Add `"X-Cardda-Event-Id": "<uuid>"` here when you want to simulate
        # the roadmap general-purpose contract.
    },
)

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.
  • Requiring X-Cardda-Event-Id today. That header is part of the roadmap general-purpose contract — the live PLH SMS webhook does not send it. Dedup on payload.id (the SMS UUID) for now; treat the header as a preferred-but-optional fallback so your verifier upgrades cleanly when the general system ships.

Related