Skip to content

Integration Examples ​

Complete, copy-paste code for each flow in Payment Flows. Each example is a full page, so it handles every outcome: success, decline, 3DS, a status that isn't final yet, and errors.

ExampleFlowWhat it shows
Confirm PaymentConfirm PaymentHTML checkout page: cardholder name, card fields, confirmPayment(), 3DS, polling
Confirm Payment in ReactConfirm PaymentTyped, reusable PaymentForm component
Token-Based FlowToken-Based FlowBrowser + Node.js server: tokenize, confirm server-side, handleNextAction()
Save a CardSave a CardBrowser + server: create a setup intent, confirmCardSetup()
3DS on a Separate Page3DS AuthenticationFull-page redirect with getChallengeRedirectUrl() and the return page
Apple PayApple PayApple Pay button, direct confirmation
Google PayGoogle PayGoogle Pay button, direct confirmation

Every example loads the SDK with the same version-pinned <script> tag from Getting Started, which defines Bahn as a global. For TypeScript, add bahn.d.ts to your project — the typed examples below rely on it.

The /api/... endpoints the browser calls are your own backend's. Each one makes a signed Bahn API request — see Authentication — and the Token-Based Flow example includes a complete server.


Confirm Payment ​

A checkout page for the Confirm Payment flow. Your backend creates the payment intent when the shopper clicks Pay, and the browser confirms it with the card the shopper entered.

The SDK's card fields cover the card number, expiry and CVC only. Collect the name on the card in an input on your own page, placed after the card fields and styled like them (the .field CSS below), and send it as payment_method.billing_details.name. It isn't card data, so it doesn't need an iframe. The same goes for email and phone.

