Getting Started
Authentication

Authentication

With the Lunixi API you do not put a static API key straight into the Authorization header. Your long-lived secret is your Ed25519 private key, and it never travels over the wire. You sign with it to mint a short-lived access token, and make your calls with that token.

Ed25519 private key  ──sign──▶  POST /api/v1/auth/token  ──▶  at_live_…  (5 min)
                                                                  │
                                           Authorization: Bearer at_live_…

Why this way? A leaked static key is usable forever. Here what can leak is a token whose life is measured in minutes, while the private key never leaves your server. Money-movement endpoints add one more signing layer on top (Step-Up, below).

Step 1 — Mint a token

POST /api/v1/auth/token

This request takes no Bearer token; it is signed.

HeaderValue
X-Key-IdThe id (kid) of your registered Ed25519 public key
X-DateISO 8601 timestamp — must be within ±5 minutes of server time
X-NonceA value unique per request (UUID) — single use
DigestSHA-256 digest of the raw body (Base64) — required when there is a body
X-SignatureBase64 Ed25519 signature over the canonical string

The canonical string

This is the text you sign — lines joined with \n:

METHOD
URL
X-Date:<value>
X-Nonce:<value>
Digest:<value>        ← ONLY when there is a body

METHOD is upper-cased. URL is the request path and query string.

🚫

On a request with no body the Digest: line is NOT part of the canonical string at all. Adding it with an empty value (Digest:) breaks the signature. The token-minting call has no body, so its canonical string is four lines.

⚠️

Order and casing must match exactly. Reorder the lines or spell the header names differently and the signature will not verify — you get 401.

The Digest header format

On requests with a body, the header must carry the SHA-256= prefix:

Digest: SHA-256=<base64 sha256 of the body>

Without the prefix — or with the base64 padding (=) stripped — verification fails.

The signing algorithm

Ed25519 takes no hash algorithm. In Node.js:

crypto.sign(null, Buffer.from(canonical), privateKey).toString('base64')

⚠️ The first argument must be null. Passing 'sha256' produces a signature that the server will not verify.

Error codes

CodeMeaning
INVALID_DATEX-Date is outside the 5-minute window
DIGEST_MISSINGThere is a body but no Digest header
INVALID_DIGESTThe body digest does not match the Digest header
401Invalid signature or expired timestamp
403IP not allow-listed, or merchant suspended

Step 2 — The response

{
  "access_token": "at_live_5f3e2b1a...",
  "token_type": "Bearer",
  "expires_in": 300,
  "expires_at": 1710000000,
  "refresh_expires_in": 43200,
  "refresh_expires_at": 1710043200,
  "session_id": "authsess_3e46a2f3-1f02-42a9-9c4d-ef12b8f6f2d1"
}
FieldMeaning
access_tokenOpaque token — prefixed at_{environment}_. Nothing inside it is meant to be decoded; do not parse it
expires_inLifetime in seconds (typically 300 = 5 minutes)
refresh_expires_inRefresh window (typically 43200 = 12 hours)
session_idSession id — used for support requests and log correlation

The prefix carries the environment: at_live_… is live, at_test_… is test. Do not use the prefix to decide which environment you are in — that is determined by the key you signed with.

Step 3 — Use it on your calls

curl "https://api-gateway.lunixi.com/api/v1/payments?page=1&limit=20" \
  -H "Authorization: Bearer at_live_5f3e2b1a..."

The authenticated party (merchantId) is read from the token, never from the request body — the body cannot spoof a different tenant.

⚠️

A token lasts about five minutes. Do not mint one per call; cache the token until it expires and refresh based on expires_at. Calling POST /auth/token on every request adds needless load and latency.

Step-Up — money-movement endpoints

Endpoints that move money (creating a payment intent, direct charge, capture, void, refund, marketplace payments) require both a valid Bearer token and a separate Ed25519 signature over that request.

Authorization: Bearer at_live_…        ← the token
X-Key-Id / X-Date / X-Nonce / Digest / X-Signature   ← the request's own signature
🚫

The point is this: even if your token is stolen, the attacker cannot move money — without your private signing key they cannot sign the Step-Up request. That is why it is worth keeping the private key separate from wherever you mint tokens.

The signing scheme is identical to Step 1: same canonical string, same headers, same ±5-minute window, same single-use nonce.

Key management

Generate an Ed25519 key pair

The private key stays on your server. You give us only the public key.

Register the public key

Register it from the panel; you get back a kid (key id). That is what you send in X-Key-Id.

Rotation

Generate a new pair, register the public key, update your services to the new kid, then revoke the old one. The moment the old kid is revoked, requests signed with it start returning 401 — do not revoke the old key before the new one is live.

⚠️

Never put the private signing key in a browser, a mobile app, or a public repository. If it leaks, revoke the kid from the panel and generate a new pair.

IP allowlist

You can set an IP allowlist on your API key; when the list is not empty, requests signed with the key are accepted only from those addresses. The list takes single IP addresses (IPv4 or IPv6); address ranges (CIDR) are not accepted, and up to 50 addresses can be added.

Some permissions are granted only to a key with an IP allowlist. bank-connect:transfer, which sends money transfers and exchanges currency from your bank accounts, is one of them: adding it to a key whose list is empty is refused with 400 organization.api_key.scope_requires_ip_allowlist (details.scopes names the permission that needs the list). Requests with this permission have no verification code, so the approval is the key's permission, the request signature and the source IP address. → Bank Accounts › IP allowlist

⚠️

Enter your server's outbound address; a load balancer, NAT or a new server can change it. If the address changes and you do not update the list, requests get 403.

Environment variables

What you store is not a token but the private key and the kid:

# .env
LUNIXI_KEY_ID=kid_...
LUNIXI_PRIVATE_KEY_PATH=/secure/lunixi-ed25519.pem

Your application mints the token at runtime and holds it in memory; it is not something to write into an environment variable.

Next