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 tocanceled.
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.
| Field | Meaning |
|---|---|
amount | The authorized total — the size of the hold placed on the card. |
amount_capturable | How much of that hold can still be captured. Starts equal to amount, shrinks with each capture, and reaches 0 when the PaymentIntent closes. |
amount_received | How 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_capture | Effect | Requires 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 |
false | The 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:
{
"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.
| Step | Request | amount_received | amount_capturable | Status | Money moved |
|---|---|---|---|---|---|
| Authorized | — | 0 | 100000 | requires_capture | — |
| Ship item 1 | { "amount_to_capture": 50000, "final_capture": false } | 50000 | 50000 | requires_capture | none yet |
| Ship item 2 (closing) | { "amount_to_capture": 20000, "final_capture": true } | 70000 | 0 | succeeded | 70000 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 request | Reason |
|---|---|
amount_to_capture above amount_capturable, or above what is left of the authorized total | You cannot capture more than was authorized. Use increment authorization to raise the total first. |
amount_to_capture: 0 with final_capture: false | A 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-enabled | Multiple 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 captured | There is nothing left to capture. |
Merchant API Responsibilities
1. Create a PaymentIntent with Manual Capture
Pass capture_method=manual when creating the PaymentIntent:
POST /api/payment_intentsRequest
{
"amount": 100000,
"currency": "704",
"capture_method": "manual",
"payment_method_options": {
"card": {
"request_multicapture": "if_available"
}
},
"description": "Order #1234",
"metadata": {
"order_id": "1234"
}
}| Field | Required | Description |
|---|---|---|
amount | Yes | Amount to authorize, in the smallest currency unit (whole đồng for VND). |
currency | Yes | ISO 4217 numeric currency code (e.g. "704" for VND). |
capture_method | Yes | Set to "manual" to enable separate capture. Defaults to "automatic". |
payment_method_options.card.request_multicapture | No | "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. |
description | No | Human-readable description shown on the customer's statement. |
metadata | No | Arbitrary key-value pairs for storing additional information. |
Response
{
"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.
| Event | Description |
|---|---|
payment_intent.status.requires_capture | Authorization 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:
POST /api/payment_intents/{id}/captureRequest — the body is optional. Send {} (or omit it) to capture the full amount_capturable and close the PaymentIntent.
{
"amount_to_capture": 50000,
"final_capture": false
}| Field | Required | Description |
|---|---|---|
amount_to_capture | No | Amount 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_capture | No | Whether 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:
{
"payment_intent": {
"id": "pi_01HZ3X9YQ7M8KH8M5JYV9X4F2B",
"amount": 100000,
"amount_received": 50000,
"amount_capturable": 50000,
"status": "requires_capture"
}
}A final capture closes it:
{
"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:
POST /api/payment_intents/{id}/cancelRequest
{
"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
| Event | Description |
|---|---|
payment_intent.status.succeeded | Capture completed and the PaymentIntent closed. Mark the order as paid for amount_received — which may be less than amount. |
payment_intent.status.canceled | Authorization reversed. Free the order or offer a retry. |
payment_intent.payment_failed | Authorization was declined by the card network. |