html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Checkout</title>
  <style>
    body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; color: #101828; }
    #checkout { max-width: 480px; margin: 40px auto; padding: 0 16px; }

    /* Your own field, styled to match the SDK's card fields: 62px box, 8px row gap,
       30px below for an error message — the same rhythm as the card iframes */
    .field { position: relative; box-sizing: border-box; height: 62px; margin: 8px 0 30px; border: 2px solid transparent;
             border-radius: 12px; background: #f8f8f8; transition: border-color 200ms ease; }
    .field:focus-within { border-color: #3b82f6; }
    .field input { box-sizing: border-box; width: 100%; height: 100%; padding: 2rem 0.875rem 0.625rem;
                   border: none; border-radius: 10px; outline: none; background: transparent;
                   font: inherit; font-size: 16px; font-weight: 500; color: #101828; }
    .field input::placeholder { color: transparent; font-weight: 400; }
    .field input:focus::placeholder { color: #737373; }
    .field label { position: absolute; top: 0.5rem; left: 0.875rem; font-size: 12px; color: #737373;
                   pointer-events: none; transition: all 200ms ease; }
    .field input:placeholder-shown:not(:focus) + label { top: 50%; transform: translateY(-50%); }
    .field .required { color: #ef4444; }

    #pay-btn { width: 100%; height: 52px; border: none; border-radius: 12px; background: #2563eb;
               color: #fff; font: inherit; font-size: 16px; font-weight: 600; cursor: pointer; }
    #pay-btn:disabled { opacity: 0.5; cursor: not-allowed; }
    #status { margin-top: 12px; font-size: 14px; color: #4b5563; }
  </style>
</head>
<body>
  <div id="checkout">
    <!-- 1. Card number, expiry and CVC: the SDK's iframes -->
    <div id="card-container"></div>

    <!-- 2. Name on card: your own input — the SDK's card fields don't include it -->
    <div class="field">
      <input id="cardholder-name" autocomplete="cc-name" placeholder="NGUYEN VAN A">
      <label for="cardholder-name">Name on card <span class="required">*</span></label>
    </div>

    <button id="pay-btn" disabled>Pay 100,000 ₫</button>
    <div id="status" role="status"></div>
  </div>

  <script
    src="https://bn.cake.vn/bn-sdk/v2.4.0/index.js"
    integrity="sha384-jBbonUZLgKU82F+X/ITqcmeInXYYwZYdmCDoPFX8wkWL9dCkeUHyi0Fs1EUnkeDS"
    crossorigin="anonymous"
  ></script>
  <script>
    const payBtn = document.getElementById("pay-btn");
    const nameInput = document.getElementById("cardholder-name");
    const setStatus = (msg) => { document.getElementById("status").textContent = msg; };
    const fail = (msg) => { setStatus(msg); payBtn.disabled = false; };
    const succeed = () => { window.location.href = "/success"; };

    const bahn = new Bahn({ pk: "pk_test_your_key_here", environment: "sandbox" });

    const card = bahn.mountCard("#card-container", {
      fields: {
        number: { label: "Card number", placeholder: "1234 5678 9012 3456" },
        expiry: { label: "Expiry date", placeholder: "MM / YY" },
        cvc: { label: "CVC/CVV", tooltip: true },
      },
    });
    // Enable Pay only when both the card fields and the name are filled in
    let cardComplete = false;
    const updatePayButton = () => { payBtn.disabled = !(cardComplete && nameInput.value.trim()); };
    card.on("change", (values) => { cardComplete = Boolean(values?.isComplete); updatePayButton(); });
    nameInput.addEventListener("input", updatePayButton);

    // Polls until the payment reaches a final status
    const pollUntilFinal = (clientSecret) =>
      bahn.pollPaymentIntentStatus(clientSecret, {
        onProgress: () => setStatus("Checking payment status..."),
        onSuccess: succeed,
        onFailure: () => fail("Payment was declined"),
        onTimeout: () => fail("Still processing — check your order status shortly"),
        onError: () => fail("Could not check the payment status"),
      });

    payBtn.addEventListener("click", async () => {
      payBtn.disabled = true;
      setStatus("Processing...");

      try {
        // Create the payment intent when the shopper clicks Pay, not on page load —
        // otherwise every page view creates one. Send your order id, not the amount.
        const res = await fetch("/api/create-payment-intent", {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({ order_id: "order_123" }),
        });
        if (!res.ok) throw new Error("Could not start the payment");
        const { client_secret } = await res.json();

        const { payment_intent, three_ds_payload } = await bahn.confirmPayment({
          client_secret,
          // Card number, expiry and CVC are added inside the iframe; the name comes from your input
          payment_method: {
            billing_details: { name: nameInput.value.trim() },
          },
        });
        const { status } = payment_intent;

        if (three_ds_payload) {
          setStatus("Verifying with your bank...");
          bahn.mount3DS(three_ds_payload.id, {
            // Passing 3DS doesn't mean the payment succeeded — poll for the final status
            onSuccess: () => pollUntilFinal(client_secret),
            onFailure: (err) => fail(`Verification failed: ${err?.message ?? "unknown error"}`),
          });
        } else if (["succeeded", "requires_capture", "requires_confirmation"].includes(status)) {
          succeed();
        } else if (["failed", "canceled", "requires_payment_method"].includes(status)) {
          fail("Payment was declined");
        } else {
          pollUntilFinal(client_secret); // e.g. "processing"
        }
      } catch (err) {
        // isUserError: the message is safe to show (invalid card, rejected request)
        fail(err?.name === "APIError" && err.isUserError ? err.message : "Payment failed — please try again");
      }
    });
  </script>
</body>
</html>

If you restyle the card fields with inputStyle or stateStyles, change the .field CSS the same way so the name field still matches — see Card Payments. Use the sandbox test cards to try the 3DS and decline paths.


Confirm Payment in React ​

The same flow as a reusable, typed PaymentForm component. Load the SDK once with the <script> tag in your index.html, and add bahn.d.ts to your project so Bahn and the Bahn.* types resolve.

The component works with React StrictMode, which mounts effects twice in development: new Bahn() returns the same singleton every time, mountCard() replaces any card already mounted, and the cleanup calls bahn.cleanup(). It is client-only — with server-side rendering (Next.js, Remix), render it only in the browser, because Bahn doesn't exist on the server.

tsx
import { useCallback, useEffect, useRef, useState } from "react";

interface PaymentFormProps {
  orderId: string;
  amountLabel: string; // e.g. "100.000 ₫" — display only; your backend sets the real amount
  onSuccess: (paymentIntent: Record<string, unknown>) => void;
  onError: (message: string) => void;
}

const SUCCESS = ["succeeded", "requires_capture", "requires_confirmation"];
const FAILURE = ["failed", "canceled", "requires_payment_method"];

// A unique, CSS-selector-safe id so several forms can live on one page
let nextId = 0;

export function PaymentForm({ orderId, amountLabel, onSuccess, onError }: PaymentFormProps) {
  const [containerId] = useState(() => `bahn-card-${++nextId}`);
  const bahnRef = useRef<Bahn | null>(null);
  const [isComplete, setIsComplete] = useState(false);
  const [cardholderName, setCardholderName] = useState(""); // the SDK's card fields don't include the name
  const [isProcessing, setIsProcessing] = useState(false);

  useEffect(() => {
    const bahn = new Bahn({
      pk: import.meta.env.VITE_BAHN_PUBLISHABLE_KEY,
      environment: import.meta.env.VITE_BAHN_ENVIRONMENT as Bahn.SDKEnvironment,
    });
    bahnRef.current = bahn;

    const card = bahn.mountCard(`#${containerId}`, {
      fields: {
        number: { label: "Card number" },
        expiry: { label: "Expiry date" },
        cvc: { label: "CVC/CVV" },
      },
    });
    card.on("change", (values) => {
      setIsComplete((values as Bahn.CardComponent["values"] | undefined)?.isComplete ?? false);
    });

    // Unmounts the card and any 3DS challenge, and stops polling
    return () => bahn.cleanup();
  }, [containerId]);

  const handlePay = useCallback(async () => {
    const bahn = bahnRef.current;
    if (!bahn) return;

    const done = (paymentIntent: Record<string, unknown>) => {
      setIsProcessing(false);
      onSuccess(paymentIntent);
    };
    const fail = (message: string) => {
      setIsProcessing(false);
      onError(message);
    };
    const pollCallbacks: Bahn.PaymentIntentPollCallbacks = {
      onSuccess: done,
      onFailure: () => fail("Payment was declined"),
      onTimeout: () => fail("Payment is still processing"),
      onError: () => fail("Could not check the payment status"),
    };

    setIsProcessing(true);
    try {
      const res = await fetch("/api/create-payment-intent", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ order_id: orderId }),
      });
      if (!res.ok) throw new Error("Could not start the payment");
      const { client_secret } = (await res.json()) as { client_secret: string };

      const { payment_intent, three_ds_payload } = await bahn.confirmPayment({
        client_secret,
        payment_method: { billing_details: { name: cardholderName.trim() } },
      });
      const status = payment_intent.status as string;

      if (three_ds_payload) {
        bahn.mount3DS(three_ds_payload.id, {
          // Passing 3DS doesn't mean the payment succeeded — poll for the final status
          onSuccess: () => bahn.pollPaymentIntentStatus(client_secret, pollCallbacks),
          onFailure: (err) => fail(`Verification failed: ${(err as Bahn.ComponentError)?.message ?? "unknown error"}`),
        });
      } else if (SUCCESS.includes(status)) {
        done(payment_intent);
      } else if (FAILURE.includes(status)) {
        fail("Payment was declined");
      } else {
        bahn.pollPaymentIntentStatus(client_secret, pollCallbacks); // e.g. "processing"
      }
    } catch (err) {
      const apiError = err as Bahn.APIError;
      fail(apiError?.name === "APIError" && apiError.isUserError ? apiError.message : "Payment failed");
    }
  }, [orderId, cardholderName, onSuccess, onError]);

  return (
    <div>
      {/* Card number, expiry and CVC: the SDK's iframes */}
      <div id={containerId} />
      {/* Name on card: your own input, after the card fields — style .field like the Confirm Payment example */}
      <div className="field">
        <input
          id={`${containerId}-name`}
          value={cardholderName}
          onChange={(e) => setCardholderName(e.target.value)}
          autoComplete="cc-name"
          placeholder="NGUYEN VAN A"
        />
        <label htmlFor={`${containerId}-name`}>
          Name on card <span className="required">*</span>
        </label>
      </div>
      <button onClick={handlePay} disabled={!isComplete || !cardholderName.trim() || isProcessing}>
        {isProcessing ? "Processing..." : `Pay ${amountLabel}`}
      </button>
    </div>
  );
}

Token-Based Flow ​

The Token-Based Flow end to end, for when your backend decides the final amount after the card is entered:

  1. Browser creates a one-time confirmation token. The card data stays inside the iframe.
  2. Server uses the token to create and confirm a payment intent in one signed Bahn API call, then returns the client_secret.
  3. Browser calls handleNextAction(). The SDK shows the 3DS challenge if one is needed and polls for the final status.

Keep the card mounted from step 1 to step 3

createConfirmationToken() needs the card iframe, and handleNextAction() sends its requests through any mounted card, Apple Pay or Google Pay iframe. If you unmount the card first, handleNextAction() rejects.

Browser (checkout.ts) ​

The page has the same markup as the Confirm Payment example — #card-container for the card fields, then your own #cardholder-name field — plus an #email field styled the same way, and a #pay-btn.

typescript
const bahn = new Bahn({ pk: "pk_test_your_key_here", environment: "sandbox" });
const card = bahn.mountCard("#card-container");
const payBtn = document.getElementById("pay-btn") as HTMLButtonElement;
// Your own inputs — the SDK's card fields don't include name or email
const nameInput = document.getElementById("cardholder-name") as HTMLInputElement;
const emailInput = document.getElementById("email") as HTMLInputElement;

let cardComplete = false;
const updatePayButton = () => { payBtn.disabled = !(cardComplete && nameInput.value.trim()); };
card.on("change", (values) => {
  cardComplete = Boolean((values as Bahn.CardComponent["values"] | undefined)?.isComplete);
  updatePayButton();
});
nameInput.addEventListener("input", updatePayButton);

payBtn.addEventListener("click", async () => {
  payBtn.disabled = true;

  try {
    // Step 1: Create a token — card data stays inside the iframe
    const { confirmation_token } = await bahn.createConfirmationToken({
      payment_method: {
        billing_details: {
          name: nameInput.value.trim(),
          email: emailInput.value.trim() || undefined,
        },
      },
    });

    // Step 2: Your backend confirms with the token and returns the client_secret.
    // Send your order id, not the amount — the backend decides what to charge.
    const res = await fetch("/api/payments/confirm", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ confirmation_token, order_id: "order_123" }),
    });
    if (!res.ok) throw new Error("Payment could not be confirmed");
    const { client_secret } = (await res.json()) as { client_secret: string };

    // Step 3: The SDK runs 3DS if needed and polls until the status is final
    await bahn.handleNextAction(client_secret, {
      onReady: () => console.log("3DS challenge loaded"),
      onSuccess: () => { window.location.href = "/payment/success"; },
      onFailure: () => { window.location.href = "/payment/failed"; },
      onTimeout: () => { window.location.href = "/payment/processing"; },
      // Polling gave up after its last attempt failed — the payment itself may still succeed
      onError: () => { window.location.href = "/payment/processing"; },
    });
  } catch (err) {
    // Rejected before any callback: invalid card, request failed, or no iframe mounted
    console.error("Payment error:", err);
    payBtn.disabled = false;
  }
});

