Skip to content

Manual Capture ​

By default, Bahn captures the authorized amount immediately after authorization succeeds. Manual capture separates these two steps: the payment is authorized (funds reserved) but not captured (funds collected) until your backend explicitly calls CapturePaymentIntent. This is useful when you need to verify inventory, run fraud checks, or adjust the order before committing the charge.

You do not have to collect the full authorized amount. A capture may be partial, and the closing capture releases whatever is left on hold — see Partial and Final Capture.

Capturing in more than one call is a separate opt-in: only a PaymentIntent created with payment_method_options.card.request_multicapture: if_available accepts a capture with final_capture: false. Without it you get exactly one capture, which must be the closing one.

If you decide not to collect anything at all, call CancelPaymentIntent instead — Bahn will reverse the authorization on the card network.

High-Level Flow ​

sequenceDiagram
    autonumber
    participant MerchantAPI as Merchant API<br/>(Backend)
    participant API as bn_api<br/>(Gateway)
    participant TXN as bn_transaction
    participant WHK as Webhook

    MerchantAPI ->> API: CreatePaymentIntent(amount, currency, capture_method=manual,<br/>payment_method_options.card.request_multicapture=if_available)
    API ->> TXN: CreatePaymentIntent(capture_method=manual)
    Note over TXN: PI status=requires_payment_method, capture_method=manual
    TXN -->> API: {payment_intent_id, client_secret}
    API -->> MerchantAPI: {payment_intent_id, client_secret}

    Note over MerchantAPI, TXN: Confirm + async authorization — same as standard flow

    Note over TXN: Authorization approved →<br/>Charge status=succeeded, captured=false<br/>PI status=requires_capture
    WHK ->> MerchantAPI: payment_intent.status.requires_capture

    MerchantAPI ->> API: CapturePaymentIntent(payment_intent_id)
    API ->> TXN: CapturePaymentIntent(payment_intent_id)
    Note over TXN: Creates local AUTHORIZATION.CLEARING transaction (no network call)<br/>Charge captured=true<br/>PI status=succeeded
    TXN -->> API: {payment_intent: {status=succeeded}}
    API -->> MerchantAPI: {payment_intent: {status=succeeded}}
    WHK ->> MerchantAPI: payment_intent.status.succeeded

    Note over TXN: AUTHORIZATION.CLEARING enters batch clearing

How It Works ​

Phase 1 — Create PaymentIntent ​

Your backend creates a PaymentIntent with capture_method=manual. This signals to Bahn that the authorization and capture steps will be separated.

If you intend to capture in more than one call, also set payment_method_options.card.request_multicapture to if_available now — eligibility is stamped onto the charge from the options stored at creation and cannot be added later.

Phase 2 — Confirm & Authorize ​

The confirm and async authorization steps are identical to the standard payment flow. The SDK collects card details and confirms the PaymentIntent; Bahn submits an authorization request to the card network.

Phase 3 — Authorization Approved (requires_capture) ​

When the card network approves the authorization, Bahn transitions the PaymentIntent to requires_capture rather than succeeded. The charge is succeeded but captured=false — funds are reserved on the card but not yet collected.

Bahn delivers a payment_intent.status.requires_capture webhook event to your registered endpoint. Your backend should treat this event as the trigger for any pre-capture logic (inventory check, fraud review, amount adjustment).

Phase 4 — Capture or Cancel ​

After completing your pre-capture logic, you have two options:

  • Capture — call CapturePaymentIntent, for the full authorized amount or part of it. Bahn creates a local clearing transaction (no synchronous network call). The clearing file is submitted to the card network in the next batch.
  • Cancel — call CancelPaymentIntent. Bahn reverses the authorization on the card network and transitions the PaymentIntent to canceled.

The authorization hold has a time limit set by the card issuer (typically 5–7 days). If you do not capture or cancel within that window, the hold expires and the funds are released to the cardholder. Bahn does not auto-capture on expiry.


Capture Amounts ​

Three fields on the PaymentIntent describe the money at each stage. Read them from the capture response rather than tracking totals yourself.

