Wallet
Transfers

Transfers

Who calls these endpoints?External API (s2s)
Base path
/api/v1/wallet
Identity
Ed25519 request signature — X-Key-Id, X-Date, X-Nonce, Digest (when there is a body), X-Signature. ±5 min clock window, single-use nonce, optional per-key IP allow-list.
Authorization
The key must carry the wallet product and the wallet permission the call requires; a call without it gets 403 WALLET_INSUFFICIENT_SCOPE. Permissions per endpoint: Key permissions.
Cross-surface
These endpoints are on the s2s surface. The end user's own transfers live under /api/portal/v1/wallet/me/transfers/* and require step-up. The panel has no transfer-initiation endpoint; it only observes and intervenes.

There are two transfer types, both following the quote → execute → complete pattern:

TypeDestinationOperation typeStatus
Wallet to wallet (W2W)Another wallet in the same programW2W_TRANSFER✅ Live
To an IBAN (W2IBAN)A third party's bank accountW2IBAN_WITHDRAW🚧 Requires a bank integration

Withdrawing to the user's own IBAN is a different operation (WITHDRAW_BANK) — see Withdrawals. The distinction is not cosmetic: limit buckets, fee schedules and reporting count them separately.

Wallet-to-wallet transfer

1. Quote

POST /api/v1/wallet/transfers/w2w/quote
FieldTypeRequiredDescription
endUserIdstring✅The end user you act for — the sender
sourceWalletAccountIdstring✅Sender's account; must belong to endUserId
destinationWalletNostring⚠️Recipient's wallet number
destinationPhonestring⚠️Recipient's phone
amountstring✅Minor-unit
currencystring✅
paymentPurposestring—Purpose code (reporting)

⚠️ Exactly one of destinationWalletNo or destinationPhone is required.

endUserId states on whose behalf you are acting. The wallet verifies that sourceWalletAccountId belongs to this end user; otherwise the request is rejected with 404. This way a bug on your side cannot spend one user's money on behalf of another. The same field is required on the initialize and complete steps.

{
  "status": "success",
  "code": "SUCCESS",
  "data": {
    "feeTotal": "250",
    "taxTotal": "125",
    "totalCharge": "10425",
    "requiredAmount": "10425",
    "maskedDestinationName": "Meh*** O***",
    "sourceBalance": "25000",
    "sufficientBalance": true,
    "limitBlocked": false,
    "scheduleVersionId": "2d8b5e1c-7f4a-4c9e-a3b6-8e1f5c2d9a74"
  }
}
⚠️

Always show maskedDestinationName to the sender. With phone-based sends, "it went to the wrong person" is the most common complaint; the masked-name confirmation puts that check in the user's hands.

If limitBlocked is true, do not start the transfer — see Limits.

2. Initialize

POST /api/v1/wallet/transfers/w2w

In addition to the quote body:

FieldTypeDescription
scheduleVersionIdstringFee version from the quote — omit and the current schedule applies
otpDestinationstringWhere the OTP is sent
otpChannelstringSMS, EMAIL, …
localestringLanguage of the OTP message
{
  "status": "success",
  "code": "SUCCESS",
  "data": {
    "operationId": "7a3e9c1d-2b4f-4e8a-9d6c-0f5b8e2a1c93",
    "status": "CHALLENGED",
    "otpRequired": true,
    "otpChannel": "SMS",
    "stepUpRequired": false,
    "integrityHash": "9c1f…",
    "nonce": "4b82…"
  }
}

At this point funds are reserved but nothing has moved. If the user abandons the flow, the operation becomes EXPIRED and the reservation is released.

3. Complete

POST /api/v1/wallet/transfers/w2w/{operationId}/complete
FieldTypeRequired
endUserIdstring✅
operationIdstring✅
integrityHashstring✅
noncestring✅
otpCodestringWhen otpRequired
trustedActionTokenstringWhen you hold a step-up token
{
  "status": "success",
  "code": "SUCCESS",
  "data": {
    "operationId": "7a3e9c1d-2b4f-4e8a-9d6c-0f5b8e2a1c93",
    "status": "COMPLETED",
    "ledgerTxnRef": "ltx_01J8ZU…",
    "receiptUrl": "https://api-gateway.lunixi.com/api/v1/wallet/operations/7a3e9c1d-2b4f-4e8a-9d6c-0f5b8e2a1c93/receipt"
  }
}
⚠️

The nonce is single-use. If complete fails with a network error, do not retry with the same nonce — read the state with GET /wallet/operations/{operationId}. If it is COMPLETED you are done; if it is CHALLENGED, a fresh complete may be attempted.

Verifying the recipient first

You can let the user check "does this number have a wallet?" before starting:

POST /api/v1/wallet/wallets/lookup
{ "programId": "5b9e2f3a-7c41-4d8e-a6f2-9e1b3c4d5f60", "phone": "+905339876543" }
{ "status": "success", "code": "SUCCESS", "data": { "exists": true, "walletNo": "90455132", "maskedName": "Meh*** O***" } }

This endpoint returns a masked name, never a full one — so it cannot be used to harvest names by enumerating numbers. Frequent lookups hit rate limits.

Transfer to an IBAN

Coming soon — requires a bank integration

The contract below is complete and will not change. To run in production your program needs a connected bank integration; that is how money leaves. While no integration is connected, these endpoints do not accept requests. Status: Roadmap.

Sending to a third party's bank account. The flow matches W2W; only the recipient fields differ:

POST /api/v1/wallet/transfers/w2iban/quote
POST /api/v1/wallet/transfers/w2iban
POST /api/v1/wallet/transfers/w2iban/{operationId}/complete
FieldTypeRequiredDescription
endUserIdstring✅The end user you act for; required on all three steps
sourceWalletAccountIdstring✅Must belong to endUserId
beneficiaryNamestring✅Expected to match on the bank side
ibanstring✅
bankCodestring—May influence which bank integration is used
amount, currencystring✅
paymentPurposestring—

The complete body is the same as for W2W: endUserId, operationId (the same id as in the path), integrityHash and nonce are required; add otpCode or trustedActionToken when the initialize response asks for them.

Permission: initialize and complete send money out of the platform, so the key needs wallet:bank-payout:operate, available on the open-loop (Growth) plan. The quote needs only a read permission. Without it the call returns 403 WALLET_INSUFFICIENT_SCOPE — see Key permissions.

IBAN transfers progress asynchronously on the bank side; the operation passes through BANK_PROCESSING and reports back via bankStatus and bankRef.

⚠️

If the bank leaves the outcome uncertain the operation becomes AWAITING_RECON — it is not a failure. Retrying such a transfer risks a double send; wait for reconciliation.

Saved contacts

You can store the people a user sends to often:

GET    /api/portal/v1/wallet/me/contacts
POST   /api/portal/v1/wallet/me/contacts
PUT    /api/portal/v1/wallet/me/contacts/{id}
DELETE /api/portal/v1/wallet/me/contacts/{id}

A contact carries alias plus either contactPhone or contactWalletNo; read responses return the phone masked. Details: Portal — Account & Operations.

Webhooks

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

Common mistakes

SymptomCauseFix
INVALID_SIGNATURE on completenonce/integrityHash regeneratedCarry both verbatim from the init response
Balance looks sufficient but rejectedrequiredAmount (incl. fee + tax) exceeds the balanceCompare against requiredAmount, not amount
limitBlocked: trueDaily/monthly limit exhaustedLimits
OTP code rejectedThe code belongs to a different operationEach operation issues its own OTP; bind the screen to the operation