Wallet
Authentication

Authentication

The Wallet API runs on separate surfaces, each with its own identity model. Picking the wrong surface is the most common integration mistake: endpoints your server calls live under /api/v1/wallet/*, endpoints the end user's app calls live under /api/portal/v1/wallet/*, and endpoints your operations team uses live under /api/panel/v1/wallet/*. The three identity models are below; for measured endpoint counts and cross-surface overlap see the matrix right after.

SurfaceModelWho carries it
External API (s2s)Ed25519-signed request (X-Key-Id + X-Date + X-Nonce + Digest + X-Signature)Your backend only
Portal / SDKPublishable key (pk_live_…) → customer session (JWT) → step-up on sensitive actionsThe end user's device
Merchant panelPanel session + RBAC permission code + four-eyesYour operations team

Base URL: https://api-gateway.lunixi.com.

Surface comparison matrix

There are in fact four surfaces: three carry one of the identity models above, and the fourth is a single endpoint reserved for terminal activation. The endpoint counts below are counted from the running controllers, not from documentation.

SurfaceRoute rootIdentityEndpoints
Merchant panel/api/panel/v1/wallet/**Panel session (JWT) + RBAC permission code; four-eyes on money-critical actions186
External API (s2s)/api/v1/wallet/**Ed25519 request signature; ±5 min window, single-use nonce, per-key IP allow-list60
Portal / SDK/api/portal/v1/wallet/**Publishable key → OTP → end-user session (not the merchant)87
Terminal activation/api/v1/wallet/terminals/activate⚠️ No identity guard — the proof of authority is a one-time activation code1

Of the panel's 186 endpoints, 162 live in the main controller and 10 in the four-eyes request controller; the remaining 14 are in the card (7), cash-out (4) and card-event webhook (3) controllers.

How much do panel and s2s overlap?

🚫

You cannot build your own panel on the s2s API. Of the 172 endpoints in the panel's main and four-eyes request controllers only 33 have an s2s counterpart. The other 139 are panel-only: a signing key does not open that door, because panel endpoints sit behind BearerAuthGuard (JWT) + PermissionsGuard, and a signed request passes neither.

IntersectionEndpointsWhat is in it
Panel only139See the table below
Shared panel + s2s33End-user and corporate-account read/write, operation read, the bulk-payout flow, QR issue/revoke, fees/quote, KYC level read
s2s only25The money itself: transfers/w2w, transfers/w2iban, withdrawals/bank, payments/buy, collect, payments/:id/approve, topups/card, agent-topups, qr/parse, qr/:id/image, wallets/lookup …

The 139 capabilities that exist in the panel and have no s2s counterpart:

CategoryEndpointsExamples
Program lifecycle + policies32programs, programs/:id/sponsor-policy, bank-rails, provider-switches, spend-policies, otp-policy, shared-limit-caps
Campaign / loyalty / segment29campaigns/**, segments/**, loyalty/**
Terminal + QR producer code18terminals/** (provisioning, credential rotate/revoke, suspend, bulk actions), qr/producer-codes/**
Fee / commission / limit schedules17fee-schedules/**, commission-schedules/**, limit-schemes/** and the version submit/approve chain
Reports11reports/overview, reports/operations, reports/fees, reports/campaigns, reports/custody, reports/terminals, operations/export, bulk-payout statements
Four-eyes intervention / operations16operations/:id/intervention-requests, withdrawals/:id/intervention-requests, cash-out/:id/hold-requests, suspense-funds/:id/match-requests, .../return, topups/:id/refund-requests, end-users/:id/kyc-level/override-requests, intervention-requests/**, ops-health, bill-payments
End user / corporate (panel-only)7end-users/:id/close, .../unfreeze, .../wallets, .../kyc-level/history, .../merchant-category, corporate-accounts/:id
Invoice / crypto5invoices/**, crypto/balances
Integration / provider / agents3integrations/catalog, agents
Bulk payout (panel-only)1bulk-payouts/:id/cancel

These numbers are counted from the route decorators in apps/api-gateway/src/wallet/*.controller.ts. They shift as endpoints are added; what is permanent is not the ratio but the direction: the panel surface is far wider than s2s, and the two are separate controllers.

External API (s2s) — signed requests

The wallet s2s surface is signature-based: every request is signed with your Ed25519 private key. You do not additionally send a Bearer API key here — the signature is your identity.

Get your key

Create a secret key under API Keys in the merchant panel. Two provisioning models exist:

  • Platform (reveal-once) — the platform generates the key pair, registers the public key and shows you the private key exactly once. Once you close that screen it cannot be retrieved; if lost, rotate.
  • BYO (bring your own) — you generate the key pair and upload only the public key. The private key never reaches the platform; this is the safest option.
# Generate an Ed25519 key pair for BYO
openssl genpkey -algorithm ed25519 -out lunixi-signing.key
openssl pkey -in lunixi-signing.key -pubout -out lunixi-signing.pub

The key's id (kid) goes in the X-Key-Id header.

Key permissions

After the signature, every request passes two more checks:

  1. Product — the key must list the wallet product (WALLET_SERVICE). Otherwise every endpoint, reads included, returns 403 PRODUCT_NOT_ENABLED.
  2. Permission — the key must carry the wallet permission for the call. Permissions are enforced fail-closed: a call the key is not permitted for returns 403 WALLET_INSUFFICIENT_SCOPE, and a key with no wallet permission can do nothing on the wallet.

Permissions are not hierarchical: a read permission does not allow writes, and a set-up permission never moves money.

EndpointsPermission on the key
Reads: GET programs/me, end-user, balance, limit and KYC-level reads, operations and receipts, top-up / payment / withdrawal detail, business commission, QR list / detail / image, POST qr/parse, bulk-payout list / detail / items, and the quotes (fees/quote, transfers/w2w/quote, transfers/w2iban/quote, withdrawals/bank/quote)Any of wallet:program:read, wallet:enduser:read, wallet:operation:read, wallet:qr:read
Set-up: GET ping, PUT programs/:programId/collection-anchor, POST end-users, POST end-users/:id/freeze, POST end-users/:id/wallets, POST wallets/:id/accounts, POST wallets/lookup, deposit instructionswallet:program:manage or wallet:enduser:manage
PUT end-users/:id/kyc-levelwallet:enduser:kyc-override
Money inside the wallet: transfers/w2w initiate and complete, payments/buy, payments/:id/approve, collect, topups/card, agent-topups, topups/:id/refundwallet:operation:intervene
Money leaving the platform: transfers/w2iban and withdrawals/bank initiate and complete; the test inbound-credit simulation (bank-integrations/inbound-credits)wallet:bank-payout:operate — available on the open-loop (Growth) plan
POST qr, POST qr/:id/revokewallet:qr:manage
POST bulk-payouts, POST bulk-payouts/:id/reject; corporate accounts (POST / GET corporate-accounts, employees/import, employees/offboard)wallet:bulk:create
POST bulk-payouts/:id/submit, /approve, /process, /retry-failedwallet:bulk:approve
⚠️

Four eyes on bulk payouts: a batch must be approved with a different key from the one that created it; the creating key is rejected with WALLET_FOUR_EYES_VIOLATION. The maker key, which creates and submits the batch, holds both wallet:bulk:create and wallet:bulk:approve; the checker key, which approves it, is a separate key holding wallet:bulk:approve.

What that means in practice:

  • A reporting or support integration can be given read permissions only; it cannot call transfers/w2w or withdrawals/bank.
  • Money leaving the platform needs its own permission: wallet:operation:intervene alone cannot send money to an IBAN or a bank account.
  • If a call returns WALLET_INSUFFICIENT_SCOPE, add the permission from the table above to the key in the panel.
  • Also fill in the key's IP allow-list for production keys.

Request headers

HeaderValueRequired
X-Key-IdYour key idYes
X-DateISO 8601 timestamp — within ±5 minutes of server timeYes
X-NonceUnique per request (UUID) — single-useYes
DigestSHA-256=<base64> of the body — sent as compact JSON, see DigestIf a body is sent
X-SignatureBase64 Ed25519 signature over the canonical stringYes
Idempotency-KeyDuplicate protection, 1–128 charactersOn the endpoints listed under Idempotency-Key

Canonical string

The signed text is these lines joined with \n:

POST
/api/v1/wallet/transfers/w2w
X-Date:2026-08-07T12:00:00.000Z
X-Nonce:9f8d2c4e-1a3b-4c5d-8e7f-0a1b2c3d4e5f
Digest:SHA-256=k6I5cUt...=
⚠️

The second line is the full request path including the query string. When signing GET /api/v1/wallet/operations?limit=20, the second line must be /api/v1/wallet/operations?limit=20 — signing the path alone yields INVALID_SIGNATURE. When there is no body, the Digest line is omitted entirely.

Digest and compact JSON

The server parses the JSON body and recomputes the SHA-256 digest over its compact form — no line breaks and no spaces between tokens. Send the body as compact JSON and compute Digest over exactly the string you send. A pretty-printed body fails with 401 INVALID_DIGEST, even when its content is the same. In Node.js, JSON.stringify(body) without an indent argument produces the right form.

Idempotency-Key

The header is trimmed and must be 1–128 characters. On the endpoints below it is required; a missing or blank header is rejected with 400:

  • End users and wallets: POST end-users, POST end-users/:id/freeze, PUT end-users/:id/kyc-level, POST end-users/:id/wallets, POST wallets/:id/accounts
  • Corporate accounts: POST corporate-accounts, employees/import, employees/offboard
  • Transfers and withdrawals: transfers/w2w, transfers/w2iban and withdrawals/bank — both initiate and complete
  • Top-ups and payments: topups/card, agent-topups, payments/buy, payments/:id/approve, collect
  • POST qr, POST bulk-payouts

It is not required on quotes, GET endpoints, PUT programs/:programId/collection-anchor, deposit instructions, bank-integrations/inbound-credits, topups/:id/refund, qr/:id/revoke, qr/parse, wallets/lookup, or the bulk-payout submit, approve, reject, process and retry-failed calls.

Working example

import { createHash, createPrivateKey, sign, randomUUID } from 'node:crypto';
 
const BASE = 'https://api-gateway.lunixi.com';
const path = '/api/v1/wallet/transfers/w2w';
 
const body = JSON.stringify({
  endUserId: '8d4c2a1f-3b6e-4f7a-9c5d-2e8b1a0f6c34',
  sourceWalletAccountId: 'c2a5e8f1-9d3b-4c7a-8e6f-5b1d2a9c0e47',
  destinationPhone: '+905321234567',
  amount: '10050',            // minor-unit string — ₺100.50
  currency: 'TRY',
  paymentPurpose: 'P2P',
});
 
const digest = 'SHA-256=' + createHash('sha256').update(body).digest('base64');
const date = new Date().toISOString();
const nonce = randomUUID();
 
const canonical = ['POST', path, `X-Date:${date}`, `X-Nonce:${nonce}`, `Digest:${digest}`].join('\n');
 
const signature = sign(
  null,                        // Ed25519 — no separate digest algorithm
  Buffer.from(canonical),
  createPrivateKey(process.env.LUNIXI_SIGNING_KEY_PEM),
).toString('base64');
 
const res = await fetch(BASE + path, {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'X-Key-Id': process.env.LUNIXI_KEY_ID,
    'X-Date': date,
    'X-Nonce': nonce,
    'Digest': digest,
    'X-Signature': signature,
    'Idempotency-Key': 'p2p-9988-attempt-1',
  },
  body,
});

Verify connectivity first

When setting up signing, start with an endpoint that has no body and needs no idempotency:

GET /api/v1/wallet/ping

ping requires a set-up permission (wallet:program:manage or wallet:enduser:manage) on the key; a read-only key receives 403 WALLET_INSUFFICIENT_SCOPE. A 200 means your key, clock and canonical string are correct. Do not attempt money-movement endpoints before ping works — they are far harder to debug.

Response format

Successful responses arrive in the gateway envelope; the fields documented on each page are under data:

{
  "status": "success",
  "code": "SUCCESS",
  "message": "…",
  "data": {
    "operationId": "7a3e9c1d-2b4f-4e8a-9d6c-0f5b8e2a1c93",
    "status": "CHALLENGED"
  },
  "requestId": "…"
}
  • Lists return data.items and data.nextCursor; send nextCursor back as cursor to fetch the next page. The operation list returns its records under data.operations.
  • Errors return the HTTP status and a body carrying code (for example WALLET_NOT_FOUND) and message.
  • Every id is a UUID. Amounts are minor-unit digit strings of up to 18 digits: "12550" means ₺125.50.

Error codes

CodeHTTPMeaningWhat to do
AUTH_HEADERS_MISSING401A required header is missingSend X-Key-Id, X-Date, X-Nonce, X-Signature
INVALID_DATE401X-Date outside the 5-minute windowSync your server clock with NTP
NONCE_REPLAY401The nonce was already usedGenerate a fresh nonce per request
INVALID_KID401Key id not found or inactiveCheck the key's status in the panel
MERCHANT_SUSPENDED403Account is not activeContact support
IP_NOT_ALLOWED403Caller IP is not allow-listedAdd your server IP in the panel
DIGEST_MISSING401Body present but no DigestAdd the SHA-256 hash of the body
INVALID_DIGEST401Digest does not match the bodySend the body as compact JSON and compute the digest over exactly that string
INVALID_SIGNATURE401Signature verification failedCheck the canonical string (query string, presence of the Digest line)
PRODUCT_NOT_ENABLED403The key does not list the wallet productAdd WALLET_SERVICE to the key's products
WALLET_INSUFFICIENT_SCOPE403The key lacks the wallet permission for this callGrant the permission listed under Key permissions

Nonces are remembered for ~6 minutes and the X-Date window is ±5 minutes, so a replayed old request fails at both gates.

Portal / SDK — publishable key + customer session

The end user's app never carries a signing key — if it did, anyone who installed the app could reach every wallet in your tenant.

Identify with a publishable key

Create a publishable key (pk_live_…) in the panel and register your app origins (allowedOrigins). This key is safe to ship in a browser or app; on its own it grants access to nothing.

⚠️

allowedOrigins is mandatory and wildcards (*) are rejected. The key works only from the origins you list.

Sign the user in

Call POST /api/portal/v1/wallet/auth/register or /login; an OTP is sent to the phone number. POST /api/portal/v1/wallet/auth/otp/verify opens a customer session (access + refresh token).

Call with the session token

All subsequent /api/portal/v1/wallet/me/* calls carry the session token and reach only that user's own data.

Step up on sensitive actions

For outbound money movement — transfers, withdrawals, QR payments — the session alone is not enough: an OTP (otpCode) or a trusted action token (trustedActionToken) is required.

Details: Portal — Identity & Session.

Merchant panel

Panel endpoints (/api/panel/v1/wallet/*) use your panel session, and each endpoint is bound to an RBAC permission code (wallet:program:manage, wallet:operation:intervene, wallet:bulk:approve, …). Money-critical actions additionally enforce four eyes.

⚠️

Four eyes is enforced by principal comparison, not by a role gate: the requester and the approver cannot be the same person. Even though OWNER/ADMIN hold both permissions, the same user cannot approve their own request.

Details: Panel — Overview & Permissions.

Terminal activation — the only endpoint with no identity guard

POST /api/v1/wallet/terminals/activate is the only wallet endpoint without an identity guard: it asks for no signature, no panel session and no customer session.

This is not an omission but the solution to a chicken-and-egg problem: a device that has not been activated yet has no identity — and no key to sign with, because this endpoint is what grants it one.

The proof of authority is the one-time activation code the device presents:

ProtectionHow
Unguessable70 bits of entropy (14 characters × a 32-letter alphabet)
Single useBurned atomically and never released back — even when provisioning fails
Short lived15 minutes by default
Brute forceTwo-window rate limit per IP: 5 attempts per 60 s, 30 per hour
Tenant leakageThe tenant cannot be declared; it is derived from the code
⚠️

This endpoint accepts no private key. Bring-your-own form factors (ATTENDED_POS, MOBILE_APP) upload only the public key; platform form factors (SERVER_ECR, WEB_TILL) must not send a public key and receive a platform-generated private PEM that is revealed once in the response and stored nowhere.

Creating the terminal itself, issuing an activation code, rotating or revoking its credential are all on the panel surface (wallet:terminal:manage) — see Panel — QR & Terminals.

Key management

  • Rotation — rotating in the panel issues a new key and revokes the old one. For a zero-downtime switch, deploy the new key first, move traffic, then revoke the old one.
  • Revocation — revoke immediately on suspicion of leakage; requests with a revoked kid get INVALID_KID.
  • The private key must never reach logs, repositories, mobile apps or browsers. The platform does not store it either.