Skip to content

3DS Authentication ​

The Bahn SDK uses a self-hosted 3D Secure v2 challenge flow — no external CDN dependencies.

This page covers handling a challenge in the browser once it is triggered. To control whether 3DS runs at all — the server-side request_three_d_secure option — see Controlling 3DS.

Sandbox 3DS Behaviour

In the sandbox environment, the 3DS flow depends on the payment amount (VND):

Amount3DS Outcome
< 2.000.000 VNDFrictionless — 3DS passes automatically, no challenge shown
2.000.000 – 5.000.000 VNDChallenge — the full 3DS challenge flow is triggered
> 5.000.000 VNDRejected — the payment is declined

Use these thresholds to test all three 3DS outcomes in sandbox before going live.

bahn.mount3DS(sessionId, callbacks, options?) ​

Mounts the 3DS challenge modal.

typescript
bahn.mount3DS(
  threeDsPayload.id,
  {
    onReady: () => console.log("3DS challenge loaded"),
    onSuccess: () => console.log("3DS authentication passed"),
    onFailure: (error) => console.error("3DS failed:", error.code, error.message),
  },
  { container: "#tds-wrapper" }
);

Parameters:

ParamTypeRequiredDefaultDescription
sessionIdstringYes—3DS session ID from the decoded payload
callbacks.onReady() => voidNo—Fires when the 3DS UI is mounted and ready
callbacks.onSuccess() => voidYes—3DS challenge passed successfully
callbacks.onFailure(error?) => voidYes—3DS challenge failed
optionsThreeDSecureOptionsNo—Challenge options (container selector, etc.)

ThreeDSCallbacks ​

typescript
interface ThreeDSCallbacks {
  onReady?: () => void;
  onSuccess: () => void;
  onFailure: (error?: ComponentError | Record<string, unknown>) => void;
}

The error object passed to onFailure includes:

FieldTypeDescription
codestringMachine-readable error code
messagestringHuman-readable error description

3DS Error Codes ​

The error.code passed to onFailure is one of the kebab-case strings below (e.g. "challenge-failed").

error.code valueDefault message
boot-failedThe 3DS interface failed to initialise. Please try again.
frame-load-errorThe 3DS interface failed to load. Please check your connection and try again.
session-completeThis 3DS session has already been completed. No further action is needed.
session-not-foundThe 3DS session could not be found. Please ensure you are using a valid session ID.
session-unknown-errorAn unexpected error occurred while communicating with the 3DS service.
challenge-failedThe card issuer was unable to verify this transaction. Please try a different payment method.
challenge-cancelledThe verification was cancelled.
challenge-abortedAdditional verification is required but was skipped by your payment configuration.
something-went-wrongSomething went wrong. Please try again.

Switch on the code:

typescript
bahn.mount3DS(sessionId, {
  onSuccess: () => {},
  onFailure: (error) => {
    switch (error?.code) {
      case "challenge-cancelled":
        showMessage("You cancelled verification. Please try again.");
        break;
      case "challenge-failed":
        showMessage("Your bank could not verify this card. Try another card.");
        break;
      default:
        showMessage(error?.message ?? "3DS authentication failed.");
    }
  },
});

Unmounting ​

The modal auto-unmounts when the challenge reaches a terminal state (success or failure). To dismiss it early, call unmount() on the component returned by mount3DS():

typescript
const tds = bahn.mount3DS(sessionId, callbacks);
// later, e.g. user cancels
tds.unmount();

The Challenge Modal ​

3DS challenge modal rendered by the SDK

The modal is rendered by the self-hosted 3DS iframe and mounts into options.container (or the document body if omitted). No external scripts are loaded.


3DS Flow Diagram ​

sequenceDiagram
    participant App as Your App
    participant SDK as Bahn SDK
    participant Iframe as 3DS Iframe
    participant ACS as Issuer ACS

    App->>SDK: confirmPayment(payload)
    SDK-->>App: { payment_intent, three_ds_payload }

    Note over App: three_ds_payload present → 3DS required

    App->>SDK: mount3DS(sessionId, callbacks)
    SDK->>Iframe: Load 3DS challenge
    Iframe->>ACS: Challenge request
    ACS-->>Iframe: Challenge HTML
    Iframe->>ACS: User completes challenge
    ACS-->>Iframe: Challenge result

    alt Challenge passed
        Iframe-->>SDK: success
        SDK-->>App: callbacks.onSuccess()
        App->>SDK: pollPaymentIntentStatus(clientSecret)
    else Challenge failed
        Iframe-->>SDK: failure
        SDK-->>App: callbacks.onFailure({ code, message })
    end

