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.
| Header | Value | Required |
|---|---|---|
X-Key-Id | Your key id (kid) — mk_test_sk_… for test, mk_live_sk_… for live | Yes |
X-Date | ISO 8601 timestamp — within ±5 minutes of server time | Yes |
X-Nonce | A unique value per request (UUID or random hex) — single use | Yes |
Digest | SHA-256=<base64> hash of the body | On requests with a body |
X-Signature | Base64 Ed25519 signature over the canonical string below | Yes |
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), theDigestline is omitted entirely. - Compute the digest from the exact body you send, and send the body as compact JSON (like
JSON.stringifyoutput). The server verifies the digest against the re-serialized parsed body; a pretty-printed body receives401 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.
| Scope | Endpoints |
|---|---|
payment-link:create | POST /payments/links |
payment-link:view | GET /payments/links, GET /payments/links/:id, GET /:id/qr, GET /:id/attempts, GET /:id/payments |
payment-link:manage | PATCH /payments/links/:id, POST /:id/pause, POST /:id/resume, POST /:id/deactivate |
payment-link:send | POST /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
200andreplayed: 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.
| Field | Type | Required | Description |
|---|---|---|---|
environment | string | No | TEST | LIVE. If sent, must match your key's environment. |
usage | string | Yes | SINGLE_USE | MULTI_USE. Cannot be changed. |
amountMode | string | Yes | FIXED | OPEN | ITEMIZED. Cannot be changed. |
currency | string (ISO 4217) | Yes | e.g. "TRY". Must be supported by your account's payment providers in this environment; otherwise 422 payment_link.currency.unsupported. Cannot be changed. |
amountMinor | integer | Yes for FIXED | The link amount, > 0. |
minAmountMinor | integer | Yes for OPEN | The minimum amount the buyer can enter, > 0. |
maxAmountMinor | integer | No | OPEN: the maximum amount, ≥ minAmountMinor. |
suggestedAmountsMinor | integer[] | No | OPEN: up to 6 suggested amounts, ascending and within [min, max]. |
quantityEnabled | boolean | No | The buyer picks a quantity. FIXED + MULTI_USE only. |
minQuantity, maxQuantity | integer | Yes with quantityEnabled | 1 ≤ minQuantity ≤ maxQuantity ≤ 999. |
items | object[] | Yes for ITEMIZED | 1–50 items — see below. |
capacity | integer | No | MULTI_USE only: the total number of units that can be sold. Empty = unlimited. Sending it on SINGLE_USE returns 422. |
title | string | Yes | 1–120 characters, plain text. Shown in the page title and the share preview. |
description | string | No | Up to 2000 characters, plain text — HTML and Markdown are not rendered. |
successMessage | string | No | Text shown after a successful payment, up to 500 characters. |
successRedirectUrl | string | No | Where the buyer is redirected after a successful payment. https:// only, up to 2048 characters. |
taxNote | string | No | A note next to the amount, e.g. "KDV dahil", up to 80 characters. |
locale | string | No | Page language: tr | en. Default tr. |
startsAt | string (ISO 8601) | No | A payment cannot be started before this time. Empty = immediately. |
expiresAt | string (ISO 8601) | No | The link becomes EXPIRED at this time. Must be in the future and after startsAt. Empty = no expiry. |
eventAt | string (ISO 8601) | No | An event date; display only. |
buttonLabelKey | string | No | The pay button label: PAY | DONATE | BUY | BOOK. Empty = your organization's default. |
requireTermsAcceptance | boolean | No | If true, the buyer cannot pay without accepting your organization's legal document links. |
buyerFields | object | No | Which standard details to ask the buyer for — see below. |
customFields | object[] | No | Up to 20 custom fields — see below. |
recipient | object | No | The recipient of a personal link — see below. |
prefill | object | No | Buyer details to write to the payment record — see below. |
verification | object | No | Access verification: { "required": true, "channel": "EMAIL" }. If required: true, recipient.email is required. Only EMAIL is supported. |
reminders | object | No | { "enabled": true, "intervalHours": 48, "maxCount": 2 } — intervalHours 24–168, maxCount 1–3. Only on SINGLE_USE with recipient set. |
checkout | object | No | Narrows the payment form — see below. |
reference | string | No | Your reference (e.g. invoice number), up to 128 characters. Not shown to the buyer; searchable when listing. |
tags | string[] | No | Up to 10 tags, each [a-z0-9_-]{1,32}. |
branchCode, salesChannel, campaign, agentCode | string | No | Attribution fields (up to 64 / 32 / 64 / 32 characters). Not shown to the buyer. |
merchantNotifyEmails | string[] | No | Up to 5 addresses that receive a notification email when a payment is taken. |
metadata | object | No | Up 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[]
| Field | Type | Required | Description |
|---|---|---|---|
itemKey | string | No | The 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. |
name | string | Yes | 1–120 characters. |
description | string | No | Up to 500 characters. |
unitPriceMinor | integer | Yes | Unit price, > 0. |
minQuantity | integer | No | 0 = the buyer may skip this item. |
maxQuantity | integer | Yes | 1–999. |
capacity | integer | No | Item stock. Empty = unlimited. |
buyerFields
Each field takes one of HIDDEN | OPTIONAL | REQUIRED.
| Field | Default | Collected |
|---|---|---|
name | REQUIRED | First and last name |
email | REQUIRED | |
phone | OPTIONAL | Phone (E.164) |
address | HIDDEN | Billing address — address, city, country, postcode |
identityNumber | HIDDEN | Turkish national ID number (algorithmic validation) |
customFields[]
| Field | Type | Description |
|---|---|---|
key | string | [a-z][a-z0-9_]{0,39}, unique within the link. The buyer's answer is returned under this key. |
label | string | 1–80 characters. |
fieldType | string | TEXT | NUMBER | EMAIL | PHONE | DATE | SELECT | CHECKBOX | TCKN | VKN |
required | boolean | Whether the field is required. |
options | string[] | SELECT only: 1–20 options, each 1–80 characters. |
maxLength | integer | TEXT only: 1–500 (0 = 200). |
helpText | string | Up 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/sendand 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 }(countryin 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 — exceptidentityNumber.
Both objects are stored encrypted and returned in the clear only in your own API responses.
checkout
| Field | Type | Description |
|---|---|---|
requireThreeDs | boolean | If true, payments on this link use 3D Secure. It only tightens; 3D Secure is already required on OPEN and MULTI_USE links. |
paymentMethods | string[] | 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:
| Field | Description |
|---|---|
id | The link id (UUID) — :id in every other endpoint |
shortCode, url | The 10-character code and the address to share |
environment | TEST | LIVE |
state | ACTIVE | PAUSED | COMPLETED | EXPIRED | DEACTIVATED | TAKEN_DOWN — see Lifecycle |
version | The link's current version; increases on every update |
rowVersion | The 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, deactivatedAt | When the corresponding state change happened |
takedown | Only on TAKEN_DOWN: { reasonCode, takenDownAt }; reasonCode ∈ PHISHING, FRAUD, BRAND_IMPERSONATION, PROHIBITED_GOODS, LEGAL_REQUEST, OTHER |
verificationLocked | Access verification is locked after too many wrong attempts; POST /:id/send unlocks it |
hasActiveAttempt | A payment is in progress right now |
amountLocked | Amount and items cannot be edited (a payment is in progress or a payment has been taken) |
counters | paidCount, collectedAmountMinor, refundedAmountMinor, soldQuantity, reservedQuantity (units held by in-progress payments), attemptCount |
availability | soldOut, remainingQuantity (null without a capacity), inProgress (on a single-use link, another buyer is paying), items[] (itemKey, soldOut, remainingQuantity) |
replayed | true = 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.
| Status | Code | What to do |
|---|---|---|
400 | VALIDATION_ERROR | Undefined field or wrong type; fix the field listed in details.errors |
400 | payment_link.idempotency.key_required / key_invalid | Send an Idempotency-Key of 8–200 characters |
401 | AUTH_HEADERS_MISSING, INVALID_DATE, NONCE_REPLAY, INVALID_KID, DIGEST_MISSING, INVALID_DIGEST, INVALID_SIGNATURE | Check the signature headers and the canonical string — see Authentication |
403 | API_INSUFFICIENT_SCOPE | Add the payment-link:create scope to your key |
403 | IP_NOT_ALLOWED | The request IP is not on the key's IP allow-list; add it in the dashboard |
403 | MERCHANT_SUSPENDED | Your account is not active; contact support |
403 | PRODUCT_NOT_ENABLED | Your PAYMENT_GATEWAY subscription is not active in this environment, or the product is not assigned to your API key — see PRODUCT_NOT_ENABLED |
403 | payment_link.environment.mismatch | Remove the environment field or send your key's environment |
409 | payment_link.idempotency.conflict | The same Idempotency-Key was used with a different body; generate a new key for a new link |
409 | payment_link.merchant.unavailable | Your account cannot take payments right now (account or product access is not active); contact support |
422 | payment_link.validation.invalid | Fix the rules listed in details.errors: [{ field, reason }] |
422 | payment_link.currency.unsupported | Choose a currency your account supports in this environment |
503 | PRODUCT_ENTITLEMENT_UNAVAILABLE | Temporary; 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