Wallet
Webhooks

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

EventWhen
wallet.topup.completedTop-up landed in the balance
wallet.topup.failedTop-up failed
wallet.topup.refund_pendingTop-up refund started
wallet.topup.refundedTop-up refund completed
wallet.topup.reversedA completed top-up was reversed

Transfers

EventWhen
wallet.transfer.completedTransfer completed
wallet.transfer.failedTransfer failed, reservation released
wallet.transfer.reversedA completed transfer was reversed

Withdrawals

EventWhen
wallet.withdrawal.processingInstruction in flight on the bank side
wallet.withdrawal.completedWithdrawal completed
wallet.withdrawal.failedWithdrawal failed
wallet.withdrawal.awaiting_reconOutcome uncertain, sent to reconciliation
wallet.withdrawal.reversedA completed withdrawal was reversed

Payments and refunds

EventWhen
wallet.payment.completedWallet payment completed
wallet.payment.failedPayment failed
wallet.payment.reversedA completed payment was reversed
wallet.refund.creditedA refund was credited to the wallet
wallet.refund.reversedA refund was reversed

Loyalty

EventWhen
wallet.loyalty.earnedPoints earned
wallet.loyalty.redeemedPoints redeemed
wallet.loyalty.expiredA point lot expired
wallet.loyalty.tier_changedThe user's tier changed
wallet.badge.grantedA badge was granted
wallet.badge.reward_creditedA badge reward was credited
wallet.campaign.paused_insufficient_fundsCampaign budget exhausted, paused

Invoices and bill pay

EventWhen
wallet.invoice.issued · paid · cancelledInvoice lifecycle
wallet.billpay.dispatchedInstruction sent to the biller
wallet.billpay.confirmedThe biller confirmed
wallet.billpay.failedThe biller rejected
wallet.billpay.awaiting_reconOutcome uncertain
wallet.billpay.reversedReversed

Operations

EventWhen
wallet.suspense.createdUnmatched 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"
  }
}
FieldMeaning
idEvent id — the same value as the x-lunixi-event-id header
typeEvent name (catalogue above)
organizationIdYour organisation
environmentTEST or LIVE
createdAtWhen the event was created
dataThe wallet event's fields

Inside data:

FieldMeaning
schemaVersionWallet payload version — increments on breaking changes
domainAlways WALLET
eventNameEvent 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:

HeaderContent
x-lunixi-event-idUnique event id
x-lunixi-event-typeEvent name
x-lunixi-signatureComma-separated signature list: one or two v2=<hex> items (the second one only during the 24-hour secret-rotation grace window)
x-lunixi-signature-timestampSignature 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.

BehaviourValue
Response timeout~10 seconds
BackoffFixed schedule: 10s, 30s, 2m, 5m, 15m, 30m, 1h, 2h, 4h, 8h, 12h, 24h (±20% jitter)
Attempts12 (a ~52-hour horizon; then DEAD_LETTER) — a replay resets the budget
RedirectsNot 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

  1. Return 2xx fast. Queue the work; do not do long processing inside the webhook request — a timeout causes retries.
  2. Be idempotent. Delivery is at least once; the same event can arrive more than once. Store x-lunixi-event-id and ignore repeats carrying the same id.
  3. There is no ordering guarantee. completed can arrive before processing. Write state from the event's content, not its arrival order; confirm with GET /wallet/operations/{id} when in doubt.
  4. Ignore unknown events. New event types can be added; return 2xx for a type you do not recognize.
  5. 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

SymptomCauseFix
Signature never matchesThe body was parsed and re-serializedVerify the v2 signature over the raw body bytes — do not parse
The same operation recorded twiceNo idempotencyDeduplicate on x-lunixi-event-id
No events arrivingThe endpoint was disabled after repeated failuresCheck its status in the panel
Some events never arriveThat capability's integration is not connectedRoadmap