Webhooks πŸͺ

Cardda emits HTTP webhooks to URLs you register, so your integration can react to events without polling.

Currently available

Event familyDescriptionReference
PLH SMS verification codesWhen 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:

HeaderDescription
Content-Type: application/jsonBody is always JSON.
X-Cardda-SignatureHex-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-TimestampUnix timestamp (seconds) when Cardda generated the request.
X-Cardda-Event-IdUUID β€” unique per delivery attempt's parent event. Use it for idempotency.
X-Cardda-Delivery-IdUUID β€” unique per HTTP delivery (changes on retry).
User-AgentCardda-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:

  1. Read the raw request body (do not parse and re-serialize β€” order and whitespace matter).
  2. Read X-Cardda-Timestamp and X-Cardda-Signature.
  3. Compute expected = HMAC-SHA256(secret, "<timestamp>.<raw_body>").
  4. Compare with X-Cardda-Signature using a constant-time comparison.
  5. 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
end

Retry policy

Cardda treats any non-2xx response (or no response within 10 seconds) as a failure and retries with exponential backoff:

AttemptDelay before next try
1 (immediate)30 s
22 min
310 min
41 h
56 h
624 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.