Payment Orchestrator
Payment Links
Creating Links

Creating Links

POST /api/v1/payments/links creates a new payment link and returns the https://pay.lunixi.com/<CODE> address you share. This page covers authentication, every request field, the response and errors. You can also create links from the dashboard; this page is for API integrations from your server.

All amounts are integers in minor units (kuruş): "amountMinor": 150000 = ₺1,500.00. This applies to amountMinor, minAmountMinor, maxAmountMinor, suggestedAmountsMinor and items[].unitPriceMinor. Never send decimal amounts.

How it works

Grant scopes to your key

Your API key's Scopes list must include payment-link:create (and any other payment-link:* codes you will use) — see Scopes below.

Sign and send the request

Build the body, sign it with your Ed25519 key and call POST /api/v1/payments/links with an Idempotency-Key.

Share the address

Send the url from the response to your buyer, or download its QR code. The link is ACTIVE as soon as it is created (if you set startsAt, a payment cannot be started before then).

Track the payment by webhook

When the payment is captured you receive payment.captured; match it to your link with paymentLinkId. → Webhooks

Authentication

Payment link endpoints (/api/v1/payments/links/*) accept only requests signed with your API key. Every request carries the headers below; no Authorization header is needed. A Bearer access token or a dashboard session does not stand in for the signature on these endpoints — a request without the signature headers receives 401 AUTH_HEADERS_MISSING.

HeaderValueRequired
X-Key-IdYour key id (kid) — mk_test_sk_… for test, mk_live_sk_… for liveYes
X-DateISO 8601 timestamp — within ±5 minutes of server timeYes
X-NonceA unique value per request (UUID or random hex) — single useYes
DigestSHA-256=<base64> hash of the bodyOn requests with a body
X-SignatureBase64 Ed25519 signature over the canonical string belowYes

The signed text is the following lines joined with \n (the Digest in the example belongs to the body of the sample request below):

POST
/api/v1/payments/links
X-Date:2026-09-25T09:00:00Z
X-Nonce:64ae70b60739b3515d972c0378854a59
Digest:SHA-256=MBzVeFBxTxZxT8gMQ+nBA+64x1CwAEeT+E7KM02BaEg=
⚠️
  • The second line is the full request path including any query string: when signing GET /api/v1/payments/links?limit=25&state=ACTIVE, the second line must be /api/v1/payments/links?limit=25&state=ACTIVE — in the order and encoding you send it.
  • On requests without a body (GET), the Digest line is omitted entirely.
  • Compute the digest from the exact body you send, and send the body as compact JSON (like JSON.stringify output). The server verifies the digest against the re-serialized parsed body; a pretty-printed body receives 401 INVALID_DIGEST.

The Node.js examples in this section use the signedHeaders helper below. Your private key stays on your server; never put it in a browser or a mobile app.

import { createHash, createPrivateKey, sign, randomUUID } from 'node:crypto';
 
export const BASE_URL = 'https://api-gateway.lunixi.com';
const signingKey = createPrivateKey(process.env.LUNIXI_SIGNING_KEY_PEM);
 
// pathWithQuery: the full path including the query string, e.g. '/api/v1/payments/links?limit=25'
// body: the exact JSON string you send; omit it for requests without a body
export function signedHeaders(method, pathWithQuery, body) {
  const date = new Date().toISOString();
  const nonce = randomUUID();
  const headers = { 'X-Key-Id': process.env.LUNIXI_KEY_ID, 'X-Date': date, 'X-Nonce': nonce };
  const lines = [method, pathWithQuery, `X-Date:${date}`, `X-Nonce:${nonce}`];
  if (body !== undefined) {
    const digest = 'SHA-256=' + createHash('sha256').update(body).digest('base64');
    headers['Content-Type'] = 'application/json';
    headers['Digest'] = digest;
    lines.push(`Digest:${digest}`);
  }
  headers['X-Signature'] = sign(null, Buffer.from(lines.join('\n')), signingKey).toString('base64');
  return headers;
}

The cURL examples compute the signature with OpenSSL 3.0 or later (openssl pkeyutl -rawin). Registering and rotating keys: Authentication.

Scopes

After the signature is verified, the key's scope is checked. A missing scope returns 403 API_INSUFFICIENT_SCOPE.

ScopeEndpoints
payment-link:createPOST /payments/links
payment-link:viewGET /payments/links, GET /payments/links/:id, GET /:id/qr, GET /:id/attempts, GET /:id/payments
payment-link:managePATCH /payments/links/:id, POST /:id/pause, POST /:id/resume, POST /:id/deactivate
payment-link:sendPOST /payments/links/:id/send
⚠️

Your existing keys do not get these scopes automatically. payment:* scopes do not cover the payment-link:* codes. In the dashboard, add the payment-link:* codes you need to your API key's Scopes list, or create a new key for this integration.

Environment

A link's environment (TEST or LIVE) is decided by your key's environment. You do not need to send environment in the body; if you do, it must match your key's environment, otherwise 403 payment_link.environment.mismatch is returned. A key only sees and changes links in its own environment.

Request

POST /api/v1/payments/links — signed request, scope payment-link:create.

Idempotency-Key

Required. 8–200 characters of printable ASCII without spaces (^[\x21-\x7E]{8,200}$). Use a value that identifies the link — e.g. plink-INV-2026-0042.

  • Same key + same body → no new link is created; the link created with this key is returned with 200 and replayed: true. You can safely retry after a network timeout.
  • Same key + different body → 409 payment_link.idempotency.conflict.
  • Missing key → 400 payment_link.idempotency.key_required; malformed key → 400 payment_link.idempotency.key_invalid.

Fields

An undefined field or a wrong type returns 400 VALIDATION_ERROR. A business-rule violation (e.g. no amountMinor on a FIXED link) returns 422 payment_link.validation.invalid, and details.errors lists which field was rejected and why.

FieldTypeRequiredDescription
environmentstringNoTEST | LIVE. If sent, must match your key's environment.
usagestringYesSINGLE_USE | MULTI_USE. Cannot be changed.
amountModestringYesFIXED | OPEN | ITEMIZED. Cannot be changed.
currencystring (ISO 4217)Yese.g. "TRY". Must be supported by your account's payment providers in this environment; otherwise 422 payment_link.currency.unsupported. Cannot be changed.
amountMinorintegerYes for FIXEDThe link amount, > 0.
minAmountMinorintegerYes for OPENThe minimum amount the buyer can enter, > 0.
maxAmountMinorintegerNoOPEN: the maximum amount, ≥ minAmountMinor.
suggestedAmountsMinorinteger[]NoOPEN: up to 6 suggested amounts, ascending and within [min, max].
quantityEnabledbooleanNoThe buyer picks a quantity. FIXED + MULTI_USE only.
minQuantity, maxQuantityintegerYes with quantityEnabled1 ≤ minQuantity ≤ maxQuantity ≤ 999.
itemsobject[]Yes for ITEMIZED1–50 items — see below.
capacityintegerNoMULTI_USE only: the total number of units that can be sold. Empty = unlimited. Sending it on SINGLE_USE returns 422.
titlestringYes1–120 characters, plain text. Shown in the page title and the share preview.
descriptionstringNoUp to 2000 characters, plain text — HTML and Markdown are not rendered.
successMessagestringNoText shown after a successful payment, up to 500 characters.
successRedirectUrlstringNoWhere the buyer is redirected after a successful payment. https:// only, up to 2048 characters.
taxNotestringNoA note next to the amount, e.g. "KDV dahil", up to 80 characters.
localestringNoPage language: tr | en. Default tr.
startsAtstring (ISO 8601)NoA payment cannot be started before this time. Empty = immediately.
expiresAtstring (ISO 8601)NoThe link becomes EXPIRED at this time. Must be in the future and after startsAt. Empty = no expiry.
eventAtstring (ISO 8601)NoAn event date; display only.
buttonLabelKeystringNoThe pay button label: PAY | DONATE | BUY | BOOK. Empty = your organization's default.
requireTermsAcceptancebooleanNoIf true, the buyer cannot pay without accepting your organization's legal document links.
buyerFieldsobjectNoWhich standard details to ask the buyer for — see below.
customFieldsobject[]NoUp to 20 custom fields — see below.
recipientobjectNoThe recipient of a personal link — see below.
prefillobjectNoBuyer details to write to the payment record — see below.
verificationobjectNoAccess verification: { "required": true, "channel": "EMAIL" }. If required: true, recipient.email is required. Only EMAIL is supported.
remindersobjectNo{ "enabled": true, "intervalHours": 48, "maxCount": 2 } — intervalHours 24–168, maxCount 1–3. Only on SINGLE_USE with recipient set.
checkoutobjectNoNarrows the payment form — see below.
referencestringNoYour reference (e.g. invoice number), up to 128 characters. Not shown to the buyer; searchable when listing.
tagsstring[]NoUp to 10 tags, each [a-z0-9_-]{1,32}.
branchCode, salesChannel, campaign, agentCodestringNoAttribution fields (up to 64 / 32 / 64 / 32 characters). Not shown to the buyer.
merchantNotifyEmailsstring[]NoUp to 5 addresses that receive a notification email when a payment is taken.
metadataobjectNoUp to 20 keys; key [A-Za-z0-9_.-]{1,40}, value a string of up to 500 characters. Returned in payment.link.* webhooks and in the link's API responses; not shown to the buyer.

items[]

FieldTypeRequiredDescription
itemKeystringNoThe item id, [A-Za-z0-9_-]{1,64}, unique within the link. If empty, the server generates one as it_…. Cannot be changed — the stock counter is tied to it.
namestringYes1–120 characters.
descriptionstringNoUp to 500 characters.
unitPriceMinorintegerYesUnit price, > 0.
minQuantityintegerNo0 = the buyer may skip this item.
maxQuantityintegerYes1–999.
capacityintegerNoItem stock. Empty = unlimited.

buyerFields

Each field takes one of HIDDEN | OPTIONAL | REQUIRED.

FieldDefaultCollected
nameREQUIREDFirst and last name
emailREQUIREDEmail
phoneOPTIONALPhone (E.164)
addressHIDDENBilling address — address, city, country, postcode
identityNumberHIDDENTurkish national ID number (algorithmic validation)

customFields[]

FieldTypeDescription
keystring[a-z][a-z0-9_]{0,39}, unique within the link. The buyer's answer is returned under this key.
labelstring1–80 characters.
fieldTypestringTEXT | NUMBER | EMAIL | PHONE | DATE | SELECT | CHECKBOX | TCKN | VKN
requiredbooleanWhether the field is required.
optionsstring[]SELECT only: 1–20 options, each 1–80 characters.
maxLengthintegerTEXT only: 1–500 (0 = 200).
helpTextstringUp to 200 characters.

Answers are stored as strings: "true" / "false" for CHECKBOX, YYYY-MM-DD for DATE. There is no file-upload field.

recipient and prefill

  • recipient — { name, email, phone } (name up to 120 and email up to 180 characters, phone in E.164). Fill it in when the link is for a specific person: POST /:id/send and reminders go to this address by default, and the access verification code is sent only to this email. The payment page shows it masked only.
  • prefill — { name, surname, email, phone, identityNumber, address, city, country, zipCode } (country in ISO 3166-1 alpha-2). These details are not sent to the buyer's browser; they are written to the payment record's buyer details on the server. Values the buyer types only fill fields that are still empty. If you added access verification to the link, a buyer who passes it sees these details in the form — except identityNumber.

Both objects are stored encrypted and returned in the clear only in your own API responses.

checkout

FieldTypeDescription
requireThreeDsbooleanIf true, payments on this link use 3D Secure. It only tightens; 3D Secure is already required on OPEN and MULTI_USE links.
paymentMethodsstring[]The methods shown on the payment form, e.g. ["card"]. Must be a subset of the form settings in your dashboard; empty = the form settings.

Sample request

# Requires OpenSSL 3.0+. The body must be compact JSON.
KEY_ID="mk_test_sk_..."             # X-Key-Id
KEY_FILE="lunixi-signing.key"       # your Ed25519 private key (PEM)
REQ_PATH="/api/v1/payments/links"
BODY='{"usage":"SINGLE_USE","amountMode":"FIXED","currency":"TRY","amountMinor":150000,"title":"November consulting fee","expiresAt":"2026-10-31T20:59:59.000Z","reference":"INV-2026-0042","recipient":{"name":"Ayşe Yılmaz","email":"[email protected]"},"metadata":{"crmId":"A-77"}}'
 
DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
NONCE=$(openssl rand -hex 16)
DIGEST="SHA-256=$(printf '%s' "$BODY" | openssl dgst -sha256 -binary | base64)"
printf 'POST\n%s\nX-Date:%s\nX-Nonce:%s\nDigest:%s' "$REQ_PATH" "$DATE" "$NONCE" "$DIGEST" > canonical.txt
SIGNATURE=$(openssl pkeyutl -sign -inkey "$KEY_FILE" -rawin -in canonical.txt | base64 | tr -d '\n')
 
curl -X POST "https://api-gateway.lunixi.com$REQ_PATH" \
  -H "Content-Type: application/json" \
  -H "X-Key-Id: $KEY_ID" \
  -H "X-Date: $DATE" \
  -H "X-Nonce: $NONCE" \
  -H "Digest: $DIGEST" \
  -H "X-Signature: $SIGNATURE" \
  -H "Idempotency-Key: plink-INV-2026-0042" \
  --data-raw "$BODY"

Body examples for other link types:

// Donation — the buyer enters the amount, with suggested amount buttons
{ "usage": "MULTI_USE", "amountMode": "OPEN", "currency": "TRY",
  "minAmountMinor": 5000, "suggestedAmountsMinor": [10000, 25000, 50000],
  "title": "Winter campaign donation", "buttonLabelKey": "DONATE" }
 
// Ticket — fixed price, quantity selection, capacity of 200
{ "usage": "MULTI_USE", "amountMode": "FIXED", "currency": "TRY",
  "amountMinor": 45000, "quantityEnabled": true, "minQuantity": 1, "maxQuantity": 4,
  "capacity": 200, "title": "Workshop — 12 November", "eventAt": "2026-11-12T16:00:00.000Z" }
 
// Itemized — size selection, stock per item
{ "usage": "MULTI_USE", "amountMode": "ITEMIZED", "currency": "TRY",
  "title": "Team jersey",
  "items": [
    { "itemKey": "jersey_m", "name": "Jersey — M", "unitPriceMinor": 90000, "maxQuantity": 3, "capacity": 40 },
    { "itemKey": "jersey_l", "name": "Jersey — L", "unitPriceMinor": 90000, "maxQuantity": 3, "capacity": 40 }
  ] }

Response

201 Created — 200 OK with replayed: true on a replay.

{
  "status": "success",
  "code": "SUCCESS",
  "data": {
    "id": "5f0c1d2e-8a4b-4c6d-9e7f-1a2b3c4d5e6f",
    "shortCode": "7KQ2M9XAPT",
    "url": "https://pay.lunixi.com/7KQ2M9XAPT",
    "organizationId": "b1d2c3e4-…",
    "environment": "TEST",
    "state": "ACTIVE",
    "version": 1,
    "rowVersion": 1,
    "createdBy": { "type": "API_KEY", "id": "mk_test_sk_…" },
    "createdAt": "2026-09-25T09:00:00.000Z",
    "updatedAt": "2026-09-25T09:00:00.000Z",
    "completedAt": null,
    "expiredAt": null,
    "deactivatedAt": null,
    "takedown": null,
    "verificationLocked": false,
    "hasActiveAttempt": false,
    "amountLocked": false,
    "counters": {
      "paidCount": 0, "collectedAmountMinor": 0, "refundedAmountMinor": 0,
      "soldQuantity": 0, "reservedQuantity": 0, "attemptCount": 0
    },
    "availability": { "soldOut": false, "remainingQuantity": null, "inProgress": false, "items": [] },
    "usage": "SINGLE_USE",
    "amountMode": "FIXED",
    "currency": "TRY",
    "amountMinor": 150000,
    "title": "November consulting fee",
    "expiresAt": "2026-10-31T20:59:59.000Z",
    "reference": "INV-2026-0042",
    "recipient": { "name": "Ayşe Yılmaz", "email": "[email protected]", "phone": "" },
    "metadata": { "crmId": "A-77" },
    "replayed": false
  },
  "statusCode": 201,
  "requestId": "req_7f3d9c1a4b2e"
}

The response contains every field you sent, flat, together with their defaults (shortened in the sample). Unset timestamps and optional ids are returned as null. The link's own fields:

FieldDescription
idThe link id (UUID) — :id in every other endpoint
shortCode, urlThe 10-character code and the address to share
environmentTEST | LIVE
stateACTIVE | PAUSED | COMPLETED | EXPIRED | DEACTIVATED | TAKEN_DOWN — see Lifecycle
versionThe link's current version; increases on every update
rowVersionThe optimistic-lock counter; sent back as expectedRowVersion on PATCH and state actions
createdBy{ type: "USER" | "API_KEY", id } — the key id for links created through the API
completedAt, expiredAt, deactivatedAtWhen the corresponding state change happened
takedownOnly on TAKEN_DOWN: { reasonCode, takenDownAt }; reasonCode ∈ PHISHING, FRAUD, BRAND_IMPERSONATION, PROHIBITED_GOODS, LEGAL_REQUEST, OTHER
verificationLockedAccess verification is locked after too many wrong attempts; POST /:id/send unlocks it
hasActiveAttemptA payment is in progress right now
amountLockedAmount and items cannot be edited (a payment is in progress or a payment has been taken)
counterspaidCount, collectedAmountMinor, refundedAmountMinor, soldQuantity, reservedQuantity (units held by in-progress payments), attemptCount
availabilitysoldOut, remainingQuantity (null without a capacity), inProgress (on a single-use link, another buyer is paying), items[] (itemKey, soldOut, remainingQuantity)
replayedtrue = a replay with the same Idempotency-Key and body; the stored link was returned

Errors

The error envelope and the rule to branch on code: Error Handling.

StatusCodeWhat to do
400VALIDATION_ERRORUndefined field or wrong type; fix the field listed in details.errors
400payment_link.idempotency.key_required / key_invalidSend an Idempotency-Key of 8–200 characters
401AUTH_HEADERS_MISSING, INVALID_DATE, NONCE_REPLAY, INVALID_KID, DIGEST_MISSING, INVALID_DIGEST, INVALID_SIGNATURECheck the signature headers and the canonical string — see Authentication
403API_INSUFFICIENT_SCOPEAdd the payment-link:create scope to your key
403IP_NOT_ALLOWEDThe request IP is not on the key's IP allow-list; add it in the dashboard
403MERCHANT_SUSPENDEDYour account is not active; contact support
403PRODUCT_NOT_ENABLEDYour PAYMENT_GATEWAY subscription is not active in this environment, or the product is not assigned to your API key — see PRODUCT_NOT_ENABLED
403payment_link.environment.mismatchRemove the environment field or send your key's environment
409payment_link.idempotency.conflictThe same Idempotency-Key was used with a different body; generate a new key for a new link
409payment_link.merchant.unavailableYour account cannot take payments right now (account or product access is not active); contact support
422payment_link.validation.invalidFix the rules listed in details.errors: [{ field, reason }]
422payment_link.currency.unsupportedChoose a currency your account supports in this environment
503PRODUCT_ENTITLEMENT_UNAVAILABLETemporary; retry with the same Idempotency-Key

4xx errors do not resolve by retrying the same request; fix the request first. On 5xx responses (e.g. 503, 504), retry with exponential backoff using the same Idempotency-Key and the same body: if the first attempt created the link, no second link is created and the recorded link is returned with replayed: true.

Webhooks

payment.link.created is published when a link is created. All payment and link events: Webhooks.

Next steps

  • Managing Links — listing, updating, pausing, sending, QR
  • Payment Page — the page the buyer sees, branding and access verification
  • Webhooks — fulfilling orders on payment.captured