Webhooks
A webhook is an HTTP call we make to your server. When a payment settles, a wallet transfer is reversed, or a fraud decision is produced, we send the event to your registered endpoint.
Delivery is run by a dedicated service: every delivery is persisted, retried, logged attempt by attempt, and can be replayed.
What a delivery looks like
POST /your-endpoint HTTP/1.1
content-type: application/json
user-agent: Lunixi-Webhook-Service/1.0
x-lunixi-event-id: evt_01J8XQ...
x-lunixi-event-type: payment.captured
x-lunixi-signature: v2=9f2c1a…
x-lunixi-signature-timestamp: 2026-08-17T09:31:44.118Z| Header | Contents |
|---|---|
x-lunixi-event-id | Unique event id — your idempotency key |
x-lunixi-event-type | Event type (e.g. payment.captured) |
x-lunixi-signature | Comma-separated signature list: one or two v2=<hex> items (the second one only during the 24-hour secret-rotation grace window) |
x-lunixi-signature-timestamp | When the signature was produced, ISO 8601 |
Return 2xx and the delivery counts as successful. Any other status triggers a retry.
Redirects are not followed. The request is sent with maxRedirects: 0; returning 301/302 fails the delivery. Your endpoint must be the final address.
Verifying the signature
The v2 signature is an HMAC of the raw body — you verify it without parsing the body. The signed string is:
{eventId}.{timestamp}.{rawBody}eventId— thex-lunixi-event-idheader (same as bodyid)timestamp— the exact string value ofx-lunixi-signature-timestamp(ISO 8601)rawBody— the exact raw request body bytes (UTF-8) — no parsing, no re-serialisation, no canonicalisation
The x-lunixi-signature header carries one or two comma-separated v2=<hex> items. Verification: split the header on ,, compare every v2= item in constant time against the HMAC you computed over the raw body — the signature is valid if any item matches. (The second v2= item is present only during a secret rotation; see below.)
import crypto from 'crypto';
export function verifyLunixiWebhook(
rawBody: string, // raw body — do NOT parse it
headers: Record<string, string>,
signingSecret: string,
toleranceSeconds = 300,
): boolean {
const eventId = headers['x-lunixi-event-id'];
const timestamp = headers['x-lunixi-signature-timestamp'];
if (Math.abs(Date.now() - Date.parse(timestamp)) > toleranceSeconds * 1000) return false;
const expected = crypto
.createHmac('sha256', signingSecret)
.update(`${eventId}.${timestamp}.${rawBody}`)
.digest('hex');
return (headers['x-lunixi-signature'] ?? '')
.split(',')
.map((item) => item.trim())
.filter((item) => item.startsWith('v2='))
.some((item) => {
const received = Buffer.from(item.slice(3), 'hex');
const wanted = Buffer.from(expected, 'hex');
return received.length === wanted.length && crypto.timingSafeEqual(received, wanted);
});
}timingSafeEqual throws when the buffers differ in length. Compare lengths first, then call it — the example above does.
You must check the freshness of x-lunixi-signature-timestamp yourself (recommended tolerance ≤ 300 seconds), so a captured request cannot be replayed later. The example above does this too.
Secret rotation
After you rotate your secret, deliveries are dual-signed with both the new and the old secret for 24 hours (the "grace window"): the header carries two v2= items — the new secret's first, the old one's second (v2=<hexNew>,v2=<hexOld>). A verifier that accepts the signature when any v2= item matches survives rotation with zero downtime — the example above works exactly this way. After 24 hours the old secret stops being used.
Retry policy
A failed delivery is retried on a fixed schedule, with ±20% jitter applied to every wait value:
| Attempt | Wait | Attempt | Wait |
|---|---|---|---|
| 1 | 10s | 7 | 1h |
| 2 | 30s | 8 | 2h |
| 3 | 2m | 9 | 4h |
| 4 | 5m | 10 | 8h |
| 5 | 15m | 11 | 12h |
| 6 | 30m | 12 | 24h |
- 12 attempts total (a ~52-hour horizon); after that the delivery becomes
DEAD_LETTER. - Replaying a
DEAD_LETTERdelivery resets the attempt budget — it re-enters the queue with a fresh 12 attempts. - Request timeout defaults to 10 seconds (clamped to 1–30s).
- These limits are configurable per installation; in an on-prem deployment, ask your operator.
Be idempotent on x-lunixi-event-id. A retry fires whenever your endpoint did not return 2xx — including when you processed the event but the response was lost in transit. If you see the same eventId twice, do not process it twice.
Delivery statuses
| Status | Meaning |
|---|---|
PENDING | Queued, not yet attempted |
PROCESSING | Currently being sent |
DELIVERED | Your endpoint returned 2xx |
FAILED | Attempt failed, will be retried |
DEAD_LETTER | Attempts exhausted — no further automatic retry |
A DEAD_LETTER delivery is not lost; it can be replayed from the panel or the API. → Webhook Setup
Your endpoint can be disabled automatically
If an endpoint keeps failing consecutively (default threshold 20), its status is set to FAILED and it stops receiving deliveries.
This is not silent, but it does not recover on its own. After fixing your endpoint you must re-enable it from the panel. If you are taking your server down for maintenance, consider returning 2xx quickly and queueing the work on your side rather than serving 5xx for a long stretch.
Security: where we will call
The delivery service re-validates the target at call time, not just at registration time:
- Scheme check —
http/httpsonly - IP-class check after resolution — loopback (
127.0.0.0/8,::1), private ranges (10/8,172.16/12,192.168/16) and link-local addresses are rejected - The socket is pinned to the vetted IP — closing the DNS-rebinding window between registration and connect
So your endpoint has to be reachable from the public internet. For local development, use a tunnel (ngrok or similar).
Which products emit events
The webhook backbone is fed from nine sources: organization · payment · subscription · invoice · usage · kyc · fraud · wallet · sanction.
Subscription is per event type — you can subscribe one endpoint to payment.captured and another to wallet.transfer.reversed. For per-product event lists, see that product's pages.
Bank account (bank_account.*) and bank transfer (bank_transfer.*) events are delivered to endpoints of the BANK_CONNECT product. → Bank Accounts › Webhook Events
Insurance events (insurance.*) are delivered to endpoints of the INSURANCE_SERVICE product. → Insurance › Webhook Events
Next
Creating endpoints, subscribing to events, rotating secrets and replaying deliveries: Webhook Setup