Payment Orchestrator
3D Secure

Card — 3D Secure

When you send paymentMethod: "CARD" and force3D: true to POST /payments/direct/payment, the card is charged from your own server with bank authentication. Before the card is charged, the buyer confirms the transaction with their bank (SMS code, banking app approval, biometrics). Use it when you want liability shift and when you want to save the card. The platform runs the bank round-trip for you; you start the payment, redirect the buyer and receive the result.

If you use the Checkout Form, this page is not for you; the form charges the card and handles the redirect → 2D Form · 3D Form.

All amounts are minor-unit integers (kuruş): "amount": 10050 means ₺100,50. Never send decimals or floats.

⚠️

PCI DSS required. You receive and send the full card number (PAN) on your server, which puts your systems in PCI DSS scope (SAQ D). Unless you are certified to handle card data, use the Checkout Form.

How it works

Send the signed request

Call POST /payments/direct/payment from your server with paymentMethod: "CARD", force3D: true, a callbackUrl and card. The payment opens in status AWAITING_3D and the response carries a REDIRECT action.

Redirect the buyer

Send the buyer's browser to action.redirect.url (GET /payments/threed/:token). This address is public; the single-use token in the path is the credential. The platform forwards the buyer to the bank's 3D Secure page.

The buyer authenticates at the bank

The bank asks the buyer to authenticate (SMS code, app approval). This step is between the buyer and their bank.

The platform completes the charge

The bank returns the authentication result to the platform. The platform verifies it and charges the card only if authentication succeeded.

Your server receives a signed form POST

The buyer's browser is sent to your callbackUrl with an HTML form POST that carries the result. Use this POST to drive the buyer's UI; do not make the fulfilment decision on it.

Confirm with the webhook

The final state is also delivered as a webhook (payment.captured, payment.failed). Fulfil the order based on the webhook or a GET /payments/:id lookup.

⚠️

A successful redirect is not a successful payment. 3D Secure has two phases: authentication (the buyer proves themselves to the bank) and authorization (the money is charged). A payment can pass authentication and still fail authorization: insufficient funds, issuer decline, limits. Never ship goods because "the buyer came back from the bank"; wait for the payment.captured webhook or a GET /payments/:id check.

Authentication

The endpoint requires Step-Up: your Authorization: Bearer at_… token and an Ed25519 signature over the request. Every request carries these headers:

HeaderValue
AuthorizationBearer at_test_… (test) or Bearer at_live_… (live)
X-Key-IdId of your registered Ed25519 public key
X-DateISO 8601 request time; within ±5 minutes of server time
X-NonceUnique, single-use value per request
DigestSHA-256=<base64> hash of the raw JSON body
X-SignatureBase64 Ed25519 signature over the canonical string
Idempotency-KeyRequired, at most 200 characters; see Idempotency

How to build the signature is described in Authentication. The endpoint requires the Payment Gateway product to be enabled on your account.

Request

POST https://api-gateway.lunixi.com/api/v1/payments/direct/payment
Content-Type: application/json

Card fields

FieldTypeRequiredDescription
paymentMethodstringYesCARD
force3DbooleanYestrue. It must be a boolean; a string such as "true" gets 400 VALIDATION_ERROR. When omitted or false the charge is 2D → Card — 2D
cardobjectYesCard details; see card
callbackUrlstringNohttp or https URL, at most 2048 characters. The buyer's browser is POSTed here with the result. Without it the buyer sees the platform's result page and does not return to your site
installmentintegerNoNumber of installments, default 1. 2 or more requires installmentQuoteToken
installmentQuoteTokenstringNoBinding token from the installment quote; see Installments
preauthbooleanNotrue: after the bank verification the amount is held and the payment becomes AUTHORIZED; see Pre-authorization. When omitted or false the amount is charged

card, force3D, installment and installmentQuoteToken can only be sent with paymentMethod: "CARD". For card, routing chooses the provider; providerCode is not sent.

