Skip to content

Webhooks

Xaalis POSTs an event to your webhook_url whenever a payment or payout reaches a final state. This is how you learn that money arrived — not the customer’s redirect.

Set the URL with PATCH /v1/account {"webhook_url":"https://…"} (must be https://), or ask an operator.

type When
payment.succeeded provider confirmed; net has been added to available
payment.failed declined or cancelled by the customer
payment.expired nobody paid within 30 minutes
payout.succeeded the money reached the recipient’s wallet
payout.failed the provider refused; amount and fee are back in available
payment.refunded a refund reached the customer
invoice.created · invoice.paid · invoice.overdue subscription invoices — invoice.created carries the link to send
subscription.past_due · subscription.canceled · subscription.completed subscription state changes; ship instalment plans on completed
{
"id": "whd_…",
"type": "payment.succeeded",
"livemode": true,
"created_at": "2026-09-24T10:42:00.000Z",
"data": { "object": { "id": "pay_…", "object": "payment", "amount": 15000, "fee": 225, "net": 14775, "status": "succeeded", "…": "…" } }
}

data.object is the same object GET /v1/payments/{id} or GET /v1/payouts/{id} returns.

Every request carries:

Xaalis-Signature: t=1758793320,v1=5f2b…
Xaalis-Event-Id: whd_…

v1 is HMAC-SHA256(webhook_secret, "<t>.<raw body>") in hex. Your webhook_secret is given once when the account is created. To verify:

  1. Take the raw request bytes — not a re-serialized JSON object (key order and spacing change the signature).
  2. Reject if |now − t| > 300 seconds (stops replays).
  3. Compute the HMAC and compare with a constant-time function against every v1 in the header.
import { constructEvent } from "@xaalis/node";
// express: app.post("/xaalis", express.raw({ type: "application/json" }), handler)
const event = constructEvent(req.body, req.header("xaalis-signature"), process.env.XAALIS_WEBHOOK_SECRET!);

Without the SDK (Node):

import { createHmac, timingSafeEqual } from "node:crypto";
function verify(raw: string, header: string, secret: string): boolean {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=", 2)));
const t = Number(parts.t);
if (!Number.isInteger(t) || Math.abs(Date.now() / 1000 - t) > 300) return false;
const expected = Buffer.from(createHmac("sha256", secret).update(`${t}.${raw}`).digest("hex"));
const got = Buffer.from(parts.v1 ?? "");
return got.length === expected.length && timingSafeEqual(got, expected);
}

Answer 2xx within 10 seconds, then process. Anything else — a timeout, 3xx (redirects are not followed), 4xx, 5xx — counts as a failure and is retried.

  • Up to 12 attempts, exponential backoff starting at 10 s (10 s, 20 s, 40 s … about 5.7 hours in total). After that the delivery is marked failed.
  • Delivery is at least once: the same event can arrive twice. Deduplicate on id (also in Xaalis-Event-Id), e.g. with a unique column.
  • Events can arrive out of order. If order matters, re-read the object with GET /v1/payments/{id}.
  • Check livemode — test and live events go to the same URL.

GET /v1/webhook-deliveries?limit=20 lists recent deliveries with status (pending, succeeded, failed), attempts, last_status_code and last_error, to debug your endpoint.