Transfers
- 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:
| Type | Destination | Operation type | Status |
|---|---|---|---|
| Wallet to wallet (W2W) | Another wallet in the same program | W2W_TRANSFER | ✅ Live |
| To an IBAN (W2IBAN) | A third party's bank account | W2IBAN_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| Field | Type | Required | Description |
|---|---|---|---|
endUserId | string | ✅ | The end user you act for — the sender |
sourceWalletAccountId | string | ✅ | Sender's account; must belong to endUserId |
destinationWalletNo | string | ⚠️ | Recipient's wallet number |
destinationPhone | string | ⚠️ | Recipient's phone |
amount | string | ✅ | Minor-unit |
currency | string | ✅ | |
paymentPurpose | string | — | 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/w2wIn addition to the quote body:
| Field | Type | Description |
|---|---|---|
scheduleVersionId | string | Fee version from the quote — omit and the current schedule applies |
otpDestination | string | Where the OTP is sent |
otpChannel | string | SMS, EMAIL, … |
locale | string | Language 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| Field | Type | Required |
|---|---|---|
endUserId | string | ✅ |
operationId | string | ✅ |
integrityHash | string | ✅ |
nonce | string | ✅ |
otpCode | string | When otpRequired |
trustedActionToken | string | When 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
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| Field | Type | Required | Description |
|---|---|---|---|
endUserId | string | ✅ | The end user you act for; required on all three steps |
sourceWalletAccountId | string | ✅ | Must belong to endUserId |
beneficiaryName | string | ✅ | Expected to match on the bank side |
iban | string | ✅ | |
bankCode | string | — | May influence which bank integration is used |
amount, currency | string | ✅ | |
paymentPurpose | string | — |
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
| Event | When |
|---|---|
wallet.transfer.completed | Transfer completed, balances updated |
wallet.transfer.failed | Transfer failed, reservation released |
wallet.transfer.reversed | A completed transfer was reversed |
Common mistakes
| Symptom | Cause | Fix |
|---|---|---|
INVALID_SIGNATURE on complete | nonce/integrityHash regenerated | Carry both verbatim from the init response |
| Balance looks sufficient but rejected | requiredAmount (incl. fee + tax) exceeds the balance | Compare against requiredAmount, not amount |
limitBlocked: true | Daily/monthly limit exhausted | Limits |
| OTP code rejected | The code belongs to a different operation | Each operation issues its own OTP; bind the screen to the operation |