Order fields

FieldTypeRequiredDescription
amountintegerYesAmount, minor-unit integer; at least 1. It is the basket total: the price of the goods and services the buyer pays for, excluding the installment fee. If you send basketItems, it must equal the sum of the items' price. For card installments the system adds the fee. Example: 10050
currencystring (enum)YesISO 4217 code: TRY, USD, EUR, GBP, RUB, AED, SAR, QAR, KWD, JPY, CHF, CAD, AUD, CNY, AZN. A currency not enabled on your account is rejected
orderIdstringYesYour order number, at most 128 characters. Example: ORD-84722
customerIdstringNoYour customer key. It identifies the buyer for wallet payments; when a card is saved with 3D Secure, it is stored under this key. Example: cust_7788
descriptionstringNoPayment description
localestringNoMessage language, default tr
paymentChannelstringNoPayment channel. Example: WEB
paymentGroupstringNoProduct group. Example: PRODUCT
buyerobjectNoBuyer details; see buyer
shippingAddressobjectNoShipping address; see Address
billingAddressobjectNoBilling address; see Address
basketItemsarrayNoBasket items; see basketItems[]
metadataobjectNoFree key-value object for your own records. Request a pre-authorization with the preauth field, not with metadata

The amount is sent only in amount; the removed amount fields get 400 payment.amount.field_removed. Sending a field that is not defined here rejects the request with 400 VALIDATION_ERROR.

card

FieldTypeRequiredDescription
cardHolderNamestringRaw cardName as printed on the card
cardNumberstringRaw cardCard number
expireMonthstringRaw cardTwo-digit month, 01–12
expireYearstringRaw cardTwo-digit year. Example: 30
cvcNumberstringRaw card3 or 4 digit security code. Most banks also require it for saved-card charges
cardUserKeystringSaved cardCustomer key the card is stored under
cardTokenstringSaved cardPermanent reference of the saved card (storedCardToken). The Checkout Form's single-use card token cannot be used here
cardSavebooleanNotrue: the card is saved under the customerId key once the payment succeeds. Only allowed on a 3D Secure request (force3D: true); a 2D request gets 400 payment.card.save_requires_3ds
cardNamestringNoA name you give the saved card; used together with cardSave: true. Example: Work card
  • When sent, expireMonth and expireYear must have two digits.
  • For a raw card charge send cardHolderName, cardNumber, expireMonth, expireYear and cvcNumber. For a saved card charge send cardUserKey and cardToken instead of the raw card fields.

buyer

buyer is optional. If you send it, every field marked "Yes" below must be filled. On a Checkout Form intent (POST /payments/intents, POST /payments/marketplace/intents) this rule does not apply: every field is optional, and the fields you send are checked for type and length.

FieldTypeRequiredExample
idstringNoCUST-1
namestringYesAda
surnamestringYesYılmaz
identityNumberstringYes11111111111
emailstring (email)Yes[email protected]
gsmNumberstringYes+905000000000
citystringYesIstanbul
countrystringYesTurkey
zipCodestringYes34732
ipstringYesThe buyer's IP address
registrationDatestringNo2024-04-21 15:12:09
lastLoginDatestringNo2026-10-05 12:43:35
registrationAddressstringNoRegistered address

Required-field rule, how it differs from customerId, and error field paths: Buyer.

Address

shippingAddress and billingAddress share the same shape. If you send the object, all five fields are required.

FieldTypeExample
contactNamestringAda Yılmaz
addressstringAltunizade Mah. …
citystringIstanbul
countrystringTurkey
zipCodestring34742

How the two addresses differ and error field paths: Addresses.

basketItems[]

FieldTypeRequiredDescription
idstringYesItem code
namestringYesItem name
category1stringYesMain category
category2stringNoSub-category
itemTypestring (enum)YesPHYSICAL or VIRTUAL
priceintegerYesItem amount, minor-unit integer, at least 0

