Beta — Smart contract audit in progress. We recommend keeping wallet balances under $100 USDC.
CardZero

title: Verify a webhook signature description: "HMAC-SHA256 in 5 languages. Don't trust the source IP — verify."

CardZero signs every webhook with HMAC-SHA256 over the raw request body, using your wallet's per-wallet webhook_secret. Verifying is a few lines of code.

Get your secret

curl https://api.cardzero.ai/v1/wallets/wallet_…/webhook-secret \
  -H "Authorization: Bearer <jwt>"

Save the webhookSecret value (starts with whsec_) in your server's secrets.

If you ever leak it, rotate:

curl -X POST https://api.cardzero.ai/v1/wallets/wallet_…/webhook-secret/rotate \
  -H "Authorization: Bearer <jwt>"

Headers we send

X-CardZero-Event:      job_completed
X-CardZero-Signature:  sha256=a1b2c3d4...
User-Agent:            CardZero-Webhook/1.0
Content-Type:          application/json

Critical: use the raw body

HMAC is over bytes, not parsed JSON. If your framework auto-parses, you must access the original raw bytes for verification. Common pitfalls:

  • JSON.stringify(req.body) — doesn't match the original bytes (key order, whitespace).
  • ✅ Use express.raw() middleware OR read req.text() in Next.js.

Examples

import express from "express";
import { createHmac, timingSafeEqual } from "crypto";

const app = express();

// CRITICAL: raw body parser BEFORE any json() middleware
app.use("/webhooks/cardzero", express.raw({ type: "application/json" }));

app.post("/webhooks/cardzero", (req, res) => {
  const rawBody = (req.body as Buffer).toString("utf8");
  const sigHeader = req.headers["x-cardzero-signature"] as string;

  if (!verify(rawBody, sigHeader, process.env.WEBHOOK_SECRET!)) {
    return res.status(401).send("invalid signature");
  }

  const event = JSON.parse(rawBody);
  console.log("Got CardZero event:", event.type, event.jobId);
  // ... process event ...
  res.status(200).send("ok");
});

function verify(body: string, header: string, secret: string): boolean {
  const expected = createHmac("sha256", secret).update(body).digest("hex");
  const received = header.replace(/^sha256=/, "");
  if (expected.length !== received.length) return false;
  return timingSafeEqual(Buffer.from(expected), Buffer.from(received));
}

What to do after verifying

Process the event idempotently. Each event has a stable jobId + type — de-dup using that. Don't process the same (jobId, type) twice.

Example dedup:

const dedupKey = `${event.jobId}-${event.type}`;
const wasProcessed = await redis.set(`processed:${dedupKey}`, "1", "EX", 86400, "NX");
if (!wasProcessed) {
  console.log("Duplicate event, skipping:", dedupKey);
  return res.status(200).send("ok");
}
// ... actually do the work ...

Test before going live

Manually trigger a webhook by causing a state change (create a Job, fund it). Check your endpoint logs to confirm:

  1. Signature verifies ✅
  2. Event JSON parses ✅
  3. Your handler runs ✅
  4. Returns HTTP 2xx ✅

CardZero will retry up to 3 times with backoff. Failed deliveries are logged in your Owner dashboard.

Webhook API reference →