Events

Webhook

Get a signed payment.paid the moment a payment is confirmed.

Add webhook endpoints in the dashboard (under Developer) — up to five per mode, each with its own URL, name and signing secret, so a store and an ERP can each receive their own copy. When a payment is confirmed, we send a POST to every endpoint that is switched on, with this payload:

{
  "id": "evt_...",
  "type": "payment.paid",
  "livemode": false,
  "created_at": "2026-08-11T12:05:00+07:00",
  "data": {
    "payment_request_id": "payreq_9b2f...",
    "external_id": "ORD-1234",
    "merchant_ref": "INV-2026-001",
    "amount": 150000,
    "currency": "IDR",
    "customer": { "name": "Budi", "email": "budi@toko.dev" },
    "paid_at": "2026-08-11T12:04:58+07:00",
    "status": "succeeded"
  }
}

Which events you receive

Three payment events exist. Each endpoint chooses which of them it wants, in the dashboard; an endpoint created before this choice existed receives payment.paid only, and keeps doing so until you say otherwise.

  • payment.paid — the buyer paid. This is the one that means money.
  • payment.expired — the request passed its deadline unpaid.
  • payment.failed — the rail refused the request outright. This is not a failed attempt: a buyer whose first scan bounces can scan again, and that leaves the request open. Only a dead request sends this.

The two terminal events carry the same shape as a paid one, with paid_at: null:

{
  "id": "evt_...",
  "type": "payment.expired",
  "livemode": true,
  "created_at": "2026-08-11T13:00:00+07:00",
  "data": {
    "payment_request_id": "payreq_9b2f...",
    "external_id": "ORD-1234",
    "amount": 150000,
    "currency": "IDR",
    "paid_at": null,
    "status": "expired"
  }
}

An expiry can be followed by a payment. If the buyer's money was accepted before our expiry timer fired, the money wins: the request becomes succeeded and a payment.paid follows the payment.expired you already received. Read data.status — it is the payment's status when the event was built, not when it was queued — or fetch the request to be certain. If you released stock on the expiry, the paid event is the signal to claim it back or refund.

Delivery is at-least-once: the same event may arrive more than once and always carries the same id — dedupe by id. With several endpoints, each receives the event once and retries on its own: one endpoint rejecting it never delays another. Your endpoint must answer 2xx; otherwise we retry with exponential backoff for up to 7 attempts over ±33 hours. The webhook URL must be https and point to a public address. Live and test are separate endpoints with separate signing secrets: configure each in its own dashboard mode. A test-mode event only ever reaches the test endpoint and carries livemode: false; a live one only reaches the live endpoint. A test secret never verifies a live payload.

Verify the signature

Every delivery carries a timestamped signature in the Kasera-Signature-V1 header: t=<unix>,v1=<hex>, where v1 is HMAC-SHA256 over t + "." + rawBody. Verify against t and reject deliveries whose timestamp is more than 5 minutes off your clock — that is what stops a captured delivery from being replayed later. After a secret rotation the header carries two v1 entries for 24 hours — one per secret — so accept the delivery if any entry matches. The event id is also in Kasera-Event-Id.

// Node.js
const crypto = require("crypto");

// Kasera-Signature-V1: t=1723350300,v1=5f4d...[,v1=9a1b...]
function verify(rawBody, v1Header, secret, toleranceSeconds = 300) {
  const parts = v1Header.split(",");
  const t = Number(parts[0].slice(2)); // "t=<unix>"
  if (!Number.isFinite(t)) return false;
  if (Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
  const expected = crypto
    .createHmac("sha256", secret)
    .update(t + "." + rawBody)
    .digest("hex");
  return parts
    .slice(1)
    .filter((p) => p.startsWith("v1="))
    .some((p) => {
      const sig = Buffer.from(p.slice(3));
      return (
        sig.length === expected.length &&
        crypto.timingSafeEqual(sig, Buffer.from(expected))
      );
    });
}

On PHP or JavaScript/TypeScript you do not need this code: the official SDKs ship it as Webhook::constructEvent and constructWebhookEvent, with the timestamp tolerance and the rotation grace built in.

Legacy header (deprecated)

Deliveries still carry the original Kasera-Signature header — bare hex HMAC-SHA256 over the raw body, no timestamp — so existing verifiers keep working unchanged. It is deprecated: it cannot protect against replay, and it is signed with the current secret only (no rotation grace). Migrate to Kasera-Signature-V1; the legacy header will be removed after a deprecation period announced in advance.

// Node.js — legacy Kasera-Signature (deprecated)
const crypto = require("crypto");

function verifyLegacy(rawBody, signatureHeader, secret) {
  const expected = crypto
    .createHmac("sha256", secret)
    .update(rawBody)
    .digest("hex");
  return crypto.timingSafeEqual(
    Buffer.from(signatureHeader),
    Buffer.from(expected)
  );
}

The secret is visible in the dashboard whenever you need it, and it changes only when you rotate it — moving the webhook URL to a new domain keeps the same secret, so a migration needs no redeploy of your verification code. Rotation has a 24-hour grace window: the old secret keeps signing a second v1 entry alongside the new one, so deploy the new secret at your own pace — in-flight retries keep verifying throughout. You can also add an endpoint without a URL to get its secret first: build and deploy the handler, point the domain at us afterwards. Switching an endpoint off parks its events without burning retries until you switch it back on.

Verify the signature before processing the payload. Use the raw body, not a re-parsed JSON.