Error Handling
Error responses use one envelope. It does not matter where the error came from — authentication, authorization, a business rule, a service — the fields are the same.
{
"status": "failure",
"code": "payment.card.declined",
"message": "The card was declined",
"statusCode": 409,
"timestamp": "2026-08-17T10:32:23.816Z",
"service": "api-gateway",
"systemTime": 1786962743816,
"requestId": "req_7f3d9c1a4b2e",
"docsUrl": "https://docs.lunixi.com/getting-started/errors#INVALID_SIGNATURE"
}Branch on code, not on message.
message is for humans: it is translated and it will change over time. code is part of the contract and is stable. An integration that writes if statements against message text breaks silently the day a new language or a reworded string lands — and that is no longer hypothetical; translation is live.
Fields
| Field | Type | Note |
|---|---|---|
status | string | Always "failure" on an error |
code | string | Dotted granular code (payment.card.declined) — branch on this |
message | string | Human-readable explanation; translatable |
statusCode | number | HTTP status, repeated in the body |
timestamp | string | ISO 8601 |
service | string | The service that produced the error |
systemTime | number | epoch milliseconds |
requestId | string | Correlation key — quote this value in a support request |
details | object | Optional — present only when there is something to add |
docsUrl | string | Optional — returned only for documented errors |
docsUrl does not come on every error. Only errors you can fix yourself carry a documentation link. On server-side failures (INTERNAL_ERROR, MICROSERVICE_ERROR, gateway.error …) the field is absent entirely — not an empty string, not null. There is nothing you could do about them, so a link would be noise.
Write your code assuming the field may not be there.
The envelope is flat at the root. It is not wrapped in an error object; do not read response.error.code.
Validation errors
When field validation fails, details breaks it down per field:
{
"status": "failure",
"code": "validation.error",
"message": "Validation failed",
"statusCode": 400,
"requestId": "req_7f3d9c1a4b2e",
"details": {
"errors": [
{ "field": "amount", "errors": ["amount must be a minor-unit integer"] }
]
}
}Correlating with requestId
Every response — successful or not — carries an x-request-id header. The same value appears in the body's requestId field and in our microservice logs.
Quote this value when you open a support request. One key lets us trace the request end to end.
You can send your own id
If you send x-request-id (or x-correlation-id) on the request, it is respected and the same value comes back on the response. That lets you line up your tracing system with ours on a shared key.
Resolution order: x-request-id → x-correlation-id → the gateway generates one.
The accepted format is narrow: only A-Z a-z 0-9 . _ : -, and at most 128 characters.
Send anything else and the request is not rejected — the value is silently ignored and the gateway generates its own id. So if you are wondering why the id you sent did not come back, it is almost always the character set or the length.
Choosing a language
The message field and the docsUrl link come back in the language you ask for.
x-lang: trWithout x-lang, Accept-Language is used; without that, the installation's default language applies.
| Language | message | docsUrl |
|---|---|---|
en | English | https://docs.lunixi.com/getting-started/errors#CODE |
tr | Turkish | https://docs.lunixi.com/tr/getting-started/errors#CODE |
An unsupported language falls back to the default. For a code that is not in the dictionary the original message is shown — never a bare code.
HTTP status codes
| Status | Meaning |
|---|---|
400 | Bad request — missing or malformed field |
401 | Not authenticated — token missing, invalid or expired |
403 | Not authorized — insufficient scope, IP not allow-listed, or merchant suspended |
404 | Resource not found |
409 | Business-rule conflict — e.g. a card decline, a duplicate |
422 | Valid JSON but fails a business rule |
429 | Rate limit exceeded |
500 | Server error |
503 | Service temporarily unavailable |
Which errors to retry
Retry: 500 · 502 · 503 · 504 — with exponential backoff.
Do not retry: 400 · 401 · 403 · 409 · 422 — the outcome will not change while the request stays the same. Fix the request first.
When retrying a create-payment call, send the same Idempotency-Key; otherwise you open a second intent. → API Overview
Code dictionary
Each code below has its own heading or table row. When the response carries a docsUrl, the link lands directly on it.
VALIDATION_ERROR
One of the fields you sent is invalid. The reason is per field in details.errors[] — look at the field value there.
UNAUTHENTICATED
Not authenticated. Either there is no Authorization: Bearer … header, or the token is invalid or expired. A token typically lives 5 minutes — cache it and refresh based on expires_at. → Authentication
PERMISSION_DENIED
The token is valid but does not carry the scope this operation needs. Check the scopes granted to your key.
auth.permission.forbidden
The same condition as PERMISSION_DENIED, returned in granular-code form.
INVALID_SIGNATURE
The signature did not verify. The three most common causes:
- You included the
Digest:line in the canonical string on a bodyless request — it must be omitted. The canonical string for the token call is four lines. - Line order or header spelling differs.
- The first argument to
crypto.signis notnull— Ed25519 takes no hash algorithm.
NONCE_REPLAY
This X-Nonce has been used before. A nonce is single use; generate a fresh one per request (a UUID works).
IP_NOT_ALLOWED
The request came from an IP that is not on the allow-list for your key. If your server's egress IP changed (a new deploy, a NAT change), update the list.
MERCHANT_SUSPENDED
The account is suspended. This is not something a change to the request can fix — contact support.
AUTH_REFRESH_REUSED
A refresh token was used a second time and the session was terminated for security. This usually happens when two processes share the same token — centralise token refresh in one place.
PRODUCT_NOT_ENABLED
Your identity is valid and your permission is in place — but the endpoint you called belongs to a product you are not entitled to. Product is a separate axis from permission: holding the permission to perform an action does not mean the product is enabled on your account.
Two conditions must both hold; the panel shows you which one is missing:
| Condition | Where | How to enable |
|---|---|---|
| An active subscription to the product | Subscription screen | Subscribe to the product |
| The product listed on the API key | API Keys screen | Edit the key and add the product |
If you have the subscription and still get this error, the second condition is almost always the missing one: an API key is a deliberate narrowing, and subscribing does not widen your existing keys automatically.
A change made in the panel is not reflected instantly: the entitlement snapshot is embedded in your access token and refreshes when the token does. If nothing is pending on our side, request a new token.
PRODUCT_ENTITLEMENT_UNAVAILABLE
Do not confuse this with PRODUCT_NOT_ENABLED. This code does not say "you lack the product"; it says "we cannot verify your product entitlement right now". The HTTP status is 503 and the condition is temporary.
The entitlement gate is fail-closed: we do not let a request through on an entitlement we could not verify. What you need is not a change to your account — it is a retry, with exponential backoff.
payment.amount.field_removed
Your payment request sent a removed amount field (price or paidPrice). The payment endpoints have a single amount field, amount: an integer in minor units, at least 1. The removed fields you sent are returned in details.fields, and any other field errors in details.errors[].
amountis the basket total: the price of the goods and services the buyer pays for, excluding the installment fee. If you sendbasketItems, the items'pricemust add up toamount. The itempricefield is the line amount and stays as it is.- You do not send the installment fee. For installments the system computes it from the binding installment quote and adds it to the charged amount.
→ Checkout Form · Card — 2D · Installments & BIN
payment.method.disabled
The payment method you chose is disabled in your business's form settings in the panel. A method disabled in the panel is disabled on every channel: it is not shown on the Checkout Form, it is not in the GET /payments/methods list, and it cannot be started with POST /payments/direct/payment. The HTTP status is 422; the payment is rejected before it starts and no money moves.
- To use the method, enable it under Payment Form Settings in the panel.
- Read which methods are enabled with
GET /payments/methods.
→ Payment · Checkout Form — Payment
payment.method.unknown
The method code you sent is not recognized. The HTTP status is 400. Method codes are written in upper case (CARD, WALLET, MOBILE_BILLING …); the request is never turned into another method or into a card payment.
- In a
POST /payments/direct/paymentrequest, send a valid code inpaymentMethod. - In the
GET /payments/methods?paymentMethod=filter, an unknown, lower-case, empty or repeated value gets this code. The response hasdetails.fieldset topaymentMethod; every recognized code is in thedetails.allowedlist.
payment.provider.required
For this method the buyer chooses the provider (providerSelectionMode: BUYER) and more than one provider is connected to your account, but the request has no providerCode. The HTTP status is 400; the payment is rejected before it starts.
- The codes of the connected providers are in
details.boundProviderCodes. - Show the providers to the buyer and send the code of the one they choose as
providerCodein the method block (e.g.wallet.providerCode).
→ Listing Methods › Provider selection · Pay with Wallet
payment.provider.none_eligible
For this method the platform chooses the provider (providerSelectionMode: PLATFORM), the request has no providerCode, and none of the providers connected to your account can take this payment. The HTTP status is 422. The payment is not started and nothing is charged.
Each provider's result is in details.candidates[]:
| Field | Description |
|---|---|
providerCode | The provider's code |
outcome | INELIGIBLE: the provider cannot take this payment. UNAVAILABLE: no eligibility answer could be obtained from the provider. NOT_CHECKED: the provider was not tried because the selection time ran out |
reason | Reason code or null |
- If any result is
UNAVAILABLEorNOT_CHECKED, you can retry the same request later. - You can also choose the provider yourself with
providerCodein the method block. - If every result is
INELIGIBLE, offer the buyer another method.
→ Mobile Billing › No provider is eligible · Listing Methods › Provider selection
payment.provider.disabled_in_form
The selected provider is turned off only for the form in the business's Form Settings. The HTTP status is 422; the payment is rejected before it starts and no money moves. This code is returned only on the Checkout Form path.
- The Checkout Form offers the buyer another provider or method shown in the form.
- To use the provider in the form, turn it on in the method's Payment options list on Payments → Form Settings in the panel.
- A provider turned off for the form stays in the
GET /payments/methodslist and can be used withPOST /payments/direct/payment.
→ Checkout Form — Payment › Order and providers shown in the form
payment.method.selection_attempts_exhausted
The non-card method selection limit (3) for this payment has been reached. The HTTP status is 409; no selection is written, no provider is called and no money moves.
- The limit is per payment (order); new attempts on the same order share the same counter.
- The buyer can pay the same order by card.
→ Checkout Form — Payment › Provider choice
payment.checkout.order_locked
A split payment is in progress for the order; until it finishes, no single-card, non-card or wallet payment can be started for the same order. The HTTP status is 409; the payment record is not touched.
- Wait for the split payment to finish. If it fails, its time runs out or the buyer gives up, the order stays open and can be paid with another method.
split_pay.checkout.order_not_open
Another payment has started for the order or the order is closed; a split payment cannot be started. The HTTP status is 409; no split payment is written and no card is charged. The reason is in details.reason:
details.reason | Meaning |
|---|---|
ORDER_NOT_OPEN | The order is not open: a single-method attempt has started or the order is closed |
METHOD_IN_PROGRESS | A non-card method was selected for the order and is in progress |
WALLET_PAYMENT_IN_PROGRESS | The order has a wallet payment whose approval was sent and whose result is not final |
METHOD_PINNED | The order was pinned to a non-card method on the intent |
ORDER_LOCKED | Another split payment is starting or in progress for the order |
Split payment codes
Codes of the split payment endpoints (POST /payments/split, POST /payments/split/{splitPaymentId}/refund and the Checkout Form's split payment endpoints). The details are always in details.
Validation
The split payment is rejected before it starts; no part is charged. The HTTP status is 422. details.legIndex or details.index gives the position of the offending part (starting at 0).
| Code | Meaning | What to do |
|---|---|---|
split_pay.validation.amount_mismatch | The parts do not add up to the order amount | Make the total details.totalAmount |
split_pay.validation.leg_count_out_of_range | The number of parts is out of range | Send between details.min and details.max parts |
split_pay.validation.card_leg_count_out_of_range | The number of cards exceeds the limit | Use at most details.max cards. While Pay with Multiple Cards is off the limit is 1 |
split_pay.validation.invalid_leg_amount | A part amount is not a positive integer | Send the amount as an integer in minor units |
split_pay.validation.currency_mismatch | A part's currency does not match the order, or the chosen provider does not support it | Send every part in the order's currency, with a provider that supports it |
split_pay.validation.missing_card_token | A card part has no card data, or a non-card part carries card data | Add cardToken to the card part; remove card fields from the non-card part |
split_pay.validation.methods_disabled | Split payments with non-card methods are off | Turn on Split Payment with Non-Card Methods in the panel, or split across cards only |
split_pay.validation.method_not_allowed | The method is not enabled for split payments | Enable the method for split payments in Form Settings or choose another method |
split_pay.validation.provider_not_available | The provider is not available for this method, or no provider code was sent | Send the code of a provider in the GET /payments/methods list |
split_pay.validation.provider_not_split_eligible | The provider cannot be used in a split payment | Choose a provider with splitEligible: true, or another method |
split_pay.validation.duplicate_provider | The same provider is in more than one part | Use a provider in one part only |
split_pay.validation.multiple_irreversible_legs | There is more than one non-reversible part | Send at most one non-reversible part |
split_pay.validation.leg_below_minimum | A part is below the minimum amount per part | Raise the part to details.minLegAmount or more |
split_pay.validation.leg_outside_provider_limits | A part is outside the provider's amount range | Bring the part between details.minAmount and details.maxAmount |
split_pay.validation.invalid_installment | A part's installment count is invalid | Choose installments between 1 and details.max. If details.reason is CREDIT_APPROVAL_PHASE, cards cannot have installments in a payment with a credit approval; if it is NON_CARD_LEG, a non-card part cannot carry installments |
split_pay.validation.installment_quote_rejected | The card part's installment quote cannot be used: it expired or does not match the part's amount or installments | Refresh the quote with the part's amount. The reason is in details.reason, the installment in details.installment |
split_pay.insurance.requires_card_leg | Insurance is selected but the split payment has no card part. The insurance premium is collected from a card part; in a split payment with insurance, card parts are charged in a single payment | Add at least one card part or continue without insurance (details.canContinueWithoutInsurance). The insurance selection is not used up |
Steps, the continuation link and the continuation page
| Code | HTTP | Meaning | What to do |
|---|---|---|---|
split_pay.step.not_allowed | 409 | This step cannot be performed on the split payment right now; nothing changes and no money moves | Read the status and follow the expected step. The reason is in details.reason (below) |
split_pay.step.busy | 409 | Another request is processing the same split payment | Retry in a few seconds |
split_pay.continuation.unavailable | 410 | The continuation link can no longer be used. details.reason: INVALID (code not recognized), EXPIRED (time ran out), CLOSED (the payment concluded or no longer awaits a buyer step) | The buyer can start a new payment |
split_pay.resume.unavailable | 410 | The continuation page link can no longer be used. details.reason: INVALID (link not recognized), EXPIRED (time ran out) | The buyer can start a new payment. The payment's result reaches you by webhook |
split_pay.resume.already_opened | 409 | The continuation page was opened in another window; the link works in a single window | The buyer continues the payment in the window they opened first |
split_pay.resume.proof_invalid | 401 | The continuation page session could not be verified | The buyer continues the payment in the window where the continuation page was first opened |
split_pay.resume.not_configured | 503 | Split payment cannot be started right now. The payment was not started and no money moved | The buyer can pay with a single method or try again shortly |
split_pay.three_ds.restart_exhausted | 409 | Because the bank did not allow a window, 3D Secure for this card part has already been restarted once on a separate page and the separate page was opened; it is not restarted a second time. If the separate page has not been opened yet, the same request gets the same response, not an error | The buyer completes verification on the separate page that is already open. If verification is not completed, the payment concludes under the abandoned 3D Secure rule |
split_pay.three_ds.restart_expired | 410 | The 3D Secure session for this card part is closed or has expired; no restart on a separate page is made. details.reason: THREE_DS_CLOSED (session closed), THREE_DS_SESSION_EXPIRED (session expired) | The buyer is asked to enter the card again |
details.reason for split_pay.step.not_allowed:
| Value | Meaning |
|---|---|
SPLIT_NOT_WAITING | No split payment is in progress for the order, or it is not waiting for this step |
NOT_CURRENT_LEG | The requested part is not the part whose turn it is |
LEG_NOT_STARTABLE | The part cannot be started in its current state |
PROVIDER_MISMATCH, METHOD_MISMATCH | The provider or method sent differs from the part's |
SPLIT_EXPIRED | The total time ran out |
REPLACEMENT_WINDOW_CLOSED | The time allowed for a new card ran out |
CARD_LEG_REQUIRED | This step expects a card part |
CARD_SOURCE_UNSUPPORTED | The card sent cannot be used in this step |
Refunds
| Code | HTTP | Meaning | What to do |
|---|---|---|---|
split_pay.refund.amount_exceeded | 400 | The refund amount exceeds the refundable total. details.refundableAmount and details.nonRefundableAmount are returned | Limit the amount to details.refundableAmount |
split_pay.refund.amount_not_distributable | 422 | The amount is within the refundable total but cannot be split across the parts; a non-card part is refunded only for its full remaining amount | Choose an amount that covers a non-card part in full or fits the card parts |
split_pay.refund.not_allowed | 409 | A refund cannot be made right now. details.reason: SPLIT_NOT_COMPLETED (the split payment did not complete), SPLIT_IN_PROGRESS (the split payment is in progress), COMPENSATION_REFUND_PENDING (the part's compensation refund is in progress) | Wait for the split payment or the compensation refund to finish |
split_pay.refund.incomplete | — | Some parts could not be refunded; the per-part result is in the response. It is the code of the failed response on the main payment refund endpoint, and is returned with success: false on the split payment refund endpoint | Retry with the same Idempotency-Key; a part already refunded is not refunded again |
Other common codes
These carry no docsUrl but come up often:
| Code | What to do |
|---|---|
INVALID_DATE | On a signed request, X-Date is outside the ±5 minute window — check your server clock |
DIGEST_MISSING | There is a body but no Digest header |
INVALID_DIGEST | The body digest does not match Digest — the header needs the SHA-256= prefix |
INVALID_CURSOR | The pagination cursor is malformed — do not construct one; send back pageInfo.nextCursor verbatim. → Pagination |
fraud.decision.unconfigured | Not an error — the default decision for an unconfigured merchant. → Flows |
fraud.quota.exceeded | Your fraud quota is exhausted — wait out Retry-After and retry. → Errors and Retries |
FRAUD_EVENT_INVALID | An event sent to event ingest failed validation. → Errors and Retries |
payment.promissory_note.not_found | 404 — There are no promissory notes for the payment: it was not paid with promissory notes or belongs to another account. Check the paymentId. → Promissory Notes |
payment.promissory_note.document_unavailable | 503 — The note document cannot be fetched from the provider right now; retry after a while. → Promissory Notes |
payment.promissory_note.not_cancellable, payment.promissory_note.provider_unavailable, payment.promissory_note.cancel_rejected | Not error responses; they are the per-note cancelResults[].errorCode values in the note cancel response. → Promissory Notes — Cancelling notes |
All fraud endpoint error codes, quota headers and retry rules: Fraud — Errors and Retries.
Privacy
Your request bodies are not logged. Server-side we record only the path, HTTP status, duration, requestId, merchantId and error code; the query string is truncated before it reaches the log.
Related
- Authentication — signed-request errors
- API Overview — the envelope and idempotency
- Webhooks — delivery failures are a separate mechanism