The sum of the items' price must equal amount; otherwise the request is rejected with 400 VALIDATION_ERROR. The installment fee is not added to the items.

Example request

# See the Authentication page for how the Step-Up signature headers are computed.
curl -X POST https://api-gateway.lunixi.com/api/v1/payments/direct/payment \
  -H "Authorization: Bearer at_test_5f3e2b1a..." \
  -H "Content-Type: application/json" \
  -H "X-Key-Id: mk_test_sk_..." \
  -H "X-Date: 2026-10-11T09:00:00.000Z" \
  -H "X-Nonce: 7d2c4e1a-3b9f-4a6d-8c51-2e0f9b7a6d34" \
  -H "Digest: SHA-256=..." \
  -H "X-Signature: ..." \
  -H "Idempotency-Key: ORD-84721-1" \
  -d '{
    "paymentMethod": "CARD",
    "force3D": true,
    "amount": 10050,
    "currency": "TRY",
    "orderId": "ORD-84721",
    "customerId": "cust_7788",
    "callbackUrl": "https://merchant.example.com/payments/3d-result",
    "card": {
      "cardHolderName": "Ada Yilmaz",
      "cardNumber": "5400000000000004",
      "expireMonth": "12",
      "expireYear": "30",
      "cvcNumber": "123"
    }
  }'

Response

The payment waits in AWAITING_3D until the bank round-trip completes. The response is HTTP 202 with envelope status: "partial" and code: "AWAITING_3D":

{
  "status": "partial",
  "code": "AWAITING_3D",
  "message": "…",
  "data": {
    "paymentId": "9c6f2b1a-4e8d-4a3f-b5e6-2d7c1a9b8e30",
    "orderId": "ORD-84721",
    "status": "AWAITING_3D",
    "paymentMethod": "CARD",
    "providerCode": "<providerCode>",
    "action": {
      "id": "3ds_9c6f2b1a-4e8d-4a3f-b5e6-2d7c1a9b8e30",
      "type": "REDIRECT",
      "expiresAt": "2026-10-11T09:15:00.000Z",
      "redirect": { "url": "/api/v1/payments/threed/<redirect-token>" }
    }
  },
  "statusCode": 202,
  "requestId": "…"
}
FieldDescription
paymentIdPayment id; used for capture, refund and lookup calls
statusAWAITING_3D
providerCodeCode of the provider processing the payment; for card, routing chooses the provider. <providerCode> is a placeholder
action.typeREDIRECT
action.redirect.urlRedirect address the buyer goes to
action.expiresAtWhen the redirect expires (ISO 8601)
  • action.redirect.url is relative; resolve it against https://api-gateway.lunixi.com and send the buyer's browser there promptly (a browser GET).
  • The redirect address is single-use. Opening it a second time shows the buyer an error page.
  • A payment abandoned in AWAITING_3D is settled by the platform: it asks the provider for the real outcome and binds the payment to its true state (FAILED if nothing was charged, CAPTURED if the charge went through).
  • Do not call confirm for the card 3D Secure action; the result is verified on the return and reported by webhook.

If the request is rejected before authentication (limit, risk, invalid installment quote), the response is HTTP 400: envelope status: "failure", data.status: "FAILED", and code carries the reason. Branch on code; the message text may change. For the envelope fields see Error Handling.

The signed callback to your server

After the bank round-trip, the buyer's browser is sent to your callbackUrl with an HTML form POST (application/x-www-form-urlencoded). The body carries these fields:

FieldMeaning
paymentIdPayment id
orderIdYour order number (when you sent one)
statussuccess, failure or pending (outcome still being verified)
success"true" / "false"
codeMachine-readable result code
messageHuman-readable result message
_timestampSigning time, ISO 8601
_signaturev2=<hex>: HMAC-SHA256 over ${paymentId}.${_timestamp}.${canonical}, hex digest

