Webhooks
Asynchronous wallet outcomes are delivered by webhook. Learn the result of money movements from webhooks, not polling; the GET endpoints are for confirmation.
The webhook record is written in the same database transaction as the money movement. "Money completed but the event was lost" is therefore structurally impossible — delivery can be delayed, but the event cannot fail to exist.
Event catalogue
Top-up
| Event | When |
|---|---|
wallet.topup.completed | Top-up landed in the balance |
wallet.topup.failed | Top-up failed |
wallet.topup.refund_pending | Top-up refund started |
wallet.topup.refunded | Top-up refund completed |
wallet.topup.reversed | A completed top-up was reversed |
Transfers
| Event | When |
|---|---|
wallet.transfer.completed | Transfer completed |
wallet.transfer.failed | Transfer failed, reservation released |
wallet.transfer.reversed | A completed transfer was reversed |
Withdrawals
| Event | When |
|---|---|
wallet.withdrawal.processing | Instruction in flight on the bank side |
wallet.withdrawal.completed | Withdrawal completed |
wallet.withdrawal.failed | Withdrawal failed |
wallet.withdrawal.awaiting_recon | Outcome uncertain, sent to reconciliation |
wallet.withdrawal.reversed | A completed withdrawal was reversed |
Payments and refunds
| Event | When |
|---|---|
wallet.payment.completed | Wallet payment completed |
wallet.payment.failed | Payment failed |
wallet.payment.reversed | A completed payment was reversed |
wallet.refund.credited | A refund was credited to the wallet |
wallet.refund.reversed | A refund was reversed |
Loyalty
| Event | When |
|---|---|
wallet.loyalty.earned | Points earned |
wallet.loyalty.redeemed | Points redeemed |
wallet.loyalty.expired | A point lot expired |
wallet.loyalty.tier_changed | The user's tier changed |
wallet.badge.granted | A badge was granted |
wallet.badge.reward_credited | A badge reward was credited |
wallet.campaign.paused_insufficient_funds | Campaign budget exhausted, paused |
Invoices and bill pay
| Event | When |
|---|---|
wallet.invoice.issued · paid · cancelled | Invoice lifecycle |
wallet.billpay.dispatched | Instruction sent to the biller |
wallet.billpay.confirmed | The biller confirmed |
wallet.billpay.failed | The biller rejected |
wallet.billpay.awaiting_recon | Outcome uncertain |
wallet.billpay.reversed | Reversed |
Operations
| Event | When |
|---|---|
wallet.suspense.created | Unmatched funds moved to suspense |
Events for capabilities whose provider is not yet connected (withdrawals, bill pay) are listed here but do not flow in production. Prepare your consumer for them now; no code change is needed when an integration connects. See the Roadmap.
Envelope
Every delivery uses the same envelope; the wallet event's own fields are under data:
{
"id": "0b6d3f2e-9a41-4c7e-8d25-6f1a3b9c4e07",
"type": "wallet.transfer.completed",
"sourceService": "wallet-service",
"organizationId": "3c9a1e5b-7d2f-4b8e-a6c4-1f0e9d2b7a35",
"productCode": "WALLET_SERVICE",
"environment": "LIVE",
"eventVersion": 1,
"schemaVersion": "v1",
"createdAt": "2026-08-07T12:00:04.512Z",
"data": {
"schemaVersion": "wallet.webhook.v1",
"domain": "WALLET",
"eventName": "wallet.transfer.completed",
"operationId": "7a3e9c1d-2b4f-4e8a-9d6c-0f5b8e2a1c93",
"sourceWalletAccountId": "c2a5e8f1-9d3b-4c7a-8e6f-5b1d2a9c0e47",
"destinationWalletAccountId": "a4d7b0e3-5f8c-4a1d-9b2e-6c3f8a1d0e59",
"amount": "10050",
"currency": "TRY",
"feeTotal": "250",
"taxTotal": "125"
}
}| Field | Meaning |
|---|---|
id | Event id — the same value as the x-lunixi-event-id header |
type | Event name (catalogue above) |
organizationId | Your organisation |
environment | TEST or LIVE |
createdAt | When the event was created |
data | The wallet event's fields |
Inside data:
| Field | Meaning |
|---|---|
schemaVersion | Wallet payload version — increments on breaking changes |
domain | Always WALLET |
eventName | Event name (catalogue above) |
Event-specific fields (operation id, amounts, status) accompany them inside data.
Amounts travel in minor units and PII is masked. Raw phone numbers, IBANs and names are never sent in the webhook body — read them from the relevant GET endpoint if you need them.
Signature verification
Every delivery carries these headers:
| Header | Content |
|---|---|
x-lunixi-event-id | Unique event id |
x-lunixi-event-type | Event name |
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 | Signature timestamp (ISO 8601) |
The v2 signature is an HMAC of the raw body — you verify it without parsing the body:
signature = HMAC-SHA256( secret, "{eventId}.{timestamp}.{rawBody}" )Compare every v2= item in the header against this value in constant time; the signature is valid if any matches (during the 24-hour grace window of a secret rotation there are two v2= items).
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verifyWalletWebhook(rawBody, headers, secret, toleranceSeconds = 300) {
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 = createHmac('sha256', secret)
.update(`${eventId}.${timestamp}.${rawBody}`)
.digest('hex');
return String(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 && timingSafeEqual(received, wanted);
});
}Never process a webhook before verifying its signature. Your endpoint is on the public internet; an unsigned body may have been produced by an attacker.
Delivery guarantee
Wallet webhooks are delivered at least once:
- When an operation changes state (e.g.
completed,failed,reversed), the corresponding event is recorded in the same transaction as the state change. If an operation reached a state, the event for that state exists; an infrastructure outage does not lose it — it is delivered late once the outage is over. - The same event can be delivered more than once. Every repeat carries the same
x-lunixi-event-id; the value is fixed per event and should be used as your deduplication key. - Events can arrive late. You can always confirm the current state of an operation with
GET /wallet/operations/{id}.
Retries
If your endpoint does not return 2xx, delivery is retried. Waits follow a fixed schedule with ±20% jitter applied to every value; once attempts are exhausted the delivery is marked dead letter.
| Behaviour | Value |
|---|---|
| Response timeout | ~10 seconds |
| Backoff | Fixed schedule: 10s, 30s, 2m, 5m, 15m, 30m, 1h, 2h, 4h, 8h, 12h, 24h (±20% jitter) |
| Attempts | 12 (a ~52-hour horizon; then DEAD_LETTER) — a replay resets the budget |
| Redirects | Not followed — 3xx counts as failure |
An endpoint that fails persistently can be disabled automatically. After fixing your endpoint, check its status in the panel.
Consuming correctly
- Return
2xxfast. Queue the work; do not do long processing inside the webhook request — a timeout causes retries. - Be idempotent. Delivery is at least once; the same event can arrive more than once. Store
x-lunixi-event-idand ignore repeats carrying the same id. - There is no ordering guarantee.
completedcan arrive beforeprocessing. Write state from the event's content, not its arrival order; confirm withGET /wallet/operations/{id}when in doubt. - Ignore unknown events. New event types can be added; return
2xxfor atypeyou do not recognize. - Do not return
3xx. Redirects are not followed.
Setup
Register your endpoint URL and the events you want in the merchant panel; your signing secret is generated there. The endpoint must be publicly reachable over HTTPS.
Common mistakes
| Symptom | Cause | Fix |
|---|---|---|
| Signature never matches | The body was parsed and re-serialized | Verify the v2 signature over the raw body bytes — do not parse |
| The same operation recorded twice | No idempotency | Deduplicate on x-lunixi-event-id |
| No events arriving | The endpoint was disabled after repeated failures | Check its status in the panel |
| Some events never arrive | That capability's integration is not connected | Roadmap |