Save Card & Recurring Payments
Save a customer's card once and reuse it for later payments without asking for the card number again. A SetupIntent tokenizes and verifies the card up front (no charge), returning a reusable payment_method_id. You then charge that saved card in one of two ways:
- CIT (Customer-Initiated Transaction) — the customer is present and actively pays, e.g. selecting a saved card at checkout or one-click pay. Set
off_session=false. - MIT (Merchant-Initiated Transaction) — the merchant charges the card with no customer present, e.g. subscription renewals or scheduled billing. Set
off_session=true.
usage | Initiator | When to use | Charge with |
|---|---|---|---|
on_session | Customer (CIT) | Customer is present and selects a saved card at checkout. | off_session=false |
off_session | Merchant (MIT) | Recurring / subscription / scheduled charges, customer absent. | off_session=true |
Preliminary
High-Level Flow
sequenceDiagram
autonumber
participant User as User
participant MerchantWeb as Merchant Web<br/>(Frontend)
participant SDK as SDK<br/>(Embedded)
participant MerchantAPI as Merchant API<br/>(Backend)
participant API as Bahn API
Note over User, API: Part 1 — Save the card (SetupIntent)
MerchantAPI ->> API: CreateCustomer (once per customer)
API -->> MerchantAPI: {customer_id}
User ->> MerchantWeb: Choose "Save my card"
MerchantWeb ->> MerchantAPI: Request setup session
MerchantAPI ->> API: CreateSetupIntent(customer_id, usage)
API -->> MerchantAPI: {setup_intent, client_secret}
MerchantAPI -->> MerchantWeb: {client_secret}
MerchantWeb ->> SDK: Initialize SDK with client_secret
SDK ->> API: ConfirmSetupIntent(card)
Note over API: 3DS always required for setup<br/>verification authorization (0 or 5,000 VND, per merchant rules) to confirm the card
API -->> SDK: requires_action (3DS challenge)
User ->> SDK: Complete 3DS challenge
Note over API: SetupIntent → succeeded<br/>card attached to customer
MerchantAPI ->> API: GetSetupIntent(id)
API -->> MerchantAPI: {status: succeeded, payment_method_id}
Note over User, API: Part 2a — Charge later, customer present (CIT)
User ->> MerchantWeb: Select saved card & pay
MerchantWeb ->> MerchantAPI: Pay with payment_method_id
MerchantAPI ->> API: CreatePaymentIntent(amount, customer_id,<br/>payment_method_id, confirm=true, off_session=false)
Note over API: evaluate merchant 3DS rules
alt 3DS required
API -->> MerchantAPI: {status: requires_action, client_secret}
Note over MerchantWeb, SDK: SDK completes 3DS challenge live
else non-3DS (rules skip authentication)
API -->> MerchantAPI: {status: processing}
else 3DS exemption (wallet token / SCA exemption)
API -->> MerchantAPI: {status: processing}
end
API ->> MerchantAPI: Webhook: payment_intent.status.succeeded
Note over MerchantAPI, API: Part 2b — Charge later, no customer (MIT)
MerchantAPI ->> API: CreatePaymentIntent(amount, customer_id,<br/>payment_method_id, confirm=true, off_session=true)
Note over API: payment method must be off_session_enabled<br/>evaluate merchant 3DS rules
alt non-3DS or 3DS exemption (typical for MIT)
API -->> MerchantAPI: {status: processing}
else 3DS required
API -->> MerchantAPI: {status: requires_action}
Note over MerchantAPI, API: no customer to complete challenge — payment cannot proceed
end
API ->> MerchantAPI: Webhook: payment_intent.status.succeeded
How It Works
Phase 1 — Create a SetupIntent
- Create a customer — Create the customer once (via
CreateCustomer) and reuse thecustomer_id. Saved cards are attached to a customer. - Create a SetupIntent — Your backend calls
CreateSetupIntentwith thecustomer_idand the intendedusage(on_sessionoroff_session). Bahn returns thesetup_intentand itsclient_secret. - Forward only the
client_secret— Return{ client_secret }to your frontend; thesetup_intent.idand API key stay on your backend.
Phase 2 — Confirm & Verify (SDK)
- SDK collects the card — The embedded SDK renders secure card fields and confirms the SetupIntent with the
client_secret. Your frontend never handles raw card data. - 3DS challenge — Unlike a payment, 3DS is always required for a setup. The SDK presents the challenge and the customer completes it.
- Verification authorization — Bahn runs an account-verification authorization against the card to confirm it is live — 0 or 5,000 VND, depending on your merchant rules. No funds are captured.
3DS and the micro-authorization run asynchronously after the customer completes the challenge. The SetupIntent moves
requires_payment_method → requires_action → processing → succeeded. If verification fails it returns torequires_payment_methodwithlast_setup_errorset, and the customer can retry with another card.
Phase 3 — Retrieve the Saved Card
On success, Bahn attaches the card to the customer and populates payment_method_id on the SetupIntent. Retrieve the SetupIntent to read it once the status is succeeded. Store the payment_method_id against your customer record — it is the handle you use for all future charges.
Phase 4 — Charge the Saved Card (CIT / MIT)
Create and confirm a PaymentIntent that references the saved payment_method_id. Use off_session=false when the customer is present (CIT) and off_session=true when they are not (MIT). From there, authorization, webhooks, and capture behave exactly like the One Time Payment flow.
For subscriptions and instalment plans, the MIT also has to state which agreement it exercises — see Recurring & Unscheduled MIT for establishing a recurring mandate and charging against it.
Merchant API Responsibilities
1. Create a Customer
Saved cards are attached to a customer. Create the customer once and reuse the customer_id for all future SetupIntents and payments. The merchant_id is derived from your API key.
POST /api/customersRequest
{
"name": "Nguyen Van A",
"email": "[email protected]",
"phone": "+84901234567"
}| Field | Required | Description |
|---|---|---|
name | No | Customer's full name. |
email | No | Customer's email address. |
phone | No | Customer's phone number. |
metadata | No | Arbitrary key-value pairs for storing additional information. |
Address fields — addr_line1, addr_line2, ward, district, province, postal_code, country — are also accepted and optional.
Response
{
"customer": {
"id": "cus_xxxxxxxx",
"name": "Nguyen Van A",
"email": "[email protected]",
"status": "active"
}
}Store the returned
customer.idagainst your user record. You pass it ascustomer_idwhen creating SetupIntents and when charging saved cards.
2. Create a SetupIntent
When the customer opts to save a card, create a SetupIntent for them. Bahn returns a client_secret the SDK uses to confirm on the customer's behalf.
POST /api/setup_intentsRequest
{
"customer_id": "cus_xxxxxxxx",
"usage": "off_session",
"description": "Save card for subscription",
"metadata": {
"order_id": "1234"
}
}| Field | Required | Description |
|---|---|---|
customer_id | Yes | The customer the card is saved against. |
usage | Yes | on_session (customer-present reuse) or off_session (merchant-initiated reuse). |
description | No | Human-readable description. |
metadata | No | Arbitrary key-value pairs for storing additional information. |
Response
{
"setup_intent": {
"id": "seti_xxxxxxxx",
"client_secret": "seti_xxxxxxxx_secret_xxxxxxxx",
"status": "requires_payment_method",
"usage": "off_session",
"customer_id": "cus_xxxxxxxx"
}
}Return only the
client_secretto your frontend — the SDK uses it to initialize and confirm the setup. Keepsetup_intent.idon your backend, and never expose your API key to the client.
3. Retrieve the Result
After the customer completes 3DS, verification finishes asynchronously. Retrieve the SetupIntent to confirm it succeeded and read the resulting payment_method_id.
GET /api/setup_intents/{id}Response
{
"setup_intent": {
"id": "seti_xxxxxxxx",
"status": "succeeded",
"payment_method_id": "pm_xxxxxxxx",
"usage": "off_session",
"customer_id": "cus_xxxxxxxx"
}
}| Status | Meaning |
|---|---|
requires_action | Waiting for the customer to complete the 3DS challenge. |
processing | 3DS done; awaiting the verification micro-authorization result. |
succeeded | Card verified and saved. payment_method_id is now available for reuse. |
requires_payment_method | Verification failed (see last_setup_error). Have the customer retry with another card. |
4. Charge a Saved Card — Customer Present (CIT)
When the customer is at checkout and selects a saved card, create and confirm a PaymentIntent referencing the payment_method_id with off_session=false. Whether this charge skips or requires 3DS depends on your merchant rules. If a rule requires it, Bahn returns requires_action and a client_secret so the SDK can complete the challenge live; otherwise the charge proceeds without a challenge.
POST /api/payment_intentsRequest
{
"amount": "10000",
"currency": "usd",
"customer_id": "cus_xxxxxxxx",
"payment_method_id": "pm_xxxxxxxx",
"confirm": true,
"off_session": false
}| Field | Required | Description |
|---|---|---|
customer_id | Yes | Must own the payment method. |
payment_method_id | Yes | The saved card returned by the SetupIntent. |
confirm | Yes | Set to true to create and confirm in one call. |
off_session | No | false (default) for customer-present payments. |
5. Charge a Saved Card — No Customer (MIT)
For subscriptions, renewals, or scheduled billing, create and confirm a PaymentIntent with off_session=true. The payment method must have been saved with usage=off_session (it must be off-session enabled), otherwise the request is rejected.
POST /api/payment_intentsRequest
{
"amount": "10000",
"currency": "usd",
"customer_id": "cus_xxxxxxxx",
"payment_method_id": "pm_xxxxxxxx",
"confirm": true,
"off_session": true
}Whether an MIT charge skips or requires 3DS depends on your merchant rules. Since no customer is present to complete a challenge, MIT is typically configured to skip 3DS — the card was already authenticated when it was saved, and that authentication carries forward.
off_session=truerequirespayment_method_id— inline card data and confirmation tokens are rejected.
6. Payment Outcomes — 3DS, Non-3DS & Exemption
When you confirm a PaymentIntent with a saved payment_method_id, Bahn evaluates your 3DS rules and returns one of three outcomes:
| Outcome | When | Response | What you do |
|---|---|---|---|
| 3DS required | Your 3DS rules (or the amount) require authentication and no exemption applies. | status: requires_action + next_action + client_secret | CIT: pass the client_secret to the SDK to run the challenge live. MIT: cannot be completed (no customer present) — the payment will not proceed. |
| Non-3DS | Your rules allow skipping the challenge (e.g. amount below the threshold). | status: processing | Wait for the payment_intent.status.succeeded / payment_intent.payment_failed webhook. |
| 3DS exemption | Authentication is satisfied without a challenge — e.g. a wallet network token (Google Pay DPAN), or an SCA exemption such as merchant-initiated (credential-on-file), low-value, or TRA. The transaction is flagged with the exemption so liability is handled correctly. | status: processing | Same as non-3DS — wait for the webhook. |
The exemption that applies automatically today is the wallet network token (Google Pay DPAN), whose cryptogram carries the authentication and liability shift. Other SCA exemptions (MIT credential-on-file, low-value, TRA) are governed by your merchant rules.
7. Save a Card During a Payment
To save the card as part of a regular payment (the "save this card for next time" checkbox) instead of a standalone SetupIntent, pass setup_future_usage on the PaymentIntent. When the payment succeeds, the card is attached to the customer for future CIT/MIT reuse.
{
"amount": "10000",
"currency": "usd",
"customer_id": "cus_xxxxxxxx",
"setup_future_usage": "off_session"
}8. Handle the Webhook
Charging a saved card emits the same events as any payment. Treat the webhook as the authoritative source of truth for the final outcome.
| Event | Description |
|---|---|
payment_intent.status.succeeded | Payment was captured successfully. Mark the order / renewal as paid. |
payment_intent.payment_failed | The charge was declined. For MIT, retry later or notify the customer to update their card. |
charge.captured | Charge was captured. |