Server (Node.js / Express) ​

The backend call is a regular Bahn REST API request: your API key goes in Bn-Api-Key, and the body is JWS-signed with your private signing key. See Authentication for how the keys are issued and what each header means. The example signs with the jose package, and the Save a Card example reuses its bahnPost helper.

typescript
import express from "express";
import { randomUUID } from "node:crypto";
import { CompactSign, importJWK } from "jose";

const app = express();
app.use(express.json());

const BAHN_API = "https://bn.cakedigibank.tech"; // production: https://bn.cake.vn
const API_KEY = process.env.BAHN_API_KEY!;               // issued at onboarding — server-side only
const SIGNING_KID = process.env.BAHN_SIGNING_KID!;       // kid of the public key you registered
const signingKey = await importJWK(JSON.parse(process.env.BAHN_SIGNING_PRIVATE_JWK!), "ES256");

// Sign a JSON body for one endpoint and POST it to Bahn
async function bahnPost(path: string, body: unknown, idempotencyKey: string) {
  const jws = await new CompactSign(new TextEncoder().encode(JSON.stringify(body)))
    .setProtectedHeader({
      alg: "ES256",
      kid: SIGNING_KID,
      typ: "jose+json",
      url: path,                            // binds the signature to this endpoint
      iat: Math.floor(Date.now() / 1000),
    })
    .sign(signingKey);

  return fetch(`${BAHN_API}${path}`, {
    method: "POST",
    headers: {
      "Content-Type": "application/jose",
      "Bn-Api-Key": API_KEY,
      "Bn-Jose-Method": "jws",
      "Bn-Request-Id": randomUUID(),        // new value on every attempt
      "Bn-Idempotency-Key": idempotencyKey, // same value across retries
    },
    body: jws,
  });
}

