Wallet
Top-up

Top-up

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 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
FieldTypeRequiredDescription
creditWalletAccountIdstring✅The account to credit (an account id, not a wallet id)
amountstring✅Minor-unit amount
currencystring✅Must match the account currency
returnUrlstring—Where the user returns after payment
savedConsumerTokenstring—Consumer token when charging a saved card
savedPublicCardStorageTokenstring—Saved card token
fundingMerchantIdstring—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:

FieldWhat you do
checkoutFormContentMount it in the user's browser — the hosted checkout form opens
threeDsHtmlRender the 3D Secure page
redirectUrlRedirect 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:

Top-up lifecycleState machine: a top-up opens, ledger entries are written once the user pays and the balance reflects it. A failed payment ends it; a completed top-up that is refunded is reversed.USER PAYSLEDGER WRITTENPAYMENT FAILEDREFUNDINITIALIZEDPOSTINGCOMPLETEDFAILEDREVERSEDLEGENDTERMINALMONEY RETURNED
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

EventWhen
wallet.topup.completedFunds landed in the balance
wallet.topup.failedThe charge failed, balance unchanged
wallet.topup.refund_pendingRefund could not be made: the wallet balance does not cover the refund amount; the top-up stays COMPLETED
wallet.topup.refundedRefund completed
wallet.topup.reversedA 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
FieldTypeRequiredDescription
reasonstring✅Written into the audit record
requesterPrincipalstring✅Identity of the requesting operator
approverPrincipalstring✅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:

  1. 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.
  2. The amount is debited from the wallet balance.
  3. The amount is refunded to the card.
  4. The top-up becomes REVERSED and the wallet.topup.refunded webhook 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
FieldTypeRequired
agentIdstring✅
creditWalletAccountIdstring✅
amountstring✅
currencystring✅
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

Coming soon — requires a bank integration

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
FieldTypeRequiredDescription
walletAccountIdstring✅Account to credit when funds arrive
currencystring—Defaults to the account currency

List instructions

GET /api/v1/wallet/end-users/{endUserId}/deposit-instructions

Always 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

SymptomCauseFix
The same top-up happened twiceA fresh Idempotency-Key on every retryDerive the key from business meaning (order/attempt)
currency mismatchThe account is in a different currencyVerify with GET /wallet/end-users/:id/balances
Top-up stuck at INITIALIZEDThe user never completed paymentIt becomes EXPIRED on timeout; start a new top-up
Limit rejectionThe KYC-tiered top-up ceiling was exceededSee Limits and KYC Levels
WALLET_KYC_BALANCE_CAP_EXCEEDEDThe unverified (NONE) account's balance would exceed the ₺5,000 capSee Limits and KYC Levels