FieldMeaning
amountThe authorized total — the size of the hold placed on the card.
amount_capturableHow much of that hold can still be captured. Starts equal to amount, shrinks with each capture, and reaches 0 when the PaymentIntent closes.
amount_receivedHow much you have collected so far, summed across every capture.

At any point before the closing capture, amount_capturable + amount_received == amount.

Partial and Final Capture ​

amount_to_capture may be less than amount_capturable. What happens to the remainder is decided entirely by final_capture — not by the amount itself.

final_captureEffectRequires multicapture
true (default)Everything collected across all captures clears in a single settlement. The remaining authorized funds are released, the PaymentIntent becomes succeeded, and no further capture is possible.No
falseThe amount is recorded as owed but nothing settles yet. amount_received grows, amount_capturable shrinks, the remainder stays authorized, and the PaymentIntent stays in requires_capture for a further capture.Yes

final_capture: false requires multicapture

A capture with final_capture: false is only accepted when the charge is multicapture-enabled — i.e. the PaymentIntent was created with payment_method_options.card.request_multicapture: if_available. Otherwise the request is rejected with multiple captures are not supported for this payment, and the PaymentIntent stays in requires_capture untouched.

A partial capture on its own needs no opt-in: amount_to_capture below amount_capturable with final_capture: true collects part of the authorization and releases the rest, in a single call. What multicapture buys you is the second call.

Check it before you rely on it — read payment_method_details.card.multicapture.status on the charge (available or absent) rather than assuming the opt-in took.

WARNING

Capturing less than the authorized amount releases the hold on the difference only on the final capture. A final_capture: false call collects less but keeps the rest of the hold in place — the cardholder still sees the full authorized amount reserved.

A split capture is therefore a sequence of final_capture: false calls — each needing multicapture — followed by one closing call with final_capture: true. That last call is the one that actually moves money: it clears the sum of every capture as a single settlement with one balance transaction.

Releasing the remainder without collecting more ​

To close out a partially captured PaymentIntent when you have collected everything you intend to, send:

json
{
  "amount_to_capture": 0,
  "final_capture": true
}

The uncaptured remainder is released and the PaymentIntent becomes succeeded.

INFO

This requires at least one prior capture. A PaymentIntent that has collected nothing must be canceled instead, which reverses the authorization rather than closing it out.

Worked Example ​

An order is authorized for 100,000, created with request_multicapture: if_available. One item ships now, a second ships later, and the rest of the order is cancelled.

StepRequestamount_receivedamount_capturableStatusMoney moved
Authorized—0100000requires_capture—
Ship item 1{ "amount_to_capture": 50000, "final_capture": false }5000050000requires_capturenone yet
Ship item 2 (closing){ "amount_to_capture": 20000, "final_capture": true }700000succeeded70000 settles

The closing call settles 70,000 as one transaction and releases the remaining 30,000 of the hold. The cardholder's available balance is restored by that 30,000 once the issuer processes the release.

Had the second call used final_capture: false, nothing would have settled and the full 100,000 would still be on hold.

Errors ​

Rejected requestReason
amount_to_capture above amount_capturable, or above what is left of the authorized totalYou cannot capture more than was authorized. Use increment authorization to raise the total first.
amount_to_capture: 0 with final_capture: falseA non-final capture must collect a positive amount. To release the remainder, set final_capture: true.
final_capture: false on a charge that is not multicapture-enabledMultiple captures are not supported for this payment. The PaymentIntent was not created with payment_method_options.card.request_multicapture: if_available, and the opt-in cannot be added after creation. Capture once with final_capture: true.
A PaymentIntent that is not requires_capture, was not created with capture_method: manual, or is already fully capturedThere is nothing left to capture.

Merchant API Responsibilities ​

1. Create a PaymentIntent with Manual Capture ​

Pass capture_method=manual when creating the PaymentIntent:

http
POST /api/payment_intents

Request