// POST /api/payments/confirm — create and confirm a payment intent with the token
app.post("/api/payments/confirm", async (req, res) => {
  const { confirmation_token, order_id } = req.body;

  // Look up the amount on your side — never trust an amount sent by the browser
  const order = await findOrder(order_id);

  const response = await bahnPost(
    "/api/payment_intents",
    {
      amount: order.amount,  // integer, smallest currency unit (VND: đồng)
      currency: "704",       // ISO 4217 numeric code for VND
      confirmation_token,
      confirm: true,
      metadata: { order_id },
    },
    `order_${order_id}_pay`,
  );

  if (!response.ok) {
    const err = await response.json();
    return res.status(response.status).json({ error: err.message ?? "Payment failed" });
  }

  // Bahn wraps the created object: { payment_intent: { ... } }
  const { payment_intent } = await response.json();
  // Return only the client_secret; the browser passes it to handleNextAction()
  res.json({ client_secret: payment_intent.client_secret });
});

app.listen(3000);

findOrder stands in for your own order lookup. Record the final payment status from the webhook, not from this response — see Webhooks.

Variant: return the payment_intent instead ​

If your server returns the whole payment_intent (res.json({ payment_intent })), pass it to handleNextAction() directly. The SDK skips fetching it and doesn't need a mounted iframe — but it also never polls, so a status that isn't final yet calls onError. See Token-Based Flow → handleNextAction.

