Getting Started
Error Handling

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

FieldTypeNote
statusstringAlways "failure" on an error
codestringDotted granular code (payment.card.declined) — branch on this
messagestringHuman-readable explanation; translatable
statusCodenumberHTTP status, repeated in the body
timestampstringISO 8601
servicestringThe service that produced the error
systemTimenumberepoch milliseconds
requestIdstringCorrelation key — quote this value in a support request
detailsobjectOptional — present only when there is something to add
docsUrlstringOptional — 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: tr

Without x-lang, Accept-Language is used; without that, the installation's default language applies.

LanguagemessagedocsUrl
enEnglishhttps://docs.lunixi.com/getting-started/errors#CODE
trTurkishhttps://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

StatusMeaning
400Bad request — missing or malformed field
401Not authenticated — token missing, invalid or expired
403Not authorized — insufficient scope, IP not allow-listed, or merchant suspended
404Resource not found
409Business-rule conflict — e.g. a card decline, a duplicate
422Valid JSON but fails a business rule
429Rate limit exceeded
500Server error
503Service 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:

  1. 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.
  2. Line order or header spelling differs.
  3. The first argument to crypto.sign is not null — Ed25519 takes no hash algorithm.

→ The canonical string

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:

ConditionWhereHow to enable
An active subscription to the productSubscription screenSubscribe to the product
The product listed on the API keyAPI Keys screenEdit 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[].

  • amount is the basket total: the price of the goods and services the buyer pays for, excluding the installment fee. If you send basketItems, the items' price must add up to amount. The item price field 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/payment request, send a valid code in paymentMethod.
  • In the GET /payments/methods?paymentMethod= filter, an unknown, lower-case, empty or repeated value gets this code. The response has details.field set to paymentMethod; every recognized code is in the details.allowed list.

→ Listing Methods · Payment

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 providerCode in 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[]:

FieldDescription
providerCodeThe provider's code
outcomeINELIGIBLE: 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
reasonReason code or null
  • If any result is UNAVAILABLE or NOT_CHECKED, you can retry the same request later.
  • You can also choose the provider yourself with providerCode in 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/methods list and can be used with POST /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 Payments › Order lock

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.reasonMeaning
ORDER_NOT_OPENThe order is not open: a single-method attempt has started or the order is closed
METHOD_IN_PROGRESSA non-card method was selected for the order and is in progress
WALLET_PAYMENT_IN_PROGRESSThe order has a wallet payment whose approval was sent and whose result is not final
METHOD_PINNEDThe order was pinned to a non-card method on the intent
ORDER_LOCKEDAnother split payment is starting or in progress for the order

→ Split Payments › Order lock

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).

CodeMeaningWhat to do
split_pay.validation.amount_mismatchThe parts do not add up to the order amountMake the total details.totalAmount
split_pay.validation.leg_count_out_of_rangeThe number of parts is out of rangeSend between details.min and details.max parts
split_pay.validation.card_leg_count_out_of_rangeThe number of cards exceeds the limitUse at most details.max cards. While Pay with Multiple Cards is off the limit is 1
split_pay.validation.invalid_leg_amountA part amount is not a positive integerSend the amount as an integer in minor units
split_pay.validation.currency_mismatchA part's currency does not match the order, or the chosen provider does not support itSend every part in the order's currency, with a provider that supports it
split_pay.validation.missing_card_tokenA card part has no card data, or a non-card part carries card dataAdd cardToken to the card part; remove card fields from the non-card part
split_pay.validation.methods_disabledSplit payments with non-card methods are offTurn on Split Payment with Non-Card Methods in the panel, or split across cards only
split_pay.validation.method_not_allowedThe method is not enabled for split paymentsEnable the method for split payments in Form Settings or choose another method
split_pay.validation.provider_not_availableThe provider is not available for this method, or no provider code was sentSend the code of a provider in the GET /payments/methods list
split_pay.validation.provider_not_split_eligibleThe provider cannot be used in a split paymentChoose a provider with splitEligible: true, or another method
split_pay.validation.duplicate_providerThe same provider is in more than one partUse a provider in one part only
split_pay.validation.multiple_irreversible_legsThere is more than one non-reversible partSend at most one non-reversible part
split_pay.validation.leg_below_minimumA part is below the minimum amount per partRaise the part to details.minLegAmount or more
split_pay.validation.leg_outside_provider_limitsA part is outside the provider's amount rangeBring the part between details.minAmount and details.maxAmount
split_pay.validation.invalid_installmentA part's installment count is invalidChoose 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_rejectedThe card part's installment quote cannot be used: it expired or does not match the part's amount or installmentsRefresh the quote with the part's amount. The reason is in details.reason, the installment in details.installment
split_pay.insurance.requires_card_legInsurance 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 paymentAdd 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

CodeHTTPMeaningWhat to do
split_pay.step.not_allowed409This step cannot be performed on the split payment right now; nothing changes and no money movesRead the status and follow the expected step. The reason is in details.reason (below)
split_pay.step.busy409Another request is processing the same split paymentRetry in a few seconds
split_pay.continuation.unavailable410The 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.unavailable410The 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_opened409The continuation page was opened in another window; the link works in a single windowThe buyer continues the payment in the window they opened first
split_pay.resume.proof_invalid401The continuation page session could not be verifiedThe buyer continues the payment in the window where the continuation page was first opened
split_pay.resume.not_configured503Split payment cannot be started right now. The payment was not started and no money movedThe buyer can pay with a single method or try again shortly
split_pay.three_ds.restart_exhausted409Because 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 errorThe 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_expired410The 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:

ValueMeaning
SPLIT_NOT_WAITINGNo split payment is in progress for the order, or it is not waiting for this step
NOT_CURRENT_LEGThe requested part is not the part whose turn it is
LEG_NOT_STARTABLEThe part cannot be started in its current state
PROVIDER_MISMATCH, METHOD_MISMATCHThe provider or method sent differs from the part's
SPLIT_EXPIREDThe total time ran out
REPLACEMENT_WINDOW_CLOSEDThe time allowed for a new card ran out
CARD_LEG_REQUIREDThis step expects a card part
CARD_SOURCE_UNSUPPORTEDThe card sent cannot be used in this step

Refunds

CodeHTTPMeaningWhat to do
split_pay.refund.amount_exceeded400The refund amount exceeds the refundable total. details.refundableAmount and details.nonRefundableAmount are returnedLimit the amount to details.refundableAmount
split_pay.refund.amount_not_distributable422The amount is within the refundable total but cannot be split across the parts; a non-card part is refunded only for its full remaining amountChoose an amount that covers a non-card part in full or fits the card parts
split_pay.refund.not_allowed409A 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 endpointRetry with the same Idempotency-Key; a part already refunded is not refunded again

→ Split Payments › Errors

Other common codes

These carry no docsUrl but come up often:

CodeWhat to do
INVALID_DATEOn a signed request, X-Date is outside the ±5 minute window — check your server clock
DIGEST_MISSINGThere is a body but no Digest header
INVALID_DIGESTThe body digest does not match Digest — the header needs the SHA-256= prefix
INVALID_CURSORThe pagination cursor is malformed — do not construct one; send back pageInfo.nextCursor verbatim. → Pagination
fraud.decision.unconfiguredNot an error — the default decision for an unconfigured merchant. → Flows
fraud.quota.exceededYour fraud quota is exhausted — wait out Retry-After and retry. → Errors and Retries
FRAUD_EVENT_INVALIDAn event sent to event ingest failed validation. → Errors and Retries
payment.promissory_note.not_found404 — 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_unavailable503 — 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_rejectedNot 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