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, 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-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