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

HeaderDescription
Content-Type: application/jsonBody is always JSON.
X-Cardda-SignatureHex-encoded HMAC-SHA256 of <timestamp>.<raw_body> using the shared secret you set when registering the webhook.
X-Cardda-TimestampUnix 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:

  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) — 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
end

Retry 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-IdUUID — unique per parent event. Stable across retries; use for idempotency.
X-Cardda-Delivery-IdUUID — unique per HTTP delivery (changes on retry).
User-Agent: Cardda-Webhooks/1.0Identifies the Cardda webhook sender.
Retry attempt (planned)Delay before next try
1 (immediate)30 s
22 min
310 min
41 h
56 h
624 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


Did this page help you?