Webhooks 🪝
Cardda emits HTTP webhooks to URLs you register, so your integration can react to events without polling.
Today there is a single webhook in production: PLH SMS verification codes. Its real contract (headers, retries, idempotency) is documented in Verification Codes via Webhook. A general-purpose event-bus (e.g.bank_transaction.created,card.issued,fiscal_invoice.processed) is on the roadmap; the Roadmap section at the bottom of this page describes that future contract — do not depend on it yet.
Live today: PLH SMS verification codes
When a card transaction triggers an SMS step-up at PLH (our Chilean issuer), Cardda forwards the SMS to your registered URL within seconds.
Headers actually sent today
| Header | Description |
|---|---|
Content-Type: application/json | Body is always JSON. |
X-Cardda-Signature | Hex-encoded HMAC-SHA256 of <timestamp>.<raw_body> using the shared secret you set when registering the webhook. |
X-Cardda-Timestamp | Unix timestamp (seconds) when Cardda generated the request. |
That is the full list. Aside from the standard Content-Type: application/json, no other Cardda-specific headers are sent today by the PLH SMS webhook — there is no X-Cardda-Event-Id, no X-Cardda-Delivery-Id, no custom User-Agent. Pre-existing samples that mentioned those headers were aspirational.
Signature verification (live behavior)
To prove the payload came from Cardda and was not tampered with:
- Read the raw request body (do not parse and re-serialize — order and whitespace matter).
- Read
X-Cardda-TimestampandX-Cardda-Signature. - Compute
expected = HMAC-SHA256(secret, "<timestamp>.<raw_body>"). - Compare with
X-Cardda-Signatureusing a constant-time comparison. - Reject deliveries where
now - timestamp > 5 minutes(replay protection).
// Node.js (Express) — verifies a PLH SMS verification webhook today
import crypto from "node:crypto";
import express from "express";
const app = express();
app.post(
"/webhooks/cardda",
// capture raw body for signature check
express.raw({ type: "application/json" }),
async (req, res) => {
const signature = req.header("X-Cardda-Signature") ?? "";
const timestamp = req.header("X-Cardda-Timestamp") ?? "";
const ageSeconds = Math.abs(Date.now() / 1000 - Number(timestamp));
if (ageSeconds > 300) return res.status(400).end("stale");
const expected = crypto
.createHmac("sha256", process.env.CARDDA_WEBHOOK_SECRET)
.update(`${timestamp}.${req.body.toString("utf8")}`)
.digest("hex");
let ok = false;
try {
const sigBuf = Buffer.from(signature, "hex");
const expBuf = Buffer.from(expected, "hex");
ok = sigBuf.length === expBuf.length && crypto.timingSafeEqual(sigBuf, expBuf);
} catch { ok = false; }
if (!ok) return res.status(401).end("bad signature");
let payload;
try {
payload = JSON.parse(req.body.toString("utf8"));
} catch {
return res.status(400).end("invalid json");
}
// Enqueue for background processing and reply immediately — see "Reply quickly" below.
// Mirrors the Rails example, which uses HandleVerificationCodeJob.perform_later.
try {
await jobQueue.add("handle-verification-code", payload);
} catch (err) {
// Failing to enqueue is a server-side problem; let Cardda retry.
console.error("failed to enqueue verification code webhook", err);
return res.status(500).end("enqueue failed");
}
res.status(200).end("ok");
}
);# Rails — verifies a PLH SMS verification webhook today
class Webhooks::CarddaController < ApplicationController
skip_before_action :verify_authenticity_token
def receive
raw = request.raw_post
signature = request.headers["X-Cardda-Signature"].to_s
timestamp = request.headers["X-Cardda-Timestamp"].to_s
return head :bad_request if (Time.now.to_i - timestamp.to_i).abs > 300
expected = OpenSSL::HMAC.hexdigest("SHA256", ENV.fetch("CARDDA_WEBHOOK_SECRET"), "#{timestamp}.#{raw}")
# ActiveSupport::SecurityUtils.secure_compare raises ArgumentError on length mismatch
# (e.g. missing/malformed X-Cardda-Signature). Treat that as unauthorized, not a 500.
return head :unauthorized if signature.bytesize != expected.bytesize
return head :unauthorized unless ActiveSupport::SecurityUtils.secure_compare(signature, expected)
payload = JSON.parse(raw)
HandleVerificationCodeJob.perform_later(payload)
head :ok
rescue JSON::ParserError
head :bad_request
end
endRetry policy (live behavior)
If your endpoint returns a non-2xx or times out, Cardda makes up to 3 attempts (initial + retries after ~3 s and ~18 s), always with the same payload id. After that, the code is not redelivered.
We are working on a richer retry contract (with the explicit schedule documented in the Roadmap section below and a delivery log you can browse) as part of the general-purpose webhook system. Until then, treat redelivery as best-effort and instrument your endpoint to alert on inbound rate drops.
Idempotency (caveat)
Because no X-Cardda-Event-Id is sent today, you cannot use a Cardda-provided header for deduplication on this webhook. The payload itself includes a stable id field (the SMS's UUID — see the reference page) — use that as your dedup key:
async function handleVerificationCode(event) {
// event.id is the SMS's stable UUID — see plh-verification-codes
const inserted = await db.processedSms.insertIfAbsent({ id: event.id });
if (!inserted) return; // already processed
await businessLogic(event);
}When the general-purpose webhook system ships, X-Cardda-Event-Id (a UUID per parent event) will be the canonical dedup key for all Cardda webhooks.
Reply quickly
Your endpoint should return 2xx within ~10 seconds. If processing is heavy, enqueue a background job and return 200 immediately.
Local development
Use a tunnel (e.g. ngrok, Cloudflare Tunnel) to expose your local server, then ask your Cardda account team to point your webhook at the public URL.
Roadmap: general-purpose webhooks
The following contract is not live yet. We list it here so integrators can build clients with the future in mind; do not validate against these headers today.
| Header (planned) | Purpose |
|---|---|
X-Cardda-Event-Id | UUID — unique per parent event. Stable across retries; use for idempotency. |
X-Cardda-Delivery-Id | UUID — unique per HTTP delivery (changes on retry). |
User-Agent: Cardda-Webhooks/1.0 | Identifies the Cardda webhook sender. |
| Retry attempt (planned) | Delay before next try |
|---|---|
| 1 (immediate) | 30 s |
| 2 | 2 min |
| 3 | 10 min |
| 4 | 1 h |
| 5 | 6 h |
| 6 | 24 h |
After 6 failed attempts the event will be dropped from the live queue and surfaced in the dashboard's delivery log for manual replay.
Related
- Verification Codes via Webhook — the only webhook live today, with a complete Flask receiver and exact payload shape.
- Errors — the full client-facing error catalog.
Updated 17 days ago
