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/tokenThis request takes no Bearer token; it is signed.
| Header | Value |
|---|---|
X-Key-Id | The id (kid) of your registered Ed25519 public key |
X-Date | ISO 8601 timestamp — must be within ±5 minutes of server time |
X-Nonce | A value unique per request (UUID) — single use |
Digest | SHA-256 digest of the raw body (Base64) — required when there is a body |
X-Signature | Base64 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 bodyMETHOD 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
| Code | Meaning |
|---|---|
INVALID_DATE | X-Date is outside the 5-minute window |
DIGEST_MISSING | There is a body but no Digest header |
INVALID_DIGEST | The body digest does not match the Digest header |
401 | Invalid signature or expired timestamp |
403 | IP 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"
}| Field | Meaning |
|---|---|
access_token | Opaque token — prefixed at_{environment}_. Nothing inside it is meant to be decoded; do not parse it |
expires_in | Lifetime in seconds (typically 300 = 5 minutes) |
refresh_expires_in | Refresh window (typically 43200 = 12 hours) |
session_id | Session 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 signatureThe 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.pemYour application mints the token at runtime and holds it in memory; it is not something to write into an environment variable.
Next
- Quickstart — make your first call
- Payments Authentication — checkout token, publishable key and idempotency
- Error Handling — the error envelope and codes