The canonical string is every field except _signature (including _timestamp), sorted by key, each written as key=value and joined with a newline (\n).

⚠️

This callback is informational; do not make the fulfilment decision on it. _signature and _timestamp are integrity fields produced by the platform; no secret is distributed that would let you verify the signature on the merchant side. Before treating the order as paid, confirm the state with GET /payments/:id or the payment.captured webhook.

Use the callback to drive the buyer's UI (show a result page, resume the session). The buyer's browser may never reach your callbackUrl (closed tab, lost connection); the webhook is always sent.

Status transitions

FromToWhen
INITIATEDAWAITING_3D3D payment accepted, redirect address issued
AWAITING_3DCAPTUREDBank authentication and charge succeeded
AWAITING_3DAUTHORIZEDFor a pre-authorization the bank authentication succeeded and the amount was held
AWAITING_3DFAILEDThe buyer failed or abandoned authentication, or authorization was declined
AWAITING_3DNEEDS_RECONCILIATIONOutcome could not be determined (e.g. the bank return was lost); the platform asks the provider again and binds the final state

Saved cards

Saving a card

A card is saved during a payment made with 3D Secure; there is no separate "save card" call. Send customerId together with force3D: true and add card.cardSave: true together with the raw card; optionally name the card with card.cardName. The card is stored under the customerId key if authentication and the charge succeed; it is not stored if the payment fails.

When the payment response carries a card newly saved in this payment, it is in data.savedCard:

FieldDescription
storedCardTokenPermanent reference of the saved card; send it as card.cardToken in later charges
cardUserKeyCustomer key the card is saved under
maskedNumber, binNumber, lastFourMasked number, BIN and last four digits
expireMonth, expireYearExpiry month and year
cardBrand, cardType, cardFamily, bankCode, cardNameBrand, type, family, bank and name of the card, when available
deduplicatedtrue: the card was already saved; no new record was created and the existing reference was returned

You can always read saved cards with GET /payments/cards. For enabling card storage in the panel and listing cards, see Stored Cards.

3D payment with a saved card

There is no separate endpoint. Put cardUserKey and cardToken into the card object instead of the raw card fields:

{
  "paymentMethod": "CARD",
  "force3D": true,
  "amount": 10050,
  "currency": "TRY",
  "orderId": "ORD-84724",
  "callbackUrl": "https://merchant.example.com/payments/3d-result",
  "card": {
    "cardUserKey": "cust_7788",
    "cardToken": "9f3c1a7e-…",
    "cvcNumber": "123"
  }
}

The Checkout Form's single-use card token cannot be used here; cardToken is the permanent saved-card reference.

Installments

If the buyer picked an installment option, send installment together with the installmentQuoteToken you received from the installment quote.

  • installmentQuoteToken is required when installment is 2 or more.
  • amount stays the basket total; you do not send the installment fee. The system computes the fee from the binding quote and adds it to the charged amount: charged amount = amount + installment fee. What you displayed is exactly what is charged.
  • The amount fields in lookups and webhooks show this charged amount.
  • If the token is missing, expired or does not match the request, the payment becomes FAILED and the response is 400 INSTALLMENT_QUOTE_REJECTED. Fetch a fresh quote and resubmit with a new Idempotency-Key.

Pre-authorization

To hold the amount before charging it, send "preauth": true in the request. When the buyer completes the bank verification no money is charged: the payment becomes AUTHORIZED instead of CAPTURED and payment.authorized is sent. You later charge the amount with capture or remove the hold with void.

  • The provider must also support pre-authorization over 3D Secure. Providers that do not are removed from the candidates; if no suitable provider remains, the payment becomes FAILED with decline code PREAUTH_3DS_NOT_SUPPORTED.
  • If no provider supports pre-authorization over 3D Secure at all, the request is rejected before the payment starts with 422 payment.preauth.three_ds_unsupported. The instruction is never turned into an immediate charge.
  • Capture, partial capture, void, expiry and errors: Pre-authorization, Capture & Void.

