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-SignatureandX-Cardda-Timestamp); there is noX-Cardda-Event-Idheader today — you must dedup on the payload'sidfield (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 theX-Cardda-Event-Idcheck and usepayload.idinstead. 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.
| Header | Live today? | Purpose |
|---|---|---|
X-Cardda-Timestamp | ✅ Yes | Unix seconds. Reject if too old (replay attack). |
X-Cardda-Signature | ✅ Yes | hex(HMAC-SHA256(secret, "<timestamp>.<raw_body>")) |
X-Cardda-Event-Id | 🛣 Roadmap | UUID. 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", 200Reference 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
endTesting 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. Usecrypto.timingSafeEqual/hmac.compare_digest/ActiveSupport::SecurityUtils.secure_compare. - Trusting
Dateheaders. UseX-Cardda-Timestamp, notDate. Proxies rewriteDate. - 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-Idtoday. That header is part of the roadmap general-purpose contract — the live PLH SMS webhook does not send it. Dedup onpayload.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
- Webhooks — protocol reference.
- Verification Codes via Webhook — the only event family live today, with an end-to-end Flask receiver.
Updated 7 days ago
