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
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
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
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.
createConfirmationToken(payload: Bahn.CreateConfirmationTokenOptions): Promise<Bahn.ConfirmationTokenResult>;Parameters — CreateConfirmationTokenOptions:
| Field | Type | Required | Description |
|---|---|---|---|
payment_method.billing_details | ConfirmationTokenBillingDetails | No | Shopper's name, email, phone and address |
Returns — ConfirmationTokenResult:
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.
handleNextAction(input: Bahn.PaymentIntentInput, callbacks: Bahn.HandleNextActionCallbacks): Promise<void>;input (PaymentIntentInput) accepts either:
client_secret(string) — the SDK fetches the payment intent itself viaget-payment-intent. RequiresmountCard(),mountApplePay(), ormountGooglePay()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. Ifrequires_action, polls payment intent status after the 3DS challenge succeeds until a terminal state is reached.- a
payment_intentobject — if your backend already confirmed the payment and returned thepayment_intentin its response, pass that object directly (must includeclient_secret). This skips theget-payment-intentfetch, so no payment iframe needs to be mounted. It never polls: ifrequires_action,onSuccessfires as soon as the 3DS challenge succeeds, with the samepayment_intentobject you passed in — the terminal status isn't re-verified against the backend.
Callbacks — HandleNextActionCallbacks:
| Callback | Signature | When |
|---|---|---|
onReady | () => void | The 3DS challenge is ready |
onSuccess | (paymentIntent: Record<string, unknown>) => void | Terminal success |
onFailure | (errorOrIntent?: ComponentError | Record<string, unknown>) => void | Terminal failure, or the 3DS challenge failed (ComponentError) |
onTimeout | () => void | Polling reached max attempts without a terminal state |
onError | (error: Error) => void | Polling 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) => void | Each 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):
- Fetches the current payment intent via
get-payment-intent - If
succeeded/requires_capture/requires_confirmation→ callsonSuccessimmediately - If
canceled/failed/requires_payment_method→ callsonFailureimmediately - If
requires_actionwith 3DS → decodes and mounts the 3DS challenge, then polls on success until a terminal state is reached - 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
// 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-intentfetch and never polls afterward. Use theclient_secretinput 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.
pollPaymentIntentStatus(clientSecret: string, callbacks: Bahn.PaymentIntentPollCallbacks): void;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:
| Callback | When |
|---|---|
onSuccess | Terminal success: succeeded, requires_capture, requires_confirmation |
onFailure | Terminal failure: canceled, failed, requires_payment_method |
onTimeout | Max attempts reached without a terminal state |
onError | Max attempts reached and the last poll failed (network, HTTP or malformed response) — receives that error. Failures on earlier attempts are retried silently |
onProgress | Each 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.
window.addEventListener("beforeunload", () => {
bahn.stopPaymentIntentPolling();
});bahn.cleanup() also stops polling, and unmounts every component. See SDK Reference.