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"
}
}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))
);
});
}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.