Skip to content

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.

typescript
// 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 ​

typescript
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 ​

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

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

typescript
confirmPayment(payload: Bahn.ConfirmPaymentPayload): Promise<{
  payment_intent: Record<string, unknown>;
  three_ds_payload?: Bahn.ThreeDSPayloadDecoded;
}>;

Parameters — ConfirmPaymentPayload:

FieldTypeRequiredDescription
client_secretstringYesClient secret of the payment intent your backend created
payment_method.billing_detailsConfirmationTokenBillingDetailsNoShopper's name, email, phone and address. Card data is added by the iframe — never pass it here
payment_method_optionsRecord<string, unknown>NoPayment-method-specific options
confirmation_tokenstringNoToken from createConfirmationToken(), to confirm with a card tokenized earlier
amountstringNoAmount override, in the smallest currency unit

Returns:

FieldTypeDescription
payment_intentRecord<string, unknown>The PaymentIntent object. See the API Reference for its fields
three_ds_payloadThreeDSPayloadDecoded | undefinedPresent only when 3DS authentication is required. Pass its id to mount3DS()

Rejects with APIError:

CausestatusisUserError
mountCard() was not called0false
The card fields are invalid or incomplete0true
The card fields didn't respond within 5 s0true
The request was rejected (400, 401, 403, 404, 409, 422)HTTP statustrue
Rate limit (429) or server error (5xx)HTTP statusfalse
No response within 30 s, or network error0false

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.

typescript
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 ​

Developers