Skip to content

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.
usageInitiatorWhen to useCharge with
on_sessionCustomer (CIT)Customer is present and selects a saved card at checkout.off_session=false
off_sessionMerchant (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 ​

  1. Create a customer — Create the customer once (via CreateCustomer) and reuse the customer_id. Saved cards are attached to a customer.
  2. Create a SetupIntent — Your backend calls CreateSetupIntent with the customer_id and the intended usage (on_session or off_session). Bahn returns the setup_intent and its client_secret.
  3. Forward only the client_secret — Return { client_secret } to your frontend; the setup_intent.id and API key stay on your backend.

Phase 2 — Confirm & Verify (SDK) ​

  1. 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.
  2. 3DS challenge — Unlike a payment, 3DS is always required for a setup. The SDK presents the challenge and the customer completes it.
  3. 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 to requires_payment_method with last_setup_error set, 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/customers

Request

{
  "name": "Nguyen Van A",
  "email": "[email protected]",
  "phone": "+84901234567"
}
FieldRequiredDescription
nameNoCustomer's full name.
emailNoCustomer's email address.
phoneNoCustomer's phone number.
metadataNoArbitrary 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.id against your user record. You pass it as customer_id when 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_intents

Request

{
  "customer_id": "cus_xxxxxxxx",
  "usage": "off_session",
  "description": "Save card for subscription",
  "metadata": {
    "order_id": "1234"
  }
}
FieldRequiredDescription
customer_idYesThe customer the card is saved against.
usageYeson_session (customer-present reuse) or off_session (merchant-initiated reuse).
descriptionNoHuman-readable description.
metadataNoArbitrary 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_secret to your frontend — the SDK uses it to initialize and confirm the setup. Keep setup_intent.id on 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"
  }
}
StatusMeaning
requires_actionWaiting for the customer to complete the 3DS challenge.
processing3DS done; awaiting the verification micro-authorization result.
succeededCard verified and saved. payment_method_id is now available for reuse.
requires_payment_methodVerification 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_intents

Request

{
  "amount": "10000",
  "currency": "usd",
  "customer_id": "cus_xxxxxxxx",
  "payment_method_id": "pm_xxxxxxxx",
  "confirm": true,
  "off_session": false
}
FieldRequiredDescription
customer_idYesMust own the payment method.
payment_method_idYesThe saved card returned by the SetupIntent.
confirmYesSet to true to create and confirm in one call.
off_sessionNofalse (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_intents

Request

{
  "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=true requires payment_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:

OutcomeWhenResponseWhat you do
3DS requiredYour 3DS rules (or the amount) require authentication and no exemption applies.status: requires_action + next_action + client_secretCIT: 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-3DSYour rules allow skipping the challenge (e.g. amount below the threshold).status: processingWait for the payment_intent.status.succeeded / payment_intent.payment_failed webhook.
3DS exemptionAuthentication 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: processingSame 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.

EventDescription
payment_intent.status.succeededPayment was captured successfully. Mark the order / renewal as paid.
payment_intent.payment_failedThe charge was declined. For MIT, retry later or notify the customer to update their card.
charge.capturedCharge was captured.

Developers