A hold reserves money on the buyer's card without taking it. You decide later: capture the whole amount, capture a smaller part, or cancel and let the buyer have it all back. Use a hold when the final amount or the sale itself is not settled at checkout, such as an order you still have to pack, or a booking you confirm by hand.
Three calls cover it, all on the PaymentIntent:
| Step | Call | The PaymentIntent after it |
|---|---|---|
| Hold | POST /v1/payment_intents with capture_method=manual | requires_capture |
| Capture | POST /v1/payment_intents/{intent}/capture | succeeded |
| Cancel | POST /v1/payment_intents/{intent}/cancel | canceled |
Every amount on this page is in the smallest unit of the currency: 5000 is 50.00. Send an Idempotency-Key with each call, so a retry after a timeout never holds, captures or cancels twice (see Idempotency).
Hold an amount#
Create and confirm a card PaymentIntent with capture_method=manual. The bank approves the card and reserves the amount, and nothing reaches your balance yet.
curl https://api.fluveo.com/v1/payment_intents \
-H "Authorization: Bearer sk_test_..." \
-H "Idempotency-Key: order-1043-hold" \
-d amount=5000 \
-d currency=usd \
-d "payment_method_types[]=card" \
-d payment_method=pm_card_visa \
-d capture_method=manual \
-d confirm=trueconst response = await fetch("https://api.fluveo.com/v1/payment_intents", {
method: "POST",
headers: {
Authorization: "Bearer sk_test_...",
"Idempotency-Key": "order-1043-hold",
},
body: new URLSearchParams({
amount: "5000",
currency: "usd",
"payment_method_types[]": "card",
payment_method: "pm_card_visa",
capture_method: "manual",
confirm: "true",
}),
});
const paymentIntent = await response.json();
console.log(paymentIntent.status, paymentIntent.amount_capturable);import requests
response = requests.post(
"https://api.fluveo.com/v1/payment_intents",
headers={
"Authorization": "Bearer sk_test_...",
"Idempotency-Key": "order-1043-hold",
},
data={
"amount": 5000,
"currency": "usd",
"payment_method_types[]": "card",
"payment_method": "pm_card_visa",
"capture_method": "manual",
"confirm": "true",
},
)
payment_intent = response.json()
print(payment_intent["status"], payment_intent["amount_capturable"])<?php
$ch = curl_init("https://api.fluveo.com/v1/payment_intents");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer sk_test_...",
"Idempotency-Key: order-1043-hold",
],
CURLOPT_POSTFIELDS => http_build_query([
"amount" => 5000,
"currency" => "usd",
"payment_method_types" => ["card"],
"payment_method" => "pm_card_visa",
"capture_method" => "manual",
"confirm" => "true",
]),
]);
$paymentIntent = json_decode(curl_exec($ch), true);
echo $paymentIntent["status"] . " " . $paymentIntent["amount_capturable"];The answer shows the hold. status is requires_capture, amount_capturable is what you may still capture, and amount_received stays 0 until you do.
{
"id": "pi_...",
"object": "payment_intent",
"amount": 5000,
"amount_capturable": 5000,
"amount_received": 0,
"capture_method": "manual",
"currency": "usd",
"latest_charge": "ch_...",
"status": "requires_capture"
}A hold does not last forever: the bank keeps it open for a limited time. The Charge of the hold (the PaymentIntent's latest_charge) tells you until when; Cards shows where to read it. Capture before then.
A card that asks for 3D Secure answers requires_action first, and reaches requires_capture only after the buyer completes the check; see 3D Secure.
Capture all or part#
Capture a PaymentIntent in requires_capture. Leave amount_to_capture out to take everything that is held. Send a smaller amount_to_capture to take only part: here 30.00 of the 50.00.
curl https://api.fluveo.com/v1/payment_intents/pi_.../capture \
-H "Authorization: Bearer sk_test_..." \
-H "Idempotency-Key: order-1043-capture" \
-d amount_to_capture=3000const response = await fetch("https://api.fluveo.com/v1/payment_intents/pi_.../capture", {
method: "POST",
headers: {
Authorization: "Bearer sk_test_...",
"Idempotency-Key": "order-1043-capture",
},
body: new URLSearchParams({ amount_to_capture: "3000" }),
});
const paymentIntent = await response.json();
console.log(paymentIntent.status, paymentIntent.amount_received);import requests
response = requests.post(
"https://api.fluveo.com/v1/payment_intents/pi_.../capture",
headers={
"Authorization": "Bearer sk_test_...",
"Idempotency-Key": "order-1043-capture",
},
data={"amount_to_capture": 3000},
)
payment_intent = response.json()
print(payment_intent["status"], payment_intent["amount_received"])<?php
$ch = curl_init("https://api.fluveo.com/v1/payment_intents/pi_.../capture");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer sk_test_...",
"Idempotency-Key: order-1043-capture",
],
CURLOPT_POSTFIELDS => http_build_query(["amount_to_capture" => 3000]),
]);
$paymentIntent = json_decode(curl_exec($ch), true);
echo $paymentIntent["status"] . " " . $paymentIntent["amount_received"];The PaymentIntent is now succeeded. amount_received is the part you took, and amount_capturable drops to 0.
{
"id": "pi_...",
"object": "payment_intent",
"amount": 5000,
"amount_capturable": 0,
"amount_received": 3000,
"capture_method": "manual",
"currency": "usd",
"status": "succeeded"
}A PaymentIntent is captured once. The 20.00 you did not take is released to the buyer at that moment, and no later call can capture it. amount keeps the 50.00 you held. On the Charge, amount_captured is 3000 and captured is true. To give back part of what you took, refund it: a refund can return up to the captured amount.
The same call also takes metadata and the statement text of the captured payment, listed under Parameters.
If the capture answers processing, it is not confirmed yet. Wait for payment_intent.succeeded, or read the PaymentIntent again. If a capture does not succeed, read the PaymentIntent's current status before deciding what to do next.
Cancel a payment#
Cancel a PaymentIntent you will not complete. On a hold, the cancel releases the whole held amount to the buyer: amount_capturable becomes 0 and nothing reaches your balance. cancellation_reason is optional and records why.
The first hold is captured now, so place a second one to cancel. It is the same call with its own Idempotency-Key.
curl https://api.fluveo.com/v1/payment_intents \
-H "Authorization: Bearer sk_test_..." \
-H "Idempotency-Key: order-1044-hold" \
-d amount=5000 \
-d currency=usd \
-d "payment_method_types[]=card" \
-d payment_method=pm_card_visa \
-d capture_method=manual \
-d confirm=trueconst response = await fetch("https://api.fluveo.com/v1/payment_intents", {
method: "POST",
headers: {
Authorization: "Bearer sk_test_...",
"Idempotency-Key": "order-1044-hold",
},
body: new URLSearchParams({
amount: "5000",
currency: "usd",
"payment_method_types[]": "card",
payment_method: "pm_card_visa",
capture_method: "manual",
confirm: "true",
}),
});
const paymentIntent = await response.json();
console.log(paymentIntent.id, paymentIntent.status);import requests
response = requests.post(
"https://api.fluveo.com/v1/payment_intents",
headers={
"Authorization": "Bearer sk_test_...",
"Idempotency-Key": "order-1044-hold",
},
data={
"amount": 5000,
"currency": "usd",
"payment_method_types[]": "card",
"payment_method": "pm_card_visa",
"capture_method": "manual",
"confirm": "true",
},
)
payment_intent = response.json()
print(payment_intent["id"], payment_intent["status"])<?php
$ch = curl_init("https://api.fluveo.com/v1/payment_intents");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer sk_test_...",
"Idempotency-Key: order-1044-hold",
],
CURLOPT_POSTFIELDS => http_build_query([
"amount" => 5000,
"currency" => "usd",
"payment_method_types" => ["card"],
"payment_method" => "pm_card_visa",
"capture_method" => "manual",
"confirm" => "true",
]),
]);
$paymentIntent = json_decode(curl_exec($ch), true);
echo $paymentIntent["id"] . " " . $paymentIntent["status"];Then cancel it with the new pi_... id.
curl https://api.fluveo.com/v1/payment_intents/pi_.../cancel \
-H "Authorization: Bearer sk_test_..." \
-H "Idempotency-Key: order-1044-cancel" \
-d cancellation_reason=requested_by_customerconst response = await fetch("https://api.fluveo.com/v1/payment_intents/pi_.../cancel", {
method: "POST",
headers: {
Authorization: "Bearer sk_test_...",
"Idempotency-Key": "order-1044-cancel",
},
body: new URLSearchParams({ cancellation_reason: "requested_by_customer" }),
});
const paymentIntent = await response.json();
console.log(paymentIntent.status, paymentIntent.cancellation_reason);import requests
response = requests.post(
"https://api.fluveo.com/v1/payment_intents/pi_.../cancel",
headers={
"Authorization": "Bearer sk_test_...",
"Idempotency-Key": "order-1044-cancel",
},
data={"cancellation_reason": "requested_by_customer"},
)
payment_intent = response.json()
print(payment_intent["status"], payment_intent["cancellation_reason"])<?php
$ch = curl_init("https://api.fluveo.com/v1/payment_intents/pi_.../cancel");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer sk_test_...",
"Idempotency-Key: order-1044-cancel",
],
CURLOPT_POSTFIELDS => http_build_query(["cancellation_reason" => "requested_by_customer"]),
]);
$paymentIntent = json_decode(curl_exec($ch), true);
echo $paymentIntent["status"] . " " . $paymentIntent["cancellation_reason"];{
"id": "pi_...",
"object": "payment_intent",
"amount": 5000,
"amount_capturable": 0,
"amount_received": 0,
"canceled_at": 1762862400,
"cancellation_reason": "requested_by_customer",
"status": "canceled"
}canceled is final: the PaymentIntent takes no capture, confirm or second cancel after it. To charge the buyer again, create a new PaymentIntent.
When a cancel works#
The cancel looks at the PaymentIntent's status when it arrives:
status | What the cancel answers |
|---|---|
requires_payment_method | 200, canceled. |
requires_confirmation | 200, canceled. |
requires_action | 200, canceled. A Boleto that waits for its payment is the exception: it can still be paid at a bank, so the cancel answers 400 invalid_request_error with no code (errors without a code). |
requires_capture | 200, canceled. The hold is released. |
processing | Not canceled. 400 payment_intent_unexpected_state, or, while a capture of it is still being confirmed, 200 with the PaymentIntent unchanged. |
succeeded | 400 payment_intent_unexpected_state. The money was taken: refund it instead. |
canceled | 400 payment_intent_unexpected_state. |
Parameters#
Hold#
manualholds the amount until you capture or cancel.automaticandautomatic_asynccapture as soon as the card is approved. Card only.The amount to hold, in the smallest unit of the currency.
Three-letter code in lowercase, one your store accepts.
cardfor a hold.The card's
pm_...id. In test mode,pm_card_visa.truecreates and confirms in one call.
Capture#
How much of
amount_capturableto take, in the smallest unit of the currency. Leave it out to take all of it. The rest is released.Your own key-value pairs, sent as
metadata[key]=value. They are added to the PaymentIntent's.Statement text for the captured payment, up to 22 characters.
Text added after your store's descriptor on the buyer's card statement.
Cancel#
requested_by_customer,duplicate,fraudulentorabandoned. The PaymentIntent shows it incancellation_reason; without it, that field isnull.
The reference lists every parameter of the create, the capture and the cancel.
Statuses and events#
status | What it means here |
|---|---|
requires_capture | The amount is held. Capture or cancel it. |
processing | A capture was accepted and is still being confirmed. |
succeeded | Captured. amount_received is what you took. |
canceled | Canceled. Nothing was taken, and any hold is released. |
| Event | When |
|---|---|
payment_intent.created | The PaymentIntent was created. |
charge.succeeded + payment_intent.amount_capturable_updated | The card was approved and the amount is held. The Charge has captured: false. |
charge.captured + payment_intent.succeeded | You captured the hold. |
payment_intent.processing | A capture is still being confirmed. |
payment_intent.canceled | The PaymentIntent was canceled. |
charge.expired | A hold ran out before anyone captured it. |
Each event's payload is on Event types.
Errors you may see#
The three 400 responses below leave the PaymentIntent unchanged. Each includes the PaymentIntent as it stands, inside the error.
Capture twice. After a capture, the PaymentIntent is succeeded and a second capture is refused with payment_intent_unexpected_state. After a partial capture the message says the rest was released; after a full one it says the PaymentIntent was already captured.
{
"error": {
"type": "invalid_request_error",
"code": "payment_intent_unexpected_state",
"message": "The remaining amount on this PaymentIntent could not be captured because the remainder of the authorized amount has been released.",
"payment_intent": { "id": "pi_...", "status": "succeeded" }
}
}Capture more than was held. An amount_to_capture above amount_capturable is refused with amount_too_large. Nothing is captured, and you may capture again with a smaller amount.
{
"error": {
"type": "invalid_request_error",
"code": "amount_too_large",
"message": "The payment could not be captured because the requested capture amount is greater than the amount you can capture for this charge. Contact us via https://docs.fluveo.com/support/ for help.",
"payment_intent": { "id": "pi_...", "status": "requires_capture" }
}
}Cancel after capture. A succeeded PaymentIntent cannot be canceled; the answer is payment_intent_unexpected_state. Refund the payment instead. The table under When a cancel works is the rule for every status.
{
"error": {
"type": "invalid_request_error",
"code": "payment_intent_unexpected_state",
"message": "You cannot cancel this PaymentIntent because it has a status of succeeded. Only a PaymentIntent with one of the following statuses may be canceled: requires_payment_method, requires_capture, requires_reauthorization, requires_confirmation, requires_action, expired, processing.",
"payment_intent": { "id": "pi_...", "status": "succeeded" }
}
}Capturing an already canceled PaymentIntent also answers payment_intent_unexpected_state. See Errors for the shape of every error.
Test it#
Use a sk_test_... key and the test card pm_card_visa.
- Hold 50.00 as in Hold an amount:
200,statusrequires_capture,amount_capturable5000. Your endpoint getscharge.succeededandpayment_intent.amount_capturable_updated. - Capture
amount_to_capture=6000on that PaymentIntent:400amount_too_large, and the PaymentIntent is stillrequires_capture. - Capture
amount_to_capture=3000:200,statussucceeded,amount_received3000. Your endpoint getscharge.capturedandpayment_intent.succeeded. - Capture it again:
400payment_intent_unexpected_state. - Cancel it:
400payment_intent_unexpected_state, since it issucceeded. - Hold 50.00 again with a new
Idempotency-Key, then cancel it withcancellation_reason=requested_by_customer:200,statuscanceled,amount_capturable0. Your endpoint getspayment_intent.canceled. - Send step 6's cancel again with the same
Idempotency-Key: the same answer comes back withIdempotent-Replayed: true.