Typical Usage with confirmPayment ​

Full example

A complete checkout page with 3DS: Integration Examples → Confirm Payment.

typescript
// client_secret comes from your backend (create a payment intent server-side)
const { payment_intent, three_ds_payload } = await bahn.confirmPayment({ client_secret });

if (three_ds_payload) {
  bahn.mount3DS(three_ds_payload.id, {
    onSuccess: () => {
      // 3DS passed — poll for the final payment status
      bahn.pollPaymentIntentStatus(client_secret, {
        onProgress: (attempt, max, status) =>
          console.log(`Polling: ${attempt}/${max} — ${status}`),
        onSuccess: (pi) => redirectToSuccess(pi),
        onFailure: (pi) => redirectToFailure(pi),
        onTimeout: () => showTimeoutError(),
        onError: (err) => console.error("Poll error:", err),
      });
    },
    onFailure: (err) => {
      showError(`3DS failed: ${err?.message || "Unknown error"}`);
    },
  });
} else {
  // No 3DS needed — payment intent is ready
  console.log("Payment confirmed:", payment_intent);
}

Standalone Challenge Page (Full-Page Redirect) ​

The SDK supports two 3DS flows:

FlowMethodBest for
Inline iframemount3DS()Web apps where the SDK can mount an overlay on the same page
Full-page redirectgetChallengeRedirectUrl()Mobile SDKs, external checkout pages, or when a full-page redirect is required

How it works ​

  1. Call getChallengeRedirectUrl(sessionId, returnUrl, options?) to build the challenge page URL
  2. Redirect the user's browser to that URL
  3. The challenge page runs the complete 3DS flow (browser fingerprint → challenge → outcome)
  4. On completion, the challenge page redirects back to returnUrl with status and session params

URL Contract ​

Input: {challengePageUrl}?session=X&redirect=X[&app=X][&team=X][&api=X][&brand=X]

ParamRequiredDescription
sessionYes3DS session ID
redirectYesMerchant return URL
appNoTenant app ID for X-Cake-App-Id header
teamNoTenant team ID for X-Cake-Team-Id header
apiNoBackend API base URL (defaults to same-origin)
brandNoBrand name shown while returning; default "Cake"

Output: {returnUrl}?status=success|failure|error&session=X[&code=X]

ParamDescription
statussuccess, failure, or error
session3DS session ID
codeError code (on failure/error)

Usage ​

Full example

The checkout and return pages for this flow: Integration Examples → 3DS on a Separate Page.

typescript
const { three_ds_payload } = await bahn.confirmPayment({ client_secret });

if (three_ds_payload) {
  // Build the full-page redirect URL and navigate
  const url = bahn.getChallengeRedirectUrl(
    three_ds_payload.id,
    "https://merchant.com/callback",
    { brandName: "MyStore" },
  );
  window.location.href = url;
}

On your callback page (https://merchant.com/callback), read the status from the URL:

typescript
const params = new URLSearchParams(window.location.search);
const status = params.get("status"); // "success" | "failure" | "error"
const sessionId = params.get("session");
const code = params.get("code");

Flow Diagram ​

sequenceDiagram
    participant App as Your App
    participant SDK as Bahn SDK
    participant CP as Challenge Page
    participant ACS as Issuer ACS

    App->>SDK: confirmPayment(payload)
    SDK-->>App: { payment_intent, three_ds_payload }

    Note over App: three_ds_payload present → 3DS required

    App->>SDK: getChallengeRedirectUrl(sessionId, returnUrl, options?)
    SDK-->>App: https://bn.cake.vn/.../challenge.html?session=...&redirect=...

    App->>CP: window.location.href = url
    CP->>ACS: Browser fingerprint → challenge → complete
    ACS-->>CP: Challenge result
    CP-->>App: Redirect to returnUrl?status=success&session=...

    Note over App: Read status from URL, proceed accordingly

Developers