KashuDocs

Webhooks

Kashu tells your server when something changes: a payment succeeds, a refund settles, a customer is updated. You add an endpoint, Kashu POSTs each event to it as JSON, and you check the signature before you act on it.

Add an endpoint

curl https://api.kashupay.com/v1/webhook_endpoints \
  -H "Authorization: Bearer kashu_sk_test_..." -H "Content-Type: application/json" \
  -d '{"url":"https://shop.example.com/kashu/webhooks","enabled_events":["payment.succeeded","refund.succeeded"]}'

The response holds secret, which starts kashu_whsec_. It appears in that response only, and again only when you roll it with POST /v1/webhook_endpoints/{id}/roll, which replaces it at once. Store it where your server reads its other secrets.

enabled_events takes event types, or "*" for every type. An endpoint takes events only from the mode of the key that made it: a test key's endpoint gets test events.

The URL must be https, on port 443, with a public host name or address. Kashu refuses localhost, private and reserved addresses, a user name or password in the URL, and a fragment. Each time Kashu sends an event it looks the host name up again; if any address it finds is private or reserved, nothing is sent and that attempt counts as failed.

What Kashu sends

{
  "id": "ev_...",
  "object": "event",
  "type": "payment.succeeded",
  "api_version": "2026-11-06",
  "created": 1762473600,
  "livemode": false,
  "data": { "object_version": 3, "object": { "id": "py_...", "object": "payment", "status": "succeeded", "...": "..." } }
}

data.object is the object as it is when Kashu sends the event, not as it was when the event happened. data.object_version is that object's version. Events can arrive out of order and more than once, so keep the highest object_version you have seen for each object and ignore anything older.

Answer with any 2xx status within 10 seconds. Kashu does not follow redirects. Anything else is a failed attempt, and Kashu tries again after 1 minute, 10 minutes, 1 hour, 6 hours, 12 hours, 24 hours and 24 hours, then stops. When every delivery to an endpoint has failed for 3 days, Kashu disables it: its status becomes disabled with disabled_reason: "failing". Set disabled to false to turn it back on, and send a missed event again with POST /v1/events/{id}/redeliver.

GET /v1/events lists events newest first, and GET /v1/events/{id} reads one, each with its object as it is now.

Verify the signature

Every delivery carries a Kashu-Signature header: t=<unix seconds>,v1=<hex>. v1 is HMAC-SHA256, keyed with your endpoint's secret, over the timestamp, a period and the raw request body, byte for byte. Verify against the raw body before any JSON parser touches it, and refuse a timestamp more than a few minutes from your clock, so a recorded delivery cannot be replayed later.

This function needs nothing beyond Node's own crypto module. It returns the parsed event, or throws:

const crypto = require("node:crypto");

function verifyKashuSignature(rawBody, header, secret, toleranceSeconds = 300) {
  if (typeof header !== "string" || header.length === 0) throw new Error("no Kashu-Signature header");
  let timestamp = null;
  const signatures = [];
  for (const part of header.split(",")) {
    const at = part.indexOf("=");
    const key = part.slice(0, at).trim();
    const value = part.slice(at + 1).trim();
    if (key === "t" && /^\d+$/.test(value)) timestamp = Number(value);
    if (key === "v1" && /^[0-9a-f]{64}$/.test(value)) signatures.push(Buffer.from(value, "hex"));
  }
  if (timestamp === null || signatures.length === 0) throw new Error("malformed Kashu-Signature header");
  const body = Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(rawBody, "utf8");
  const expected = crypto.createHmac("sha256", secret).update(`${timestamp}.`).update(body).digest();
  if (!signatures.some((s) => crypto.timingSafeEqual(s, expected))) throw new Error("Kashu-Signature does not match");
  if (Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) throw new Error("Kashu-Signature is too old or too new");
  return JSON.parse(body.toString("utf8"));
}

With Express, read the body raw on the webhook route only:

app.post("/kashu/webhooks", express.raw({ type: "application/json" }), (req, res) => {
  let event;
  try {
    event = verifyKashuSignature(req.body, req.get("Kashu-Signature"), process.env.KASHU_WEBHOOK_SECRET);
  } catch {
    return res.sendStatus(400);
  }
  if (event.type === "payment.succeeded") fulfil(event.data.object);
  res.sendStatus(200);
});

Event types

Type When
customer.created, customer.updated A customer is made or changed.
payment.created A payment is made.
payment.requires_action, payment.requires_capture, payment.succeeded, payment.failed, payment.canceled A payment's status moves.
payment.updated A payment's amounts change without its status moving, as when part of it is refunded.
refund.created, refund.succeeded, refund.failed A refund is made, then settles.
subscription.created A subscription is made. Its first charge, and each renewal, is a payment that names it, with its own payment.* events.
subscription.updated A subscription changes: it becomes active, its billing dates move, a cancel is scheduled or undone, or it could not be made (status: failed).
subscription.canceled A subscription ends. Nothing more is billed.
dispute.created, dispute.updated A buyer disputes a payment, then the dispute moves. A dispute never changes the payment's amounts; the payment shows disputed: true.
checkout.session.completed A checkout session's payment succeeds, or is authorized when the session captures manually. A session completes once.
checkout.session.expired A checkout session stops taking payment: you expired it, or its expires_at passed with no payment in flight.