Docs
API 2026-05-27.dahlia Dashboard

Payments

Hold an amount on a card, capture all or part of it later, or cancel the payment.

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:

StepCallThe PaymentIntent after it
HoldPOST /v1/payment_intents with capture_method=manualrequires_capture
CapturePOST /v1/payment_intents/{intent}/capturesucceeded
CancelPOST /v1/payment_intents/{intent}/cancelcanceled

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.

Hold 50.00 on a card
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=true
const 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.

200 response (trimmed)
{
  "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.

Capture 30.00 of the hold
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=3000
const 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.

200 response (trimmed)
{
  "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.

Hold another 50.00
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=true
const 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.

Cancel the hold
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_customer
const 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"];
200 response (trimmed)
{
  "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:

statusWhat the cancel answers
requires_payment_method200, canceled.
requires_confirmation200, canceled.
requires_action200, 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_capture200, canceled. The hold is released.
processingNot canceled. 400 payment_intent_unexpected_state, or, while a capture of it is still being confirmed, 200 with the PaymentIntent unchanged.
succeeded400 payment_intent_unexpected_state. The money was taken: refund it instead.
canceled400 payment_intent_unexpected_state.

Parameters#

Hold#

Body parameters of POST /v1/payment_intents
  • capture_methodstringOptional

    manual holds the amount until you capture or cancel. automatic and automatic_async capture as soon as the card is approved. Card only.

  • amountintegerRequired

    The amount to hold, in the smallest unit of the currency.

  • currencystringRequired

    Three-letter code in lowercase, one your store accepts.

  • card for a hold.

  • payment_methodstringOptional

    The card's pm_... id. In test mode, pm_card_visa.

  • confirmbooleanOptional

    true creates and confirms in one call.

Capture#

Body parameters of POST /v1/payment_intents/{intent}/capture
  • amount_to_captureintegerOptional

    How much of amount_capturable to take, in the smallest unit of the currency. Leave it out to take all of it. The rest is released.

  • metadataobjectOptional

    Your own key-value pairs, sent as metadata[key]=value. They are added to the PaymentIntent's.

  • statement_descriptorstringOptional

    Statement text for the captured payment, up to 22 characters.

  • Text added after your store's descriptor on the buyer's card statement.

Cancel#

Body parameters of POST /v1/payment_intents/{intent}/cancel
  • cancellation_reasonstringOptional

    requested_by_customer, duplicate, fraudulent or abandoned. The PaymentIntent shows it in cancellation_reason; without it, that field is null.

The reference lists every parameter of the create, the capture and the cancel.

Statuses and events#

statusWhat it means here
requires_captureThe amount is held. Capture or cancel it.
processingA capture was accepted and is still being confirmed.
succeededCaptured. amount_received is what you took.
canceledCanceled. Nothing was taken, and any hold is released.
EventWhen
payment_intent.createdThe PaymentIntent was created.
charge.succeeded + payment_intent.amount_capturable_updatedThe card was approved and the amount is held. The Charge has captured: false.
charge.captured + payment_intent.succeededYou captured the hold.
payment_intent.processingA capture is still being confirmed.
payment_intent.canceledThe PaymentIntent was canceled.
charge.expiredA 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.

400 response (trimmed)
{
  "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.

400 response (trimmed)
{
  "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.

400 response (trimmed)
{
  "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.

  1. Hold 50.00 as in Hold an amount: 200, status requires_capture, amount_capturable 5000. Your endpoint gets charge.succeeded and payment_intent.amount_capturable_updated.
  2. Capture amount_to_capture=6000 on that PaymentIntent: 400 amount_too_large, and the PaymentIntent is still requires_capture.
  3. Capture amount_to_capture=3000: 200, status succeeded, amount_received 3000. Your endpoint gets charge.captured and payment_intent.succeeded.
  4. Capture it again: 400 payment_intent_unexpected_state.
  5. Cancel it: 400 payment_intent_unexpected_state, since it is succeeded.
  6. Hold 50.00 again with a new Idempotency-Key, then cancel it with cancellation_reason=requested_by_customer: 200, status canceled, amount_capturable 0. Your endpoint gets payment_intent.canceled.
  7. Send step 6's cancel again with the same Idempotency-Key: the same answer comes back with Idempotent-Replayed: true.

Next#