Card Payment — 3D Secure
3D Secure has the buyer confirm the transaction with their own bank before the card is charged (SMS code, banking app approval, biometrics). This flow runs when you want liability shift, when your 3D policy requires it, or when the bank asks for authentication. The platform routes the transaction to a suitable provider and handles the bank round-trip for you.
This page covers the Checkout Form path. The Checkout Form SDK calls POST /payments/form/3d itself from the buyer's browser and also redirects the buyer to the bank; you do not write requests to this endpoint. The code you write is creating the intent on your server, mounting the form on your page and handling the result → Checkout Form.
If you send card data from your own server, this page is not for you → Card — 3D Secure.
All amounts are integers in minor units (kuruş): "amount": 10050 = ₺100.50. Never send decimals or floats.
How it works
Create an intent (your server)
Call POST /payments/intents with Step-Up authentication and supply the callbackUrl the result returns to. The form token in the response is valid for about 15 minutes and is bound to the origin the form opens on. All fields and 3D settings → Checkout Form.
Mount the form (browser)
Pass the token to the SDK and place the form on your page. On start-up the SDK obtains the session proof (POST /payments/form/session-init) and starts risk analysis (POST /payments/form/live-analyze). The proof is written to the pg_checkout_session cookie; card-touching calls are rejected without it.
The buyer enters the card
The form resolves the card brand and family, shows the installment table, and turns the card into a single-use token inside an isolated iframe (POST /payments/form/tokenize). The card number is never exposed to your page. If installments are chosen, the charged amount is bound to the displayed quote → Installments & BIN.
The SDK starts the 3D Secure payment
The SDK calls POST /payments/form/3d. The payment moves to AWAITING_3D and the response carries the bank redirect.
The buyer authenticates at the bank
The SDK sends the buyer to GET /payments/threed/:token. This address requires no authentication; the single-use token in the path is the credential. The buyer approves the transaction on the bank's 3D Secure page.
The platform charges
The bank returns the result to the platform. The platform verifies it and charges with the provider only if authentication succeeded.
The buyer returns to your result page
The buyer's browser is sent to the callbackUrl you supplied on the intent with a signed form POST → The signed POST to your result page.
Confirm with the webhook
payment.captured or payment.failed is delivered to your webhook endpoint. Close the order based on that event or on a GET /payments/:id lookup.
A successful redirect is not a successful payment. 3D Secure has two separate phases: the buyer authenticating to the bank, and the money being charged. A payment can pass authentication and still fail the charge (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 confirmation.
When the form uses 3D Secure
The intent's 3D decision determines which endpoint runs. The decision is resolved when the intent is created, from force3D, settings.threeD and the panel 3D policy, and is fixed on the intent → Card Payment — 2D › The 3D decision.
The form charges with 3D Secure when:
- The intent's 3D decision is 3D. The SDK calls
POST /payments/form/3ddirectly. - The decision is 2D, but the provider routing the payment cannot perform 2D sales. The platform upgrades the charge to 3D Secure and marks the payment with
escalationReason: "PROVIDER_2D_UNSUPPORTED"→ Card Payment — 2D › When the provider cannot do 2D.
In both cases the flow proceeds as described on this page.
Endpoint reference — POST /payments/form/3d
This section is for reference: the SDK builds and sends the body.
Authentication does not use an Authorization header; it uses the form token in the body and the session proof (the pg_checkout_session cookie or checkoutSessionProof). The body is the same as for POST /payments/form/2d → field table.
Example body
The body the SDK sends (secure-fields mode, 3 installments):
{
"token": "<form token from the intent response>",
"installment": 3,
"paymentCards": {
"secureCardToken": "<cardToken from the tokenize response>"
},
"paymentChannel": "WEB",
"browserFingerprint": "<fingerprint>",
"installmentQuoteToken": "<quote token from the installment table>"
}installmentQuoteToken is sent for installment payments; the charged amount is bound to the quote shown to the buyer.
The Checkout Form SDK builds this body. The SDK also adds the total it displayed to the buyer as an internal form field; it is not an amount you send. The server computes the charged amount from the intent's amount and, for installments, the installment fee in the binding quote.
Response
The payment waits in AWAITING_3D until the bank round-trip completes. The response returns with status: "partial" (HTTP 202):
{
"status": "partial",
"code": "AWAITING_3D",
"message": "Payment processed",
"data": {
"id": "9c6f2b1a-4e8d-4a3f-b5e6-2d7c1a9b8e30",
"orderId": "ORD-9988",
"amount": 10350,
"currency": "TRY",
"status": "AWAITING_3D",
"threeDRedirectUrl": "/api/v1/payments/threed/<single-use token>"
},
"statusCode": 202,
"requestId": "…"
}data.amount is the charged amount: the intent's amount plus the installment fee the system added from the installment quote. In the example the intent amount is 10050 and the amount charged over 3 installments is 10350.
| Field | Description |
|---|---|
data.threeDRedirectUrl | Where the buyer is sent. It may be relative; in that case it is resolved against the gateway root. Single-use. |
data.threeDRedirectMethod | The redirect method, when present (GET or POST). |
data.threeDRedirectData | Only for a POST redirect: the form fields to send to the bank. |
The SDK performs the redirect itself. A payment abandoned in AWAITING_3D is picked up by a background cleanup job: it asks the provider for the real outcome and binds the payment to its true final state (FAILED if nothing was charged, CAPTURED if the charge landed).
The signed POST to your result page
After the bank round-trip, the buyer's browser is sent to the callbackUrl you supplied on the intent with an HTML form POST (application/x-www-form-urlencoded). The same POST also arrives after a successful 2D charge. The body carries these fields:
| Field | Meaning |
|---|---|
paymentId | The payment id |
orderId | The order reference you supplied at intent creation (sent only when set on the intent) |
status | success, failure or pending (outcome not yet final) |
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 (canonical) is every field except _signature (including _timestamp), sorted by key, each written as key=value and joined with a newline (\n).
This POST is informational; do not rely on it for fulfilment. _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. Never treat the order as paid based on any field in this POST; confirm the final state with GET /payments/:id or the payment.captured webhook.
Use this POST to drive the buyer's UI (render the result page, resume the session). The browser may never reach your callbackUrl (closed tab, network loss); the webhook is sent in every case.
Status transitions
| From | To | When |
|---|---|---|
INITIATED | AWAITING_3D | 3D Secure payment accepted, redirect issued |
AWAITING_3D | CAPTURED | Bank authentication and charge succeeded |
AWAITING_3D | FAILED | The buyer failed or abandoned authentication, or the charge was declined |
AWAITING_3D | NEEDS_RECONCILIATION | The outcome could not be determined (e.g. the bank's return was lost); a reconciliation job queries the provider again and binds the final state |
Card storage
On the form, a card is saved when the buyer ticks "save my card" and the charge completes successfully with 3D Secure. This requires:
- Card storage enabled in the form settings of your panel.
- The intent created with
customerId(orcardUserKey); the card is saved under that key.
Because the payment response goes to the buyer's browser, the permanent card reference is not returned there. After the payment completes, read the cards from your server with GET /payments/cards → Stored Cards.
Pre-authorization
You do not request a pre-authorization from the form: send "preauth": true when you create the intent with POST /payments/intents. When the buyer completes the bank verification no money is charged; the payment becomes AUTHORIZED instead of CAPTURED and payment.authorized is sent.
- The provider must also support pre-authorization over 3D Secure. 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 with
422 payment.preauth.three_ds_unsupported. The instruction is never turned into an immediate charge.
Capture, void and the 7-day window: Pre-authorization, Capture & Void.
Errors
Error responses use the common error envelope; branch on code, not on message → Error Handling.
| Situation | Code | What to do |
|---|---|---|
| The body failed validation | 400 VALIDATION_ERROR | Use the current SDK; the SDK builds the body. |
| The form token is invalid | 401 payment.checkout.session_invalid | Create a new intent and mount the form again. |
| The form token expired (about 15 minutes) | 401 payment.checkout.session_expired | Create a new intent and mount the form again. |
| The form was opened outside the origin the intent is bound to | 401 payment.checkout.origin_mismatch | Create the intent with the origin of the page that opens the form → Checkout Form. |
| The session proof is missing or invalid | 401 payment.checkout.proof_missing | Open the form with the SDK. Where cookies cannot be carried, the proof is sent in the body as checkoutSessionProof. |
| The session proof belongs to another session | 401 payment.checkout.proof_mismatch | Open the form again. |
| The form token does not belong to this payment | 401 payment.checkout.token_mismatch | Open the form again with the token from the intent response. |
| The single-use card token is invalid or already used | 401 payment.secure_card.invalid_token | The buyer enters the card again; the SDK obtains a new token. |
| The transaction did not pass the security check | 409 payment.intent.live_risk_blocked | Retrying does not change the outcome. |
| This payment attempt is closed (the payment has a result) | 409 payment.intent.attempt_closed | Create a new intent and open the form again. |
The intent was opened for a non-card method (e.g. paymentMethod: "WALLET") | 409 payment.method.intent_not_card | This intent cannot be paid by card; create the intent without paymentMethod or with "CARD". The intent was not changed and can still be paid with its own method → Checkout Form — Payment. |
| Pre-authorization over 3D Secure is not supported | 422 payment.preauth.three_ds_unsupported or payment FAILED, code PREAUTH_3DS_NOT_SUPPORTED | Nothing was held; see Pre-authorization. |
| The redirect token expired or was already used | GET /payments/threed/:token shows an error page | Create a new intent and start the payment again. |
| Bank authentication failed | Payment FAILED; status: failure in the signed POST; payment.failed | Create a new intent if the buyer wants to pay again. |
| Authentication succeeded, charge declined | Payment FAILED | Show the decline to the buyer; do not fulfil. |
| The installment quote is invalid or expired | Payment FAILED, code INSTALLMENT_QUOTE_REJECTED | Create a new intent; the buyer picks the installment again from the current table. |
| The total the form displayed to the buyer does not match the charged amount computed by the server | Payment FAILED, code CLIENT_PRICE_DIVERGENCE | Make sure the form is opened with the current SDK and create a new intent. |
| Temporary infrastructure error | 503 payment.dependency.unavailable, payment.credential.resolution_failed, payment.lock.unavailable | Temporary; the buyer can retry shortly. |
Webhooks
| Event | When |
|---|---|
payment.captured | Authentication and charge completed, funds captured |
payment.failed | Authentication or charge failed (including abandoned 3D Secure sessions resolved as not charged) |
payment.authorized | For a pre-authorization intent the verification completed and the amount was held (AUTHORIZED); no funds were captured yet |
Delivery and signature verification → Webhooks.
Next steps
- Card Payment — 2D: charging without bank authentication, and the sources of the 3D decision
- Checkout Form: creating the intent,
callbackUrl,force3Dandsettings.threeD - Stored Cards: reading saved cards and showing them on the form
- Installments & BIN: the displayed amount is the charged amount
- Refunds: returning money after capture