json
{
  "amount": 100000,
  "currency": "704",
  "capture_method": "manual",
  "payment_method_options": {
    "card": {
      "request_multicapture": "if_available"
    }
  },
  "description": "Order #1234",
  "metadata": {
    "order_id": "1234"
  }
}
FieldRequiredDescription
amountYesAmount to authorize, in the smallest currency unit (whole đồng for VND).
currencyYesISO 4217 numeric currency code (e.g. "704" for VND).
capture_methodYesSet to "manual" to enable separate capture. Defaults to "automatic".
payment_method_options.card.request_multicaptureNo"if_available" to allow capturing in more than one call, i.e. to accept final_capture: false later. Defaults to "never". Creation-only — the confirm endpoint accepts payment_method_options but does not apply it, so this cannot be added afterwards.
descriptionNoHuman-readable description shown on the customer's statement.
metadataNoArbitrary key-value pairs for storing additional information.

Response

json
{
  "payment_intent": {
    "id": "pi_01HZ3X9YQ7M8KH8M5JYV9X4F2B",
    "client_secret": "pi_secret_ilAA93E16NV3uic90rq--tz-7FOTeupgN5DH6GYwji0",
    "amount": 100000,
    "currency": "704",
    "capture_method": "manual",
    "status": "requires_payment_method"
  }
}

2. Handle the requires_capture Webhook ​

When the authorization is approved, Bahn delivers a payment_intent.status.requires_capture event. Use this event to run your pre-capture business logic.

EventDescription
payment_intent.status.requires_captureAuthorization approved. Funds are reserved. Call CapturePaymentIntent or CancelPaymentIntent.

3. Capture the Payment ​

Once your pre-capture logic completes and you are ready to collect funds, call CapturePaymentIntent:

http
POST /api/payment_intents/{id}/capture

Request — the body is optional. Send {} (or omit it) to capture the full amount_capturable and close the PaymentIntent.

json
{
  "amount_to_capture": 50000,
  "final_capture": false
}
FieldRequiredDescription
amount_to_captureNoAmount to capture in the smallest currency unit, ≤ amount_capturable. Defaults to the full amount_capturable. 0 is allowed only together with final_capture: true.
final_captureNoWhether this is the closing capture. Defaults to true, which settles everything collected so far and releases the remaining authorized funds. Set to false to keep the remainder authorized for a further capture — accepted only on a multicapture-enabled charge.

Response — a non-final capture leaves the PaymentIntent in requires_capture with the amount fields updated:

json
{
  "payment_intent": {
    "id": "pi_01HZ3X9YQ7M8KH8M5JYV9X4F2B",
    "amount": 100000,
    "amount_received": 50000,
    "amount_capturable": 50000,
    "status": "requires_capture"
  }
}

A final capture closes it:

json
{
  "payment_intent": {
    "id": "pi_01HZ3X9YQ7M8KH8M5JYV9X4F2B",
    "amount": 100000,
    "amount_received": 70000,
    "amount_capturable": 0,
    "status": "succeeded"
  }
}

Bahn delivers a payment_intent.status.succeeded webhook event once the PaymentIntent closes. No webhook is delivered for a non-final capture — the PaymentIntent has not changed status — so drive your own bookkeeping from the capture response.

4. Cancel Instead of Capture ​

To release the authorization hold without collecting anything, call CancelPaymentIntent:

http
POST /api/payment_intents/{id}/cancel

Request

json
{
  "cancellation_reason": "abandoned"
}

Bahn reverses the authorization on the card network and delivers a payment_intent.status.canceled webhook event.

Cancel only works while nothing has been collected. Once a PaymentIntent has a non-zero amount_received, close it out with amount_to_capture: 0 and final_capture: true instead, and issue a Refund if you need to return what was already captured.

5. Handle Terminal Webhooks ​

EventDescription
payment_intent.status.succeededCapture completed and the PaymentIntent closed. Mark the order as paid for amount_received — which may be less than amount.
payment_intent.status.canceledAuthorization reversed. Free the order or offer a retry.
payment_intent.payment_failedAuthorization was declined by the card network.

Developers