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.
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:
| Header | Value |
|---|---|
Authorization | Bearer at_test_… (test) or Bearer at_live_… (live) |
X-Key-Id | Id of your registered Ed25519 public key |
X-Date | ISO 8601 request time; within ±5 minutes of server time |
X-Nonce | Unique, single-use value per request |
Digest | SHA-256=<base64> hash of the raw JSON body |
X-Signature | Base64 Ed25519 signature over the canonical string |
Idempotency-Key | Required, 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/jsonCard fields
| Field | Type | Required | Description |
|---|---|---|---|
paymentMethod | string | Yes | CARD |
force3D | boolean | Yes | true. It must be a boolean; a string such as "true" gets 400 VALIDATION_ERROR. When omitted or false the charge is 2D → Card — 2D |
card | object | Yes | Card details; see card |
callbackUrl | string | No | http 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 |
installment | integer | No | Number of installments, default 1. 2 or more requires installmentQuoteToken |
installmentQuoteToken | string | No | Binding token from the installment quote; see Installments |
preauth | boolean | No | true: 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
| Field | Type | Required | Description |
|---|---|---|---|
amount | integer | Yes | Amount, 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 |
currency | string (enum) | Yes | ISO 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 |
orderId | string | Yes | Your order number, at most 128 characters. Example: ORD-84722 |
customerId | string | No | Your 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 |
description | string | No | Payment description |
locale | string | No | Message language, default tr |
paymentChannel | string | No | Payment channel. Example: WEB |
paymentGroup | string | No | Product group. Example: PRODUCT |
buyer | object | No | Buyer details; see buyer |
shippingAddress | object | No | Shipping address; see Address |
billingAddress | object | No | Billing address; see Address |
basketItems | array | No | Basket items; see basketItems[] |
metadata | object | No | Free 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
| Field | Type | Required | Description |
|---|---|---|---|
cardHolderName | string | Raw card | Name as printed on the card |
cardNumber | string | Raw card | Card number |
expireMonth | string | Raw card | Two-digit month, 01–12 |
expireYear | string | Raw card | Two-digit year. Example: 30 |
cvcNumber | string | Raw card | 3 or 4 digit security code. Most banks also require it for saved-card charges |
cardUserKey | string | Saved card | Customer key the card is stored under |
cardToken | string | Saved card | Permanent reference of the saved card (storedCardToken). The Checkout Form's single-use card token cannot be used here |
cardSave | boolean | No | true: 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 |
cardName | string | No | A name you give the saved card; used together with cardSave: true. Example: Work card |
- When sent,
expireMonthandexpireYearmust have two digits. - For a raw card charge send
cardHolderName,cardNumber,expireMonth,expireYearandcvcNumber. For a saved card charge sendcardUserKeyandcardTokeninstead 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.
| Field | Type | Required | Example |
|---|---|---|---|
id | string | No | CUST-1 |
name | string | Yes | Ada |
surname | string | Yes | Yılmaz |
identityNumber | string | Yes | 11111111111 |
email | string (email) | Yes | [email protected] |
gsmNumber | string | Yes | +905000000000 |
city | string | Yes | Istanbul |
country | string | Yes | Turkey |
zipCode | string | Yes | 34732 |
ip | string | Yes | The buyer's IP address |
registrationDate | string | No | 2024-04-21 15:12:09 |
lastLoginDate | string | No | 2026-10-05 12:43:35 |
registrationAddress | string | No | Registered 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.
| Field | Type | Example |
|---|---|---|
contactName | string | Ada Yılmaz |
address | string | Altunizade Mah. … |
city | string | Istanbul |
country | string | Turkey |
zipCode | string | 34742 |
How the two addresses differ and error field paths: Addresses.
basketItems[]
| Field | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Item code |
name | string | Yes | Item name |
category1 | string | Yes | Main category |
category2 | string | No | Sub-category |
itemType | string (enum) | Yes | PHYSICAL or VIRTUAL |
price | integer | Yes | Item 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": "…"
}| Field | Description |
|---|---|
paymentId | Payment id; used for capture, refund and lookup calls |
status | AWAITING_3D |
providerCode | Code of the provider processing the payment; for card, routing chooses the provider. <providerCode> is a placeholder |
action.type | REDIRECT |
action.redirect.url | Redirect address the buyer goes to |
action.expiresAt | When the redirect expires (ISO 8601) |
action.redirect.urlis relative; resolve it againsthttps://api-gateway.lunixi.comand send the buyer's browser there promptly (a browserGET).- The redirect address is single-use. Opening it a second time shows the buyer an error page.
- A payment abandoned in
AWAITING_3Dis settled by the platform: it asks the provider for the real outcome and binds the payment to its true state (FAILEDif nothing was charged,CAPTUREDif 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:
| Field | Meaning |
|---|---|
paymentId | Payment id |
orderId | Your order number (when you sent one) |
status | success, failure or pending (outcome still being verified) |
success | "true" / "false" |
code | Machine-readable result code |
message | Human-readable result message |
_timestamp | Signing time, ISO 8601 |
_signature | v2=<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
| From | To | When |
|---|---|---|
INITIATED | AWAITING_3D | 3D payment accepted, redirect address issued |
AWAITING_3D | CAPTURED | Bank authentication and charge succeeded |
AWAITING_3D | AUTHORIZED | For a pre-authorization the bank authentication succeeded and the amount was held |
AWAITING_3D | FAILED | The buyer failed or abandoned authentication, or authorization was declined |
AWAITING_3D | NEEDS_RECONCILIATION | Outcome 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:
| Field | Description |
|---|---|
storedCardToken | Permanent reference of the saved card; send it as card.cardToken in later charges |
cardUserKey | Customer key the card is saved under |
maskedNumber, binNumber, lastFour | Masked number, BIN and last four digits |
expireMonth, expireYear | Expiry month and year |
cardBrand, cardType, cardFamily, bankCode, cardName | Brand, type, family, bank and name of the card, when available |
deduplicated | true: 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.
installmentQuoteTokenis required wheninstallmentis2or more.amountstays 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
FAILEDand the response is400 INSTALLMENT_QUOTE_REJECTED. Fetch a fresh quote and resubmit with a newIdempotency-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
FAILEDwith decline codePREAUTH_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).
| Situation | Result |
|---|---|
| Same key, same request | The 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 progress | 429 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
| Situation | Response | What to do |
|---|---|---|
| Missing, invalid or undefined field | 400 VALIDATION_ERROR | Fix the field named in details |
| A removed amount field was sent | 400 payment.amount.field_removed | Send the amount only in amount |
Basket total does not equal amount | 400 VALIDATION_ERROR | Make the items' price add up to amount |
No Idempotency-Key | 400 payment.operation.idempotency_key_required | Send a key for every attempt |
| Bearer token missing or expired | 401 UNAUTHENTICATED | Get a new token |
| Signature could not be verified | 401 INVALID_SIGNATURE | Build the canonical string in the order given in Authentication |
| Nonce already used | 401 NONCE_REPLAY | Generate a new X-Nonce for every request |
Invalid X-Date or Digest | 401 INVALID_DATE, DIGEST_MISSING, INVALID_DIGEST | See Error Handling |
| Token and signing key belong to different merchants | 403 AUTH_MERCHANT_MISMATCH | Use keys of the same account |
| IP not on the allow-list | 403 IP_NOT_ALLOWED | Update the IP list in the panel |
| Account suspended | 403 MERCHANT_SUSPENDED | Contact support |
| Product not enabled | 403 PRODUCT_NOT_ENABLED | Check that the Payment Gateway product is enabled on your account and your API key |
| Product entitlement could not be verified | 503 PRODUCT_ENTITLEMENT_UNAVAILABLE | Temporary; retry shortly |
| Installment quote invalid or missing | 400 INSTALLMENT_QUOTE_REJECTED, payment FAILED | Fetch a fresh quote and resubmit with a new Idempotency-Key |
| Pre-authorization over 3D Secure is not supported | 422 payment.preauth.three_ds_unsupported or payment FAILED, decline code PREAUTH_3DS_NOT_SUPPORTED | Nothing was held; see Pre-authorization |
| Rejected by a limit rule | 400 payment.limit.exceeded, payment FAILED | Check your limits |
| Rejected by the risk evaluation | 400 RISK_BLOCKED, payment FAILED | Do not fulfil the order |
| Redirect address expired or already used | GET /payments/threed/:token shows an error page | Start a new payment with a new Idempotency-Key |
| Bank authentication failed | Callback status=failure, payment FAILED, payment.failed | Let the buyer retry with another card |
| Authorization declined after authentication | Callback status=failure, payment FAILED, payment.failed | Show the decline; do not fulfil |
| Outcome not yet certain | Callback status=pending, payment NEEDS_RECONCILIATION | Do not open a new payment; wait for the webhook |
| Same key, different request | 409 payment.idempotency.conflict | Use a new key for a new attempt |
| Request with the same key in progress | 429 payment.idempotency.processing | Retry shortly with the same key |
| Temporary outage | 503 | Check 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
| Event | When |
|---|---|
payment.captured | The charge completed and the funds were captured. Fulfil the order on this event |
payment.failed | Authentication or authorization failed (including abandoned payments settled as not charged) |
payment.authorized | For 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
- Card — 2D — charging without bank authentication
- Installments & BIN — installment table and binding quote
- Stored Cards — managing saved cards
- Refunds — returning money after capture
- Webhooks — payment events