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.
Events
Section titled “Events”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.
Verify the signature
Section titled “Verify the signature”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:
- Take the raw request bytes — not a re-serialized JSON object (key order and spacing change the signature).
- Reject if
|now − t| > 300seconds (stops replays). - Compute the HMAC and compare with a constant-time function against every
v1in 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);}Respond fast, then work
Section titled “Respond fast, then work”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.
Retries and duplicates
Section titled “Retries and duplicates”- 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 inXaalis-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.