Idempotency

Idempotency-Key is required (at most 200 characters). Without it the request gets 400 payment.operation.idempotency_key_required and is not processed. Use a value that identifies the attempt, not the payment (for example ORD-84721-1).

SituationResult
Same key, same requestThe result of the first attempt is returned; no second payment is opened. An open REDIRECT action is included again
Same key, different request (e.g. different amount)409 payment.idempotency.conflict
First request with the same key still in progress429 payment.idempotency.processing; retry shortly with the same key
⚠️

To retry a failed order (another card, a new authentication), send a new Idempotency-Key. If you reuse the same key, the earlier attempt's result is returned. Also use a new key when you try the same order first with 2D and then with 3D; otherwise the request is rejected with 409.

Errors

SituationResponseWhat to do
Missing, invalid or undefined field400 VALIDATION_ERRORFix the field named in details
A removed amount field was sent400 payment.amount.field_removedSend the amount only in amount
Basket total does not equal amount400 VALIDATION_ERRORMake the items' price add up to amount
No Idempotency-Key400 payment.operation.idempotency_key_requiredSend a key for every attempt
Bearer token missing or expired401 UNAUTHENTICATEDGet a new token
Signature could not be verified401 INVALID_SIGNATUREBuild the canonical string in the order given in Authentication
Nonce already used401 NONCE_REPLAYGenerate a new X-Nonce for every request
Invalid X-Date or Digest401 INVALID_DATE, DIGEST_MISSING, INVALID_DIGESTSee Error Handling
Token and signing key belong to different merchants403 AUTH_MERCHANT_MISMATCHUse keys of the same account
IP not on the allow-list403 IP_NOT_ALLOWEDUpdate the IP list in the panel
Account suspended403 MERCHANT_SUSPENDEDContact support
Product not enabled403 PRODUCT_NOT_ENABLEDCheck that the Payment Gateway product is enabled on your account and your API key
Product entitlement could not be verified503 PRODUCT_ENTITLEMENT_UNAVAILABLETemporary; retry shortly
Installment quote invalid or missing400 INSTALLMENT_QUOTE_REJECTED, payment FAILEDFetch a fresh quote and resubmit with a new Idempotency-Key
Pre-authorization over 3D Secure is not supported422 payment.preauth.three_ds_unsupported or payment FAILED, decline code PREAUTH_3DS_NOT_SUPPORTEDNothing was held; see Pre-authorization
Rejected by a limit rule400 payment.limit.exceeded, payment FAILEDCheck your limits
Rejected by the risk evaluation400 RISK_BLOCKED, payment FAILEDDo not fulfil the order
Redirect address expired or already usedGET /payments/threed/:token shows an error pageStart a new payment with a new Idempotency-Key
Bank authentication failedCallback status=failure, payment FAILED, payment.failedLet the buyer retry with another card
Authorization declined after authenticationCallback status=failure, payment FAILED, payment.failedShow the decline; do not fulfil
Outcome not yet certainCallback status=pending, payment NEEDS_RECONCILIATIONDo not open a new payment; wait for the webhook
Same key, different request409 payment.idempotency.conflictUse a new key for a new attempt
Request with the same key in progress429 payment.idempotency.processingRetry shortly with the same key
Temporary outage503Check the status with GET /payments/:id; retrying with the same Idempotency-Key does not open a second payment

Common codes for method and provider selection: Payment — Error codes.

Webhooks

EventWhen
payment.capturedThe charge completed and the funds were captured. Fulfil the order on this event
payment.failedAuthentication or authorization failed (including abandoned payments settled as not charged)
payment.authorizedFor a pre-authorization the bank verification completed and the amount was held (AUTHORIZED); no funds were captured yet. See Pre-authorization

The data of the events carries paymentMethod: "CARD" and the providerCode of the provider that processed the payment. For delivery and signature verification see Webhooks.

Next steps