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.
| Surface | Model | Who carries it |
|---|---|---|
| External API (s2s) | Ed25519-signed request (X-Key-Id + X-Date + X-Nonce + Digest + X-Signature) | Your backend only |
| Portal / SDK | Publishable key (pk_live_…) → customer session (JWT) → step-up on sensitive actions | The end user's device |
| Merchant panel | Panel session + RBAC permission code + four-eyes | Your 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.
| Surface | Route root | Identity | Endpoints |
|---|---|---|---|
| Merchant panel | /api/panel/v1/wallet/** | Panel session (JWT) + RBAC permission code; four-eyes on money-critical actions | 186 |
| External API (s2s) | /api/v1/wallet/** | Ed25519 request signature; ±5 min window, single-use nonce, per-key IP allow-list | 60 |
| 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 code | 1 |
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.
| Intersection | Endpoints | What is in it |
|---|---|---|
| Panel only | 139 | See the table below |
| Shared panel + s2s | 33 | End-user and corporate-account read/write, operation read, the bulk-payout flow, QR issue/revoke, fees/quote, KYC level read |
| s2s only | 25 | The 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:
| Category | Endpoints | Examples |
|---|---|---|
| Program lifecycle + policies | 32 | programs, programs/:id/sponsor-policy, bank-rails, provider-switches, spend-policies, otp-policy, shared-limit-caps |
| Campaign / loyalty / segment | 29 | campaigns/**, segments/**, loyalty/** |
| Terminal + QR producer code | 18 | terminals/** (provisioning, credential rotate/revoke, suspend, bulk actions), qr/producer-codes/** |
| Fee / commission / limit schedules | 17 | fee-schedules/**, commission-schedules/**, limit-schemes/** and the version submit/approve chain |
| Reports | 11 | reports/overview, reports/operations, reports/fees, reports/campaigns, reports/custody, reports/terminals, operations/export, bulk-payout statements |
| Four-eyes intervention / operations | 16 | operations/: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) | 7 | end-users/:id/close, .../unfreeze, .../wallets, .../kyc-level/history, .../merchant-category, corporate-accounts/:id |
| Invoice / crypto | 5 | invoices/**, crypto/balances |
| Integration / provider / agents | 3 | integrations/catalog, agents |
| Bulk payout (panel-only) | 1 | bulk-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.pubThe key's id (kid) goes in the X-Key-Id header.
Key permissions
After the signature, every request passes two more checks:
- Product — the key must list the wallet product (
WALLET_SERVICE). Otherwise every endpoint, reads included, returns403 PRODUCT_NOT_ENABLED. - 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.
| Endpoints | Permission 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 instructions | wallet:program:manage or wallet:enduser:manage |
PUT end-users/:id/kyc-level | wallet:enduser:kyc-override |
Money inside the wallet: transfers/w2w initiate and complete, payments/buy, payments/:id/approve, collect, topups/card, agent-topups, topups/:id/refund | wallet: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/revoke | wallet: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-failed | wallet: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/w2worwithdrawals/bank. - Money leaving the platform needs its own permission:
wallet:operation:intervenealone 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
| Header | Value | Required |
|---|---|---|
X-Key-Id | Your key id | Yes |
X-Date | ISO 8601 timestamp — within ±5 minutes of server time | Yes |
X-Nonce | Unique per request (UUID) — single-use | Yes |
Digest | SHA-256=<base64> of the body — sent as compact JSON, see Digest | If a body is sent |
X-Signature | Base64 Ed25519 signature over the canonical string | Yes |
Idempotency-Key | Duplicate protection, 1–128 characters | On 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/w2ibanandwithdrawals/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/pingping 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.itemsanddata.nextCursor; sendnextCursorback ascursorto fetch the next page. The operation list returns its records underdata.operations. - Errors return the HTTP status and a body carrying
code(for exampleWALLET_NOT_FOUND) andmessage. - Every id is a UUID. Amounts are minor-unit digit strings of up to 18 digits:
"12550"means ₺125.50.
Error codes
| Code | HTTP | Meaning | What to do |
|---|---|---|---|
AUTH_HEADERS_MISSING | 401 | A required header is missing | Send X-Key-Id, X-Date, X-Nonce, X-Signature |
INVALID_DATE | 401 | X-Date outside the 5-minute window | Sync your server clock with NTP |
NONCE_REPLAY | 401 | The nonce was already used | Generate a fresh nonce per request |
INVALID_KID | 401 | Key id not found or inactive | Check the key's status in the panel |
MERCHANT_SUSPENDED | 403 | Account is not active | Contact support |
IP_NOT_ALLOWED | 403 | Caller IP is not allow-listed | Add your server IP in the panel |
DIGEST_MISSING | 401 | Body present but no Digest | Add the SHA-256 hash of the body |
INVALID_DIGEST | 401 | Digest does not match the body | Send the body as compact JSON and compute the digest over exactly that string |
INVALID_SIGNATURE | 401 | Signature verification failed | Check the canonical string (query string, presence of the Digest line) |
PRODUCT_NOT_ENABLED | 403 | The key does not list the wallet product | Add WALLET_SERVICE to the key's products |
WALLET_INSUFFICIENT_SCOPE | 403 | The key lacks the wallet permission for this call | Grant 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:
| Protection | How |
|---|---|
| Unguessable | 70 bits of entropy (14 characters × a 32-letter alphabet) |
| Single use | Burned atomically and never released back — even when provisioning fails |
| Short lived | 15 minutes by default |
| Brute force | Two-window rate limit per IP: 5 attempts per 60 s, 30 per hour |
| Tenant leakage | The 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
kidgetINVALID_KID. - The private key must never reach logs, repositories, mobile apps or browsers. The platform does not store it either.