Getting Started
Best Practices

Best Practices

Keys and tokens

What you store is not a token but your Ed25519 private key. Tokens last about five minutes; your application mints them at runtime.

  • Keep the private key in a secrets manager or a file with restricted access
  • Never log the Authorization header
  • Rotate the key on a regular cadence; revoke immediately if you suspect compromise
  • Do not revoke the old key before the new one is live — the moment a kid is revoked, requests signed with it start returning 401
🚫

The private signing key must never reach a browser, a mobile app, or a public repository. It stays server-side.

Cache the token

Minting a token per call adds needless load and latency.

// ✅ Right — hold it until it expires
let cached: { token: string; expiresAt: number } | null = null;
 
async function getToken() {
  const now = Math.floor(Date.now() / 1000);
  if (cached && cached.expiresAt - 30 > now) return cached.token;
  const t = await mintToken();
  cached = { token: t.access_token, expiresAt: t.expires_at };
  return cached.token;
}
 
// ❌ Wrong — sign and mint on every request
async function handler() {
  const { access_token } = await mintToken();
}

The 30-second margin stops the token expiring while a call is in flight.

⚠️

The token is opaque — there is nothing inside it to decode. Do not parse it or try to pull merchantId out of it.

Error handling

🚫

Branch on code, not on message. message is translatable and will change; code is part of the contract.

// ✅ Right
if (err.code === 'payment.card.declined') { … }
 
// ❌ Wrong — breaks silently when the translation changes
if (err.message.includes('declined')) { … }
  • 500 · 502 · 503 · 504 → retry with exponential backoff
  • 400 · 401 · 403 · 409 · 422 → do not retry; fix the request
  • Write the requestId from every error into your own logs — it is the one key a support request needs

Do not act before the decision

Get the fraud decision before you capture the money. Asking after the transaction has happened produces audit data, not protection.

Acting on force_3d is part of this: the point of selective friction is to let low-risk transactions through without 3DS and protect conversion.

Idempotency

When retrying a create-payment call, send the same Idempotency-Key; otherwise you open a second intent.

Idempotency-Key: intent_<your own transaction reference>

Derive the key from your transaction rather than generating it randomly — on a retry you have to be able to produce the same value.

Consuming webhooks

In its first version, give your endpoint one job: verify the signature → write the event to your own queue → return 2xx. Do not run business logic synchronously; the delivery timeout defaults to 10 seconds.

  • Always verify the signature. The v2 signature is an HMAC of the raw body — verify over the raw bytes without parsing. → Webhooks
  • Be idempotent on x-lunixi-event-id. If you processed the event but your response was lost, the same event comes again.
  • During a long maintenance window, return 2xx quickly and queue the work rather than serving 5xx — after 20 consecutive failures the endpoint is disabled automatically and does not recover on its own.

Amounts

Minor-unit integers in every product. 250000 = ₺2,500.00.

⚠️

Send a decimal and the amount is off by a factor of 100 — silently, because the API received a valid number and has no reason to reject it.

When we are unreachable

Decide this in advance; an outage is not when you want to be making the call.

StrategyRiskUse when
Fail open — proceedHigher fraud exposureLow-value, low-risk transactions
Fail closed — declineMore false positivesHigh-value or regulated transactions
Queue for reviewOperational overheadA balanced approach

Our own posture is fail-closed. If a flow errors while running, the decision rises to at least review, and an existing block is never downgraded. So "I got no answer" and "I got allow" are not the same thing — keep them distinct on your side too.

Do not go live without publishing rules

An unconfigured merchant does not silently get allow: you get the policy default (typically review) with code fraud.decision.unconfigured. Publish your flow and watch it in shadow mode before going live. → Flows and Resolution

Related