Top-up
- 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 side is
/api/portal/v1/wallet/me/topups/*; in the panel, top-up refunds and suspense-fund matching are four-eyes requests (wallet:operation:intervene; requester and approver are different people, both taken from their panel sessions).
Money enters the wallet three ways: by card, as cash through an agent, and by bank transfer. All three share the same operation model — each top-up produces an operation record and the outcome is delivered by webhook. Starting a card or agent top-up requires an Idempotency-Key.
Amounts are minor-unit strings: "amount": "25000" means ₺250.00.
Card top-up
Card top-ups are delegated to the payment stack; card data never touches your server. Routing, 3D Secure and provider selection happen on the payment side, and the result lands in the wallet.
Start the top-up
POST /api/v1/wallet/topups/card| Field | Type | Required | Description |
|---|---|---|---|
creditWalletAccountId | string | ✅ | The account to credit (an account id, not a wallet id) |
amount | string | ✅ | Minor-unit amount |
currency | string | ✅ | Must match the account currency |
returnUrl | string | — | Where the user returns after payment |
savedConsumerToken | string | — | Consumer token when charging a saved card |
savedPublicCardStorageToken | string | — | Saved card token |
fundingMerchantId | string | — | Which merchant charges, in multi-merchant setups |
curl -X POST "https://api-gateway.lunixi.com/api/v1/wallet/topups/card" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: topup-crm-88213-1" \
-H "X-Key-Id: …" -H "X-Signature: …" \
-d '{
"creditWalletAccountId": "c2a5e8f1-9d3b-4c7a-8e6f-5b1d2a9c0e47",
"amount": "25000",
"currency": "TRY",
"returnUrl": "https://app.acme.com/wallet/topup/result"
}'{
"status": "success",
"code": "SUCCESS",
"data": {
"operationId": "4f1b8d2e-6a9c-4b3e-8f7d-1c5e9a2b6d08",
"paymentId": "9c6f2b1a-4e8d-4a3f-b5e6-2d7c1a9b8e30",
"status": "INITIALIZED",
"checkoutFormContent": "<script …>",
"threeDsHtml": "",
"redirectUrl": ""
}
}The response offers three possible continuations — use whichever is populated:
| Field | What you do |
|---|---|
checkoutFormContent | Mount it in the user's browser — the hosted checkout form opens |
threeDsHtml | Render the 3D Secure page |
redirectUrl | Redirect the user there |
Get the outcome
GET /api/v1/wallet/topups/{operationId}Do not derive the outcome from the user returning to returnUrl — the browser may never come back (app closed, network dropped). The webhook is the source of truth; GET /wallet/topups/{operationId} is for confirmation.
Status flow:
REVERSED can only follow a completed top-up — if it never completed there is nothing to refund: it is FAILED, and the reserved funds were already released.Related webhooks
| Event | When |
|---|---|
wallet.topup.completed | Funds landed in the balance |
wallet.topup.failed | The charge failed, balance unchanged |
wallet.topup.refund_pending | Refund could not be made: the wallet balance does not cover the refund amount; the top-up stays COMPLETED |
wallet.topup.refunded | Refund completed |
wallet.topup.reversed | A completed top-up was reversed |
Top-up refund
A completed card top-up can be refunded — money returns to the card and leaves the wallet balance.
POST /api/v1/wallet/topups/{operationId}/refund| Field | Type | Required | Description |
|---|---|---|---|
reason | string | ✅ | Written into the audit record |
requesterPrincipal | string | ✅ | Identity of the requesting operator |
approverPrincipal | string | ✅ | Approving operator — must differ from the requester |
This endpoint enforces four eyes: approverPrincipal cannot equal requesterPrincipal. On this s2s endpoint both identities come from your system; binding them to two genuinely different people is the responsibility of your own approval flow. A refund made from the panel is a stored four-eyes request whose identities are taken from the panel sessions: Four-eyes requests.
A refund runs in this order:
- The top-up moves to
REVERSING. A concurrent second refund or reversal of the same top-up cannot win this step and moves no money. - The amount is debited from the wallet balance.
- The amount is refunded to the card.
- The top-up becomes
REVERSEDand thewallet.topup.refundedwebhook is sent.
If the outcome of step 2 or 3 cannot be settled at that moment (timeout, provider unreachable), the top-up stays in REVERSING. The system completes the refund automatically with the same keys; the amount never moves a second time from the wallet or to the card. You do not need to restart the refund; repeating the same request resumes it.
If the user has already spent the topped-up funds, the refund is not made: the balance is never pushed negative and the card is not refunded. The request is rejected with WALLET_REFUND_INSUFFICIENT_BALANCE, the top-up stays COMPLETED and the wallet.topup.refund_pending webhook is sent. You can request the refund again once the balance covers it.
Agent top-up
If you run an agent/dealer network, the agent takes cash and credits the customer's wallet from their own float account. Money moves inside the tenant; nothing enters from outside.
POST /api/v1/wallet/agent-topups| Field | Type | Required |
|---|---|---|
agentId | string | ✅ |
creditWalletAccountId | string | ✅ |
amount | string | ✅ |
currency | string | ✅ |
curl -X POST "https://api-gateway.lunixi.com/api/v1/wallet/agent-topups" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: agent-4471-20260807-0012" \
-H "X-Key-Id: …" -H "X-Signature: …" \
-d '{
"agentId": "6b2e9f4a-3c8d-4e1b-9a7f-5d0c3e8b2a61",
"creditWalletAccountId": "c2a5e8f1-9d3b-4c7a-8e6f-5b1d2a9c0e47",
"amount": "50000",
"currency": "TRY"
}'The response status is normally COMPLETED. If the ledger outcome cannot be settled at that moment it is INDETERMINATE: the credit may have been made and the system settles the operation on its own. Do not tell the agent "the top-up failed" and do not retry with a new Idempotency-Key; repeating with the same key returns the operation's current state. See Uncertain outcome.
If the agent's float is insufficient the operation is rejected. The agent's daily credit ceiling (dailyCreditLimit) is configured in the panel and cannot be exceeded — this is the gate that stops an agent from creating balance with no cash behind it.
Agents and float management are configured in the merchant panel: Panel — Programs.
Bank transfer top-up
The API contract for this flow is complete and will stay as described below. To run in production your program needs a connected bank integration: generating deposit references and matching inbound funds run on it. While no integration is connected, these endpoints do not accept requests. Status: Roadmap.
Bank transfer top-up works by giving each user a dedicated deposit reference. The user sends money quoting it, and inbound funds are matched to that reference and credited to the wallet.
Create a deposit instruction
POST /api/v1/wallet/end-users/{endUserId}/deposit-instructions| Field | Type | Required | Description |
|---|---|---|---|
walletAccountId | string | ✅ | Account to credit when funds arrive |
currency | string | — | Defaults to the account currency |
List instructions
GET /api/v1/wallet/end-users/{endUserId}/deposit-instructionsAlways read the IBAN and reference you display from this endpoint; never hard-code them.
If the user mistypes or omits the reference, the money goes to suspense — it is not lost. Suspense funds are either matched to an instruction or returned to the sender from the panel. See Core Concepts → Suspense funds.
Once matched, wallet.topup.completed fires with fundingSourceType indicating the bank-transfer source.
Common mistakes
| Symptom | Cause | Fix |
|---|---|---|
| The same top-up happened twice | A fresh Idempotency-Key on every retry | Derive the key from business meaning (order/attempt) |
currency mismatch | The account is in a different currency | Verify with GET /wallet/end-users/:id/balances |
Top-up stuck at INITIALIZED | The user never completed payment | It becomes EXPIRED on timeout; start a new top-up |
| Limit rejection | The KYC-tiered top-up ceiling was exceeded | See Limits and KYC Levels |
WALLET_KYC_BALANCE_CAP_EXCEEDED | The unverified (NONE) account's balance would exceed the ₺5,000 cap | See Limits and KYC Levels |