Authentication
The Payment Orchestrator API uses different authentication models depending on who is calling (your server, the buyer's browser, your public website) and how sensitive the operation is. This page walks through each model with working examples, then maps every endpoint group to the model it requires.
| Model | What it is | Used by |
|---|---|---|
Bearer access token (at_live_…) | A short-lived token you mint with your Ed25519 key | Listing, queries, subscription & marketplace CRUD |
| Step-Up (Bearer + Ed25519 signature) | Signed request for money movement | Intents, direct charges, capture/void/refund |
| Signed request (Ed25519 signature only) | You sign the request itself with your API key, without a Bearer token | Payment links (/payments/links/*) |
| Form token + session-proof cookie | Short-lived buyer-side credentials | /payments/form/* (hosted checkout) |
Publishable key (mk_live_pk_…) + Origin allow-list | Safe-to-expose browser key | /api/public/v1/subscription/* (pricing catalog) |
Idempotency-Key header | Duplicate-charge protection | All payment POSTs |
Base URL for all examples: https://api-gateway.lunixi.com/api/v1.
Bearer access token
Server-to-server calls are authenticated by a short-lived access token. You do not store it as a static key — you sign with your registered Ed25519 key and mint it from POST /api/v1/auth/token.
Ed25519 private key ──sign──▶ POST /api/v1/auth/token ──▶ at_live_… (~5 min)Send the token you get back in the Authorization header:
curl "https://api-gateway.lunixi.com/api/v1/payments?limit=20" \
-H "Authorization: Bearer at_live_5f3e2b1a..."The example above sends limit only. GET /api/v1/payments also accepts cursor — that is the recommended way to page. The endpoint still accepts page, but it is deprecated: using it makes the response carry Deprecation and Sunset headers. → Pagination
Your long-lived secret is not the token but your Ed25519 private key, and it never travels over the wire. Never ship it to a browser, a mobile app or a public repository. If it leaks, revoke the kid from the panel and generate a new pair.
Step-Up: signed requests for money movement
Every endpoint that moves money — creating payment intents, direct charges, capture, void, refund — requires Step-Up auth: a valid Bearer token and an Ed25519 signature over the request. Even if your token is stolen, an attacker cannot move money without your private signing key.
A Step-Up request carries these headers:
| Header | Value |
|---|---|
Authorization | Bearer at_live_… |
X-Key-Id | The key id (kid) of your registered Ed25519 public key |
X-Date | ISO 8601 request timestamp — must be within ±5 minutes of server time |
X-Nonce | A unique value per request (e.g. a UUID) — single-use, replays are rejected |
Digest | SHA-256=<base64> hash of the raw JSON body (required whenever a body is present) |
X-Signature | Base64 Ed25519 signature over the canonical string below |
The string you sign is built from the method, the full request path and the headers, joined with \n:
POST
/api/v1/payments/intents
X-Date:2026-07-06T12:00:00.000Z
X-Nonce:9f8d2c4e-1a3b-4c5d-8e7f-0a1b2c3d4e5f
Digest:SHA-256=k6I5cUt...=import { createHash, createPrivateKey, sign, randomUUID } from 'node:crypto';
const body = JSON.stringify({
amount: 10050, // minor units — ₺100.50
currency: 'TRY',
orderId: 'ORD-9988',
});
const digest = 'SHA-256=' + createHash('sha256').update(body).digest('base64');
const date = new Date().toISOString();
const nonce = randomUUID();
const canonical = [
'POST',
'/api/v1/payments/intents',
`X-Date:${date}`,
`X-Nonce:${nonce}`,
`Digest:${digest}`,
].join('\n');
const signature = sign(
null, // Ed25519 — no digest algorithm
Buffer.from(canonical),
createPrivateKey(process.env.LUNIXI_SIGNING_KEY_PEM),
).toString('base64');
const res = await fetch('https://api-gateway.lunixi.com/api/v1/payments/intents', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.LUNIXI_SECRET_KEY}`,
'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': randomUUID(),
},
body,
});The Bearer key and the signing key must belong to the same merchant and the same environment — a mismatch is rejected with 403 AUTH_MERCHANT_MISMATCH / AUTH_ENVIRONMENT_MISMATCH.
Checkout form token & session-proof cookie (buyer side)
The hosted checkout form authenticates the buyer's browser without ever exposing your keys:
- Your server creates an intent with Step-Up auth. The response contains a short-lived form token (expires in ~15 minutes) bound to your
allowedOrigin. - The form calls
POST /payments/form/live-analyzewith that token; the response sets a session-proof cookie that binds the browser session. - Every
/payments/form/*call carries the token; the card-touching calls (/form/tokenize,/form/2d,/form/3d) additionally require the session-proof cookie.
curl -X POST "https://api-gateway.lunixi.com/api/v1/payments/form/live-analyze" \
-H "Content-Type: application/json" \
-c cookies.txt \
-d '{ "token": "<form-token-from-intent>" }'If you use the Lunixi checkout SDK from https://cdn.lunixi.com, the token handoff, the live-analyze call and the cookie handling all happen automatically — you never manage these credentials yourself. See the Quickstart.
Publishable key + Origin allow-list
The publishable key (mk_live_pk_…) is safe to embed in your public website. It only unlocks read-only public catalog endpoints, and requests are additionally checked against your registered Origin allow-list — a stolen key is useless from another domain.
curl "https://api-gateway.lunixi.com/api/public/v1/subscription/plans" \
-H "Authorization: Bearer mk_live_pk_xxxxxxxxxxxx" \
-H "Origin: https://shop.example.com"Used by /api/public/v1/subscription/plans|products|branding — for embedding pricing on your site. See Pricing & Portal SDK.
Idempotency
Send an Idempotency-Key header (any unique string, e.g. a UUID) on every payment POST. If the same key is replayed — a network timeout, a double-clicked button, a retrying job — the API returns the original result instead of charging twice. Deduplication is backed by Redis with a database-uniqueness fallback.
curl -X POST "https://api-gateway.lunixi.com/api/v1/payments/pay_01JZ8K4W9X2R/refund" \
-H "Authorization: Bearer at_live_5f3e2b1a..." \
-H "Idempotency-Key: refund-ORD-9988-1" \
... # Step-Up signature headers
-d '{ "amount": 5000 }'Use a key that identifies the operation, not the request — e.g. refund-ORD-9988-1. Generating a fresh random key on every retry defeats the protection.
Which endpoints need which model
| Endpoint group | Auth model |
|---|---|
POST /payments/intents, POST /payments/marketplace/intents | Step-Up |
POST /payments/direct/payment, GET /payments/methods | Step-Up |
POST /payments/:id/capture, /:id/void, /:id/refund | Step-Up |
GET /payments, GET /payments/:id, analytics | Bearer |
POST /bin/info, GET /bin/card-taxonomy, POST /bin/installments | Bearer |
/chargebacks/* | Bearer |
/payments/form/* (2d, 3d, tokenize, live-analyze, bin) | Form token (+ session-proof cookie on card-touching calls) |
GET /payments/threed/:token, /payments/threed/callback/:token | Public — the single-use token in the path is the security boundary |
/payments/links/* (create, list, update, send, QR) | Signed request — Ed25519 signature headers plus the key's payment-link:* scope; no Authorization header, dashboard sessions are not accepted — see Creating Links |
/api/public/v1/pay/* (payment link page) | Public — called by the buyer's browser; carries no key and is protected by a bot check (Turnstile) and a short-lived payment session cookie. You do not need to call it — see Payment Page |
/marketplace/* (dealers, commissions, payouts) | Bearer |
/marketplace/portal/* (seller onboarding) | Marketplace onboarding token (X-Marketplace-Token) — see Seller Onboarding |
/api/v1/subscriptions/* | Bearer |
/api/public/v1/subscription/* | Publishable key + Origin |
/api/portal/v1/* (end-customer self-service) | Portal session (portal token → OTP → session JWT) — see Customer Portal |
Errors
| Status / code | Meaning | What to do |
|---|---|---|
401 AUTH_HEADERS_MISSING | One of X-Key-Id, X-Date, X-Nonce, X-Signature is missing | Send all four Step-Up headers |
401 INVALID_DATE | X-Date outside the ±5 minute window | Sync your server clock (NTP) and retry |
401 DIGEST_MISSING / INVALID_DIGEST | Body present but Digest missing or wrong | Hash the exact raw body bytes you send |
401 INVALID_SIGNATURE | Signature does not verify | Rebuild the canonical string — method, path, headers, in that order, \n-joined |
401 (nonce reuse) | X-Nonce was already used | Generate a fresh nonce per request |
403 AUTH_MERCHANT_MISMATCH | Bearer key and signing key belong to different merchants | Use keys from the same merchant account |
403 AUTH_ENVIRONMENT_MISMATCH | Test key mixed with live key | Keep test and live credentials separate |
403 IP_NOT_ALLOWED | Caller IP not on the key's IP allow-list | Update the allow-list in the panel |
Next steps
- Quickstart — put the Step-Up flow to work and take your first payment
- Checkout Form — the full intent-create reference
- Webhooks — verify signed event deliveries