Confirm Payment
Confirm a payment intent your backend created, using the card the shopper entered in the SDK's card fields. This is the direct confirmation flow: the amount is fixed when the intent is created, and the browser confirms it in one call.
To decide the amount on your server after seeing the card instead, use the Token-Based Flow. To save a card without charging it, see Save a Card.
Apple Pay and Google Pay use this same flow when you pass a clientSecret to bahn.mountApplePay()/bahn.mountGooglePay() — the button confirms the intent itself, so you don't call confirmPayment() for them.
Full example
A complete checkout page for this flow: Integration Examples → Confirm Payment, or as a typed React component: Confirm Payment in React.
1. Create a payment intent on your backend
confirmPayment() confirms an existing payment intent. Create it on your backend with a signed API request and return only its client_secret to the browser. See One Time Payment for the server-side request.
// Your backend: POST /api/create-payment-intent → { client_secret: "pi_xxx_secret_yyy" }
const { client_secret } = await fetch("/api/create-payment-intent", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ amount: 100000, currency: "vnd" }),
}).then((r) => r.json());2. Mount the card fields
const bahn = new Bahn({ pk: "pk_test_...", environment: "sandbox" });
const card = bahn.mountCard("#card-container");confirmPayment() sends the request through the card-number iframe, so the card must stay mounted until the call resolves. See Card Payments.
3. Confirm the payment
const { payment_intent, three_ds_payload } = await bahn.confirmPayment({
client_secret,
payment_method: {
billing_details: { name: "Nguyen Van A", email: "[email protected]" },
},
});The card fields don't include the cardholder name: collect it in your own input and pass it as billing_details.name, as above. See Card Payments → Cardholder Name.
4. Handle the result
If the issuer requires 3D Secure, three_ds_payload is present: mount the challenge, then poll for the final status once it passes. Otherwise, check payment_intent.status: succeeded, requires_capture and requires_confirmation are successes, failed, canceled and requires_payment_method are failures, and anything else (such as processing) isn't final yet, so poll until it is.
if (three_ds_payload) {
bahn.mount3DS(three_ds_payload.id, {
onSuccess: () => {
bahn.pollPaymentIntentStatus(client_secret, {
onSuccess: (paymentIntent) => redirectToSuccess(paymentIntent),
onFailure: (paymentIntent) => redirectToFailure(paymentIntent),
onTimeout: () => showTimeoutMessage(),
onError: (error) => console.error("Poll error:", error.message),
});
},
onFailure: (error) => showError(`3DS failed: ${error?.message ?? "Unknown error"}`),
});
} else if (["succeeded", "requires_capture", "requires_confirmation"].includes(payment_intent.status)) {
redirectToSuccess(payment_intent);
} else if (["failed", "canceled", "requires_payment_method"].includes(payment_intent.status)) {
redirectToFailure(payment_intent);
} else {
// e.g. "processing" — poll until it reaches a terminal status
bahn.pollPaymentIntentStatus(client_secret, {
onSuccess: (paymentIntent) => redirectToSuccess(paymentIntent),
onFailure: (paymentIntent) => redirectToFailure(paymentIntent),
onTimeout: () => showTimeoutMessage(),
});
}To run the challenge on a separate page instead of an overlay, see 3DS Authentication → Standalone Challenge Page.
Trust the webhook, not the browser
The browser result decides what to show the shopper. Mark the order as paid only after your backend receives the webhook for the final status. See Webhooks.
How it works
sequenceDiagram
participant App as Your App
participant Backend as Your Backend
participant SDK as Bahn SDK
participant CardIframe as Card Iframe
participant API as Cake API
App->>Backend: Start checkout
Backend->>API: Create PaymentIntent (amount, currency)
API-->>Backend: PaymentIntent
Backend-->>App: { client_secret }
App->>SDK: mountCard("#container", options)
App->>SDK: confirmPayment({ client_secret })
SDK->>CardIframe: Delegate + merge card data
CardIframe->>API: POST /api/sdk/payment_intents/confirm
API-->>CardIframe: PaymentIntent + optional base64 3DS payload
CardIframe-->>SDK: Decoded result (no card data)
SDK-->>App: { payment_intent, three_ds_payload? }
opt three_ds_payload present
App->>SDK: mount3DS(three_ds_payload.id, callbacks)
SDK-->>App: callbacks.onSuccess()
App->>SDK: pollPaymentIntentStatus(client_secret, callbacks)
SDK-->>App: callbacks.onSuccess(paymentIntent) / onFailure(paymentIntent)
end
bahn.confirmPayment(payload)
Confirms a payment intent with the card in the mounted card fields. The request is sent from inside the card-number iframe — card details are merged there and never reach your page.
confirmPayment(payload: Bahn.ConfirmPaymentPayload): Promise<{
payment_intent: Record<string, unknown>;
three_ds_payload?: Bahn.ThreeDSPayloadDecoded;
}>;Parameters — ConfirmPaymentPayload:
| Field | Type | Required | Description |
|---|---|---|---|
client_secret | string | Yes | Client secret of the payment intent your backend created |
payment_method.billing_details | ConfirmationTokenBillingDetails | No | Shopper's name, email, phone and address. Card data is added by the iframe — never pass it here |
payment_method_options | Record<string, unknown> | No | Payment-method-specific options |
confirmation_token | string | No | Token from createConfirmationToken(), to confirm with a card tokenized earlier |
amount | string | No | Amount override, in the smallest currency unit |
Returns:
| Field | Type | Description |
|---|---|---|
payment_intent | Record<string, unknown> | The PaymentIntent object. See the API Reference for its fields |
three_ds_payload | ThreeDSPayloadDecoded | undefined | Present only when 3DS authentication is required. Pass its id to mount3DS() |
Rejects with APIError:
| Cause | status | isUserError |
|---|---|---|
mountCard() was not called | 0 | false |
| The card fields are invalid or incomplete | 0 | true |
| The card fields didn't respond within 5 s | 0 | true |
The request was rejected (400, 401, 403, 404, 409, 422) | HTTP status | true |
Rate limit (429) or server error (5xx) | HTTP status | false |
| No response within 30 s, or network error | 0 | false |
When isUserError is true, message is safe to show the shopper. APIError.code is never set by these methods, so branch on status and isUserError.
try {
const result = await bahn.confirmPayment({ client_secret });
// ...
} catch (error) {
if (error?.name === "APIError" && error.isUserError) {
showFieldError(error.message);
} else {
showGenericError();
}
}See Error Handling for the full error model.
Next steps
- 3DS Authentication — challenge options, error codes and the redirect flow
- Type Definitions — every type used on this page
- Integration Examples — complete checkout pages