typescript
const { payment_intent } = await res.json();

await bahn.handleNextAction(payment_intent, {
  onSuccess: () => { window.location.href = "/payment/success"; },
  onFailure: () => { window.location.href = "/payment/failed"; },
  // e.g. "processing" — show a pending page and rely on the webhook
  onError: () => { window.location.href = "/payment/processing"; },
});

Save a Card ​

The Save a Card flow: save a card to a customer without charging it, so you can charge it later. See Save Card & Recurring for creating the customer and charging the saved card.

Browser ​

The same layout as the Confirm Payment example: the card fields, then the name field.

html
<!doctype html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Save a card</title>
  <style>
    body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; color: #101828; }
    #checkout { max-width: 480px; margin: 40px auto; padding: 0 16px; }

    /* Your own field, styled to match the SDK's card fields: 62px box, 8px row gap,
       30px below for an error message — the same rhythm as the card iframes */
    .field { position: relative; box-sizing: border-box; height: 62px; margin: 8px 0 30px; border: 2px solid transparent;
             border-radius: 12px; background: #f8f8f8; transition: border-color 200ms ease; }
    .field:focus-within { border-color: #3b82f6; }
    .field input { box-sizing: border-box; width: 100%; height: 100%; padding: 2rem 0.875rem 0.625rem;
                   border: none; border-radius: 10px; outline: none; background: transparent;
                   font: inherit; font-size: 16px; font-weight: 500; color: #101828; }
    .field input::placeholder { color: transparent; font-weight: 400; }
    .field input:focus::placeholder { color: #737373; }
    .field label { position: absolute; top: 0.5rem; left: 0.875rem; font-size: 12px; color: #737373;
                   pointer-events: none; transition: all 200ms ease; }
    .field input:placeholder-shown:not(:focus) + label { top: 50%; transform: translateY(-50%); }
    .field .required { color: #ef4444; }

    #save-btn { width: 100%; height: 52px; border: none; border-radius: 12px; background: #2563eb;
                color: #fff; font: inherit; font-size: 16px; font-weight: 600; cursor: pointer; }
    #save-btn:disabled { opacity: 0.5; cursor: not-allowed; }
    #status { margin-top: 12px; font-size: 14px; color: #4b5563; }
  </style>
</head>
<body>
  <div id="checkout">
    <!-- 1. Card number, expiry and CVC: the SDK's iframes -->
    <div id="card-container"></div>

    <!-- 2. Name on card: your own input — the SDK's card fields don't include it -->
    <div class="field">
      <input id="cardholder-name" autocomplete="cc-name" placeholder="NGUYEN VAN A">
      <label for="cardholder-name">Name on card <span class="required">*</span></label>
    </div>

    <button id="save-btn" disabled>Save card</button>
    <div id="status" role="status"></div>
  </div>

  <script
    src="https://bn.cake.vn/bn-sdk/v2.4.0/index.js"
    integrity="sha384-jBbonUZLgKU82F+X/ITqcmeInXYYwZYdmCDoPFX8wkWL9dCkeUHyi0Fs1EUnkeDS"
    crossorigin="anonymous"
  ></script>
  <script>
    const saveBtn = document.getElementById("save-btn");
    const nameInput = document.getElementById("cardholder-name");
    const setStatus = (msg) => { document.getElementById("status").textContent = msg; };
    const fail = (msg) => { setStatus(msg); saveBtn.disabled = false; };
    const saved = () => { window.location.href = "/account/cards?saved=1"; };

    const bahn = new Bahn({ pk: "pk_test_your_key_here", environment: "sandbox" });
    const card = bahn.mountCard("#card-container", {
      fields: {
        number: { label: "Card number" },
        expiry: { label: "Expiry date" },
        cvc: { label: "CVC/CVV" },
      },
    });

    // Enable Save only when both the card fields and the name are filled in
    let cardComplete = false;
    const updateSaveButton = () => { saveBtn.disabled = !(cardComplete && nameInput.value.trim()); };
    card.on("change", (values) => { cardComplete = Boolean(values?.isComplete); updateSaveButton(); });
    nameInput.addEventListener("input", updateSaveButton);

    const pollUntilFinal = (clientSecret) =>
      bahn.pollSetupIntentStatus(clientSecret, {
        onSuccess: saved,
        onFailure: () => fail("The card could not be saved"),
        onTimeout: () => fail("Still verifying — check your saved cards shortly"),
        onError: () => fail("Could not check the card status"),
      });

    saveBtn.addEventListener("click", async () => {
      saveBtn.disabled = true;
      setStatus("Saving...");

      try {
        // Your backend creates a setup intent for the signed-in customer
        const res = await fetch("/api/setup-intents", { method: "POST" });
        if (!res.ok) throw new Error("Could not start saving the card");
        const { client_secret } = await res.json();

        const { setup_intent, three_ds_payload } = await bahn.confirmCardSetup({
          client_secret,
          payment_method: { billing_details: { name: nameInput.value.trim() } },
        });

        if (three_ds_payload) {
          setStatus("Verifying with your bank...");
          bahn.mount3DS(three_ds_payload.id, {
            onSuccess: () => pollUntilFinal(client_secret),
            onFailure: (err) => fail(`Verification failed: ${err?.message ?? "unknown error"}`),
          });
        } else if (setup_intent.status === "succeeded") {
          saved();
        } else if (["failed", "canceled", "requires_payment_method"].includes(setup_intent.status)) {
          fail("The card could not be saved");
        } else {
          pollUntilFinal(client_secret);
        }
      } catch (err) {
        fail(err?.name === "APIError" && err.isUserError ? err.message : "Could not save the card");
      }
    });
  </script>
</body>
</html>

Server ​

Add this route to the Token-Based Flow server — it reuses bahnPost.

typescript
// POST /api/setup-intents — create a setup intent for the signed-in customer
app.post("/api/setup-intents", async (req, res) => {
  const customerId = await getBahnCustomerId(req); // your user → Bahn customer_id mapping

  const response = await bahnPost(
    "/api/setup_intents",
    {
      customer_id: customerId,
      usage: "off_session", // you'll charge the card without the customer present
      description: "Save card for later payments",
    },
    `save_card_${customerId}_${randomUUID()}`,
  );

  if (!response.ok) {
    const err = await response.json();
    return res.status(response.status).json({ error: err.message ?? "Could not create setup intent" });
  }

  const { setup_intent } = await response.json();
  res.json({ client_secret: setup_intent.client_secret });
});

getBahnCustomerId stands in for your own lookup of the Bahn customer you created for this user. The saved card's payment method arrives on the setup intent and its webhook — see Save Card & Recurring → Retrieve the Result.


3DS on a Separate Page ​

Instead of the overlay mount3DS() shows, send the shopper to Cake's standalone challenge page and back. Use it when an overlay isn't possible — inside a mobile app's WebView, or on an external checkout page. See 3DS Authentication → Standalone Challenge Page for the URL contract.

Checkout page ​

In the Confirm Payment example, replace the mount3DS() call with a redirect:

javascript
// Inside the Pay button's click handler, replacing the mount3DS() branch
const { payment_intent, three_ds_payload } = await bahn.confirmPayment({ client_secret });

if (three_ds_payload) {
  window.location.href = bahn.getChallengeRedirectUrl(
    three_ds_payload.id,
    `${window.location.origin}/checkout/return?order_id=order_123`,
    { brandName: "My Store" },
  );
  return;
}

Return page (/checkout/return) ​

The challenge page redirects back with status (success, failure or error), session and, on failure, code. A success status only means 3DS passed, and this page has no card mounted to poll through — so ask your backend for the payment's final status.

javascript
async function handleChallengeReturn() {
  const params = new URLSearchParams(window.location.search);
  const status = params.get("status");
  const orderId = params.get("order_id");

  if (status !== "success") {
    // e.g. "challenge-failed", "challenge-cancelled" — see 3DS Error Codes
    showError(`Verification failed (${params.get("code") ?? status})`);
    return;
  }

  // Your backend retrieves the payment intent (GET /api/payment_intents/{id}) and returns its status
  const { payment_status } = await fetch(`/api/orders/${orderId}/payment-status`).then((r) => r.json());

  if (["succeeded", "requires_capture", "requires_confirmation"].includes(payment_status)) {
    window.location.href = "/success";
  } else if (["failed", "canceled", "requires_payment_method"].includes(payment_status)) {
    showError("Payment was declined");
  } else {
    window.location.href = "/payment/processing"; // not final yet — rely on the webhook
  }
}

handleChallengeReturn();

See 3DS Error Codes for every code value.


Apple Pay ​

An Apple Pay button using direct confirmation: pass a client_secret and the button confirms the payment and runs 3DS itself. To use the token-based flow instead, pass undefined and listen for "token" — see Apple Pay.

Unlike the Confirm Payment example, this creates the payment intent when the page loads: direct confirmation needs the client_secret before the button mounts. To avoid creating an intent for every page view, use the token-based flow instead, which creates nothing until the shopper pays.

isApplePayAvailable() is optional. mountApplePay() renders nothing when Apple Pay can't be used; call the check first only to hide your own surrounding UI, like the "Or pay with" section here.

html
<section id="wallet-section" hidden>
  <p>Or pay with</p>
  <div id="apple-pay-container"></div>
</section>
<div id="status" role="status"></div>

<script
  src="https://bn.cake.vn/bn-sdk/v2.4.0/index.js"
  integrity="sha384-jBbonUZLgKU82F+X/ITqcmeInXYYwZYdmCDoPFX8wkWL9dCkeUHyi0Fs1EUnkeDS"
  crossorigin="anonymous"
></script>
<script>
  const setStatus = (msg) => { document.getElementById("status").textContent = msg; };
  const bahn = new Bahn({ pk: "pk_test_your_key_here", environment: "sandbox" });

  async function setUpApplePay() {
    if (!(await bahn.isApplePayAvailable())) return; // keep the section hidden
    document.getElementById("wallet-section").hidden = false; // show it before mounting the button

    // client_secret comes from your backend (create a payment intent server-side)
    const { client_secret } = await fetch("/api/create-payment-intent", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ order_id: "order_123" }),
    }).then((r) => r.json());

    const ap = bahn.mountApplePay("#apple-pay-container", client_secret, {
      amount: 100000, // smallest currency unit — VND as-is
      currency: "VND",
      country: "VN",
      // merchantName, supportedNetworks and merchantCapabilities default to
      // your merchant's configured values — set them only to override
    });

    ap.on("ready", () => setStatus(""));
    // Show the result page; your backend records the final status from the webhook
    ap.on("success", () => { window.location.href = "/success"; });
    ap.on("error", (err) => setStatus(err?.message || "Payment failed"));
    ap.on("cancel", () => setStatus("Payment cancelled"));
  }

  setUpApplePay();
</script>

Google Pay ​

A Google Pay button using direct confirmation, the same way as Apple Pay above — including creating the payment intent when the page loads. Pass undefined instead of the client_secret for the token-based flow, which creates nothing until the shopper pays — see Google Pay.

html
<section id="wallet-section" hidden>
  <p>Or pay with</p>
  <div id="google-pay-container"></div>
</section>
<div id="status" role="status"></div>

<script
  src="https://bn.cake.vn/bn-sdk/v2.4.0/index.js"
  integrity="sha384-jBbonUZLgKU82F+X/ITqcmeInXYYwZYdmCDoPFX8wkWL9dCkeUHyi0Fs1EUnkeDS"
  crossorigin="anonymous"
></script>
<script>
  const setStatus = (msg) => { document.getElementById("status").textContent = msg; };
  const bahn = new Bahn({ pk: "pk_test_your_key_here", environment: "sandbox" });

  async function setUpGooglePay() {
    if (!(await bahn.isGooglePayAvailable())) return; // keep the section hidden
    document.getElementById("wallet-section").hidden = false; // show it before mounting the button

    // client_secret comes from your backend (create a payment intent server-side)
    const { client_secret } = await fetch("/api/create-payment-intent", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ order_id: "order_123" }),
    }).then((r) => r.json());

    const gp = bahn.mountGooglePay("#google-pay-container", client_secret, {
      amount: 100000, // smallest currency unit — VND as-is
      currency: "VND",
      country: "VN",
    });

    gp.on("ready", () => setStatus(""));
    // Show the result page; your backend records the final status from the webhook
    gp.on("success", () => { window.location.href = "/success"; });
    gp.on("error", (err) => setStatus(err?.message || "Payment failed"));
    gp.on("cancel", () => setStatus("Payment cancelled"));
  }

  setUpGooglePay();
</script>

Developers