Skip to content

Token-Based Flow ​

Tokenize the card in the browser, send the token to your backend, and let the backend create and confirm the payment. Use this flow when your server decides the final amount after seeing the card — discounts, surcharges, fraud checks — or when you confirm payments server-side.

If the amount is known before checkout, Confirm Payment is simpler.

Apple Pay and Google Pay use this same flow when you call bahn.mountApplePay()/bahn.mountGooglePay() without a clientSecret: the button emits a "token" event with a confirmation_token instead of confirming the payment, and you continue from step 2.

Full example

Browser and Node.js server code for this flow: Integration Examples → Token-Based Flow.

Step 1: Create a confirmation token ​

typescript
const bahn = new Bahn({ pk: "pk_live_...", environment: "production" });
bahn.mountCard("#card-container");

const { confirmation_token } = await bahn.createConfirmationToken({
  payment_method: {
    billing_details: {
      name: "Nguyen Van A",
      email: "[email protected]",
      phone: "+84901234567",
      address: {
        country: "VN",
        city: "Ho Chi Minh City",
        line1: "123 Nguyen Hue",
        line2: "District 1",
        postal_code: "70000",
      },
    },
  },
});

Step 2: Send the token to your backend ​

typescript
const response = await fetch("/api/confirm-payment", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    confirmation_token,
    amount: 100000,
    currency: "vnd",
  }),
});

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

Your backend creates and confirms the payment intent with the token. See Finalize Payments Server-Side.

Step 3: Handle the next action on the client ​

typescript
try {
  await bahn.handleNextAction(client_secret, {
    onReady: () => console.log("Setting up..."),
    onSuccess: (paymentIntent) => redirectToSuccess(paymentIntent),
    onFailure: (paymentIntentOrError) => redirectToFailure(),
    onTimeout: () => console.warn("Polling timed out"),
    onError: (error) => console.error("Error:", error),
    onProgress: (attempt, max, status) => console.log(`Polling: ${attempt}/${max} — ${status}`),
  });
} catch (error) {
  // No payment iframe mounted, or the payment intent could not be fetched
  showGenericError();
}

How it works ​

sequenceDiagram
    participant App as Your App
    participant SDK as Bahn SDK
    participant Iframe as Card Iframe
    participant Backend as Your Backend
    participant API as Cake API

    App->>SDK: mountCard
    App->>SDK: createConfirmationToken({ payment_method })
    SDK->>Iframe: Delegate + merge card data
    Iframe->>API: POST /api/sdk/confirmation_token
    API-->>Iframe: { confirmation_token }
    Iframe-->>SDK: Result (card data stays in iframe)
    SDK-->>App: { confirmation_token }

    App->>Backend: Send token + amount/currency
    Backend->>API: Create and confirm PaymentIntent with token
    API-->>Backend: PaymentIntent
    Backend-->>App: { client_secret }

    App->>SDK: handleNextAction(client_secret, callbacks)
    SDK->>Iframe: get-payment-intent (relay)
    Iframe->>API: GET payment intent status
    API-->>Iframe: PaymentIntent
    Iframe-->>SDK: PaymentIntent

    alt requires_action (3DS)
        SDK->>SDK: Decode 3DS payload, mount challenge
        SDK->>SDK: On success → start polling (via Iframe)
    else succeeded
        SDK-->>App: callbacks.onSuccess(paymentIntent)
    else failed
        SDK-->>App: callbacks.onFailure(paymentIntent)
    end

bahn.createConfirmationToken(payload) ​

Creates a one-time token for the card in the mounted card fields. Card data is merged inside the iframe — the token is safe to send to your backend.

typescript
createConfirmationToken(payload: Bahn.CreateConfirmationTokenOptions): Promise<Bahn.ConfirmationTokenResult>;

Parameters — CreateConfirmationTokenOptions:

FieldTypeRequiredDescription
payment_method.billing_detailsConfirmationTokenBillingDetailsNoShopper's name, email, phone and address

Returns — ConfirmationTokenResult:

typescript
interface ConfirmationTokenResult {
  confirmation_token: string;
}

Rejects with APIError if pk is not configured, plus every case listed for confirmPayment(): mountCard() not called, invalid card fields, HTTP and network errors.


bahn.handleNextAction(input, callbacks) ​

Handles the next required action for a payment intent: a 3DS challenge, polling, or an immediate terminal state.

typescript
handleNextAction(input: Bahn.PaymentIntentInput, callbacks: Bahn.HandleNextActionCallbacks): Promise<void>;

