Getting Started
Quickstart

Quickstart

From zero to your first successful API call. The critical step is signing, so we give you working code for it.

Generate an Ed25519 key pair

This is your long-lived secret, and it stays on your server.

openssl genpkey -algorithm ed25519 -out lunixi-private.pem
openssl pkey -in lunixi-private.pem -pubout -out lunixi-public.pem
⚠️

Never put lunixi-private.pem in a repository, a browser, or a mobile app. If it leaks you have to revoke the key from the panel and generate a new one.

Register the public key

Sign in to the panel (opens in a new tab) and register the public key (lunixi-public.pem). You get back a kid (key id), which goes on every signed request.

Mint an access token

The code below builds the canonical string, signs it and gets a token. No extra dependencies — Node.js's own crypto module is enough.

import crypto from 'crypto';
import fs from 'fs';
 
const BASE = 'https://api-gateway.lunixi.com';
const KID = process.env.LUNIXI_KEY_ID!;
const privateKey = crypto.createPrivateKey(
  fs.readFileSync(process.env.LUNIXI_PRIVATE_KEY_PATH!),
);
 
export async function mintToken() {
  const method = 'POST';
  const url = '/api/v1/auth/token';
  const date = new Date().toISOString();
  const nonce = crypto.randomUUID();
 
  // 🔴 This request has NO BODY → the `Digest:` line is NOT added to the
  // canonical string at all. Adding it empty breaks the signature. On requests
  // with a body it goes in as the fifth line.
  const canonical = [
    method.toUpperCase(),
    url,
    `X-Date:${date}`,
    `X-Nonce:${nonce}`,
  ].join('\n');
 
  const signature = crypto
    .sign(null, Buffer.from(canonical), privateKey)
    .toString('base64');
 
  const res = await fetch(BASE + url, {
    method,
    headers: {
      'X-Key-Id': KID,
      'X-Date': date,
      'X-Nonce': nonce,
      'X-Signature': signature,
    },
  });
 
  if (!res.ok) {
    const err = await res.json();
    throw new Error(`${err.code}: ${err.message}`);
  }
 
  return res.json(); // { access_token, expires_in, expires_at, session_id, ... }
}
🚫

Ed25519 takes no hash algorithm. crypto.sign(null, ...) — the first argument must be null. Passing 'sha256' produces a signature that will not verify; you get 401 with no obvious cause.

Cache the token

expires_in is typically 300 seconds. Do not mint a token per call:

let cached: { token: string; expiresAt: number } | null = null;
 
export async function getToken(): Promise<string> {
  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;
}

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

Make your first call

A fraud decision is a good place to start — it moves no money and returns immediately:

const token = await getToken();
 
const res = await fetch(
  'https://api-gateway.lunixi.com/api/v1/fraud/decisions/evaluate',
  {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${token}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      subjectType: 'card_payment',
      payment: { amount: 250000, currency: 'TRY', is3D: false },
      customer: { id: 'cust_84213' },
    }),
  },
);
 
const decision = await res.json();
 
if (decision.decision === 'block') {
  throw new Error(`Blocked: ${decision.reasonCodes.join(', ')}`);
}
if (decision.decision === 'force_3d') {
  // Step the transaction up to 3DS
}

Three things to know

⚠️

1 — Amounts are minor units. 250000 = ₺2,500.00. Send a decimal and the amount is off by a factor of 100.

⚠️

2 — decision is lower-case. The values are allow · force_3d · review · block. Do not compare against upper-case.

🚫

3 — With no rules published you do not get allow. An unconfigured merchant gets the default decision (typically review) with code fraud.decision.unconfigured. "I haven't written rules" ≠ "there is no risk". → Flows and Resolution

If your first call returns 401

SymptomCause
INVALID_DATEX-Date is more than ±5 minutes from server time — check your server clock
Signature does not verifyYou may have included the Digest: line on a bodyless request — it must be omitted. Line order and header spelling must also match exactly
Still fails on a request with a bodyThe Digest header must carry the SHA-256= prefix: SHA-256=<base64>
403IP not allow-listed, or merchant suspended

Next