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:
| Header | Purpose |
|---|---|
X-Cardda-Timestamp | Unix seconds. Reject if too old (replay attack). |
X-Cardda-Signature | hex(HMAC-SHA256(secret, "<timestamp>.<raw_body>")) |
X-Cardda-Event-Id | UUID. 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", 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
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
endTesting 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. 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.
Related
- Webhooks — protocol reference.
- Verification Codes via Webhook — the only event family live today, with an end-to-end Flask receiver.
Updated 1 day ago