input (PaymentIntentInput) accepts either:

  • client_secret (string) — the SDK fetches the payment intent itself via get-payment-intent. Requires mountCard(), mountApplePay(), or mountGooglePay() to be active — this call must go through one of those iframes (the backend doesn't allow CORS on this endpoint, so a direct fetch from your own page would be blocked). It never touches card data, so it doesn't need the card iframe specifically — an Apple Pay or Google Pay iframe left mounted after its token-based flow completes works just as well. If requires_action, polls payment intent status after the 3DS challenge succeeds until a terminal state is reached.
  • a payment_intent object — if your backend already confirmed the payment and returned the payment_intent in its response, pass that object directly (must include client_secret). This skips the get-payment-intent fetch, so no payment iframe needs to be mounted. It never polls: if requires_action, onSuccess fires as soon as the 3DS challenge succeeds, with the same payment_intent object you passed in — the terminal status isn't re-verified against the backend.

Callbacks — HandleNextActionCallbacks:

CallbackSignatureWhen
onReady() => voidThe 3DS challenge is ready
onSuccess(paymentIntent: Record<string, unknown>) => voidTerminal success
onFailure(errorOrIntent?: ComponentError | Record<string, unknown>) => voidTerminal failure, or the 3DS challenge failed (ComponentError)
onTimeout() => voidPolling reached max attempts without a terminal state
onError(error: Error) => voidPolling ran out of attempts and the last poll failed (see pollPaymentIntentStatus), or a payment_intent object input has a non-terminal status
onProgress(attempt: number, maxAttempts: number, status: string) => voidEach poll that returned a payment intent

Rejects with APIError — client_secret input only — before any callback fires, when no payment iframe is mounted or the initial get-payment-intent fetch fails (HTTP or network error). Everything after that, including 3DS and polling outcomes, is reported through the callbacks. The payment_intent object input never rejects.

How it works (client_secret input):

  1. Fetches the current payment intent via get-payment-intent
  2. If succeeded/requires_capture/requires_confirmation → calls onSuccess immediately
  3. If canceled/failed/requires_payment_method → calls onFailure immediately
  4. If requires_action with 3DS → decodes and mounts the 3DS challenge, then polls on success until a terminal state is reached
  5. For other statuses (processing) → polls the payment intent status every 3 seconds until a terminal state is reached or the max attempts are exhausted

How it works (payment_intent object input): identical to above for steps 1–3 (no fetch needed, status is read straight off the object), but step 4 differs — after the 3DS challenge succeeds, onSuccess is called immediately with the same payment_intent object, with no polling and no payment iframe involved. A non-terminal, non-requires_action status (e.g. processing) calls onError — this path never polls, so there's no way to resolve it further from here.

Passing a payment_intent your own backend already has ​

typescript
// Your backend's response already includes the payment_intent, e.g.:
// { payment_intent: { id, client_secret, status: "requires_action", next_action: {...}, ... } }
const { payment_intent } = await fetch("/api/confirm-payment", { method: "POST" }).then((r) => r.json());

await bahn.handleNextAction(payment_intent, {
  onSuccess: (paymentIntent) => redirectToSuccess(),
  onFailure: () => redirectToFailure(),
});

Note: this path skips the initial get-payment-intent fetch and never polls afterward. Use the client_secret input instead if you need the terminal status re-verified (e.g. your backend applies additional risk checks after 3DS) — mountCard()/mountApplePay()/mountGooglePay() is required in that case, since polling always relays through a mounted payment iframe (CORS).


bahn.pollPaymentIntentStatus(clientSecret, callbacks) ​

Polls the payment intent status every 3 seconds until a terminal state is reached or the max attempts are exhausted. handleNextAction() calls this for you; call it directly after a mount3DS() challenge in the Confirm Payment flow.

typescript
pollPaymentIntentStatus(clientSecret: string, callbacks: Bahn.PaymentIntentPollCallbacks): void;
typescript
bahn.pollPaymentIntentStatus("pi_secret_abc", {
  onProgress: (attempt, max, status) => console.log(`Poll ${attempt}/${max}: ${status}`),
  onSuccess: (paymentIntent) => redirectToSuccess(),
  onFailure: (paymentIntent) => redirectToFailure(),
  onTimeout: () => showTimeoutMessage(),
  onError: (error) => console.error("Poll error:", error.message),
});

Callbacks — PaymentIntentPollCallbacks:

CallbackWhen
onSuccessTerminal success: succeeded, requires_capture, requires_confirmation
onFailureTerminal failure: canceled, failed, requires_payment_method
onTimeoutMax attempts reached without a terminal state
onErrorMax attempts reached and the last poll failed (network, HTTP or malformed response) — receives that error. Failures on earlier attempts are retried silently
onProgressEach poll that returned an intent — receives (attempt, maxAttempts, status)

Max attempts is configured via paymentIntentPollAttempts in BahnOptions (default: 10).

bahn.stopPaymentIntentPolling() ​

Stops any active payment intent polling.

typescript
window.addEventListener("beforeunload", () => {
  bahn.stopPaymentIntentPolling();
});

bahn.cleanup() also stops polling, and unmounts every component. See SDK Reference.

Developers