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, Cards::Plh::VerificationSms#send_to_webhook raises WebhookDeliveryError. The retry behavior is whatever the underlying ActiveJob backend (SQS / Shoryuken) is configured to do — currently a small number of retries with backoff inherited from the job's retry_on. There is no fixed 30 s / 2 m / 10 m / 1 h / 6 h / 24 h schedule today. If your endpoint is flaky, expect a handful of redeliveries within the first few minutes, then the event is shipped to the dead-letter queue and surfaced in operations dashboards — not in your client-facing dashboard.
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 register the public URL in the Cardda dashboard. The dashboard's "Send test event" button signs with your registered secret and posts a sample payload to validate your verifier.
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 7 days ago
