Webhooks πͺ
Cardda emits HTTP webhooks to URLs you register, so your integration can react to events without polling.
Currently available
| Event family | Description | Reference |
|---|---|---|
| PLH SMS verification codes | When a card transaction triggers an SMS verification code at PLH (our Chilean issuer), Cardda forwards the code to your endpoint within seconds. | Verification Codes via Webhook |
The general-purpose webhook system for resource events (
bank_transaction.created,card.issued,fiscal_invoice.processed, etc.) is on our roadmap. The mechanics described below β signature, retry, idempotency β already apply to the PLH webhook today and will continue to apply to future events.
Mechanics
All Cardda webhooks share the same envelope and verification scheme.
Request shape
Every webhook is delivered as POST <your-endpoint> with:
| Header | Description |
|---|---|
Content-Type: application/json | Body is always JSON. |
X-Cardda-Signature | Hex-encoded HMAC-SHA256 of <timestamp>.<raw_body> (literal Unix timestamp, dot, then the raw request body) using the shared secret you set when registering the webhook. |
X-Cardda-Timestamp | Unix timestamp (seconds) when Cardda generated the request. |
X-Cardda-Event-Id | UUID β unique per delivery attempt's parent event. Use it for idempotency. |
X-Cardda-Delivery-Id | UUID β unique per HTTP delivery (changes on retry). |
User-Agent | Cardda-Webhooks/1.0 |
The body shape depends on the event; see the relevant reference page (e.g. PLH SMS).
Signature verification
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) β minimal example
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" }),
(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");
// Decode first and validate buffer lengths before timingSafeEqual,
// which throws on length mismatch (which can happen with malformed
// / non-hex signatures even when the string lengths match).
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");
const event = JSON.parse(req.body.toString("utf8"));
handleEvent(event); // your business logic β see Idempotency below
res.status(200).end("ok");
}
);# Rails β minimal example
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}")
return head :unauthorized unless ActiveSupport::SecurityUtils.secure_compare(signature, expected)
event = JSON.parse(raw)
HandleCarddaEventJob.perform_later(event)
head :ok
end
endRetry policy
Cardda treats any non-2xx response (or no response within 10 seconds) as a failure and retries with exponential backoff:
| Attempt | 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 is dropped from the live queue and surfaced in the dashboard (delivery log) for manual inspection / replay.
Idempotency
Always treat your webhook handler as idempotent: the same event may arrive more than once (network retries, Cardda re-deliveries after deploys). Use X-Cardda-Event-Id as your dedup key:
async function handleEvent(req, event) {
const eventId = req.header("X-Cardda-Event-Id");
const inserted = await db.processedEvents.insertIfAbsent({
id: eventId,
receivedAt: new Date(),
});
if (!inserted) return; // already processed
await businessLogic(event);
}Reply quickly
Your endpoint should return 200 within seconds. If processing is heavy, enqueue a background job and return 200 immediately. A slow handler will block the queue and trigger our retry timer unnecessarily.
Local development
Use a tunnel (e.g. ngrok, Cloudflare Tunnel) to expose your local server, then register the public URL in the Cardda dashboard. We support testing webhooks via the dashboard's "Send test event" button (uses a separate timestamp + signature so you can validate your verification code path).
Related
- Verification Codes via Webhook β the only webhook live today, with a complete Flask receiver.
- Errors β when your webhook returns a non-2xx, this is the schema we record in the delivery log.
Updated 1 day ago
