Manual capture

A payment normally settles the moment it confirms. Manual capture splits that in two. Confirming places an authorization, a hold on the buyer's card for the full amount, and no money moves. Later you capture, which settles some or all of the held amount, or cancel, which releases the hold without charging anything. A hold on a card usually lasts seven days.

Use it when the final amount is not known while the buyer is present: a rental with a damage deposit, a hotel stay with incidentals, a service quoted before the work is done, a bar tab. It also fits the case where the amount is known but you want to confirm you can deliver before charging, such as a pre-order, a stock check, or a manual review of a high-value payment.

If you always charge the full amount right away, keep the default "capture_method": "automatic". Every hold you place needs a capture-or-cancel decision before it expires, and an unmanaged hold ties up the buyer's money for a week.

Note:

Manual capture is for payments your server drives with an API key: PaymentIntents you create through the Payments API, standalone or as a leg on an order. Checkout sessions and payment links always capture automatically. Invoice payments reject manual capture. Affirm supports one full capture and no partial capture; see Affirm.

How a hold works#

A manual-capture PaymentIntent follows the normal lifecycle with one extra stop. After confirmation it parks in requires_capture instead of settling, and stays there until you capture, you cancel, or the hold expires.

Manual capture flowStartFinal
  • requires_payment_method moves to requires_capture on confirm
  • requires_capture moves to succeeded on capture
  • requires_capture moves to canceled on cancel
  • requires_capture moves to expired on seven days pass

Four money fields on the PaymentIntent say where the funds stand, during the hold and after it resolves:

  • authorized_money is the amount the hold was placed for. Set at confirmation and never changes.
  • capturable_money is what you can still capture. Equals the authorized amount until you capture or cancel, then drops to zero.
  • captured_money is what settled. Zero until capture.
  • released_money is what went back to the buyer: the uncaptured remainder after a partial capture, or the whole hold after a cancellation or expiry.

Three timestamps go with them. authorized_at and authorization_expires_at are set when the hold is placed, and captured_at when it settles. authorization_expires_at is the deadline every hold has to beat, and Authorization expiry covers what happens when it passes.

Place a hold#

Create the PaymentIntent with "capture_method": "manual". Nothing else about creation changes.

cURL
curl -X POST https://api.withflintpay.com/v1/payment-intents \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Idempotency-Key: hold-camera-rental-001" \
  -d '{
    "amount_money": {"amount": 12900, "currency": "USD"},
    "payment_options": ["card"],
    "capture_method": "manual"
  }'
Response
{
  "data": {
    "payment_intent": {
      "payment_intent_id": "pi_1kmn0aExample",
      "status": "requires_payment_method",
      "amount_money": {"amount": 12900, "currency": "USD"},
      "capture_method": "manual",
      "origin": "api"
    },
    "payment_collection": {
      "stripe": {
        "account_id": "acct_Example",
        "publishable_key": "pk_test_Example",
        "elements": {
          "next_step": "create_confirmation_token",
          "submit_to": "confirm_payment_intent",
          "mode": "payment",
          "payment_method_types": ["card"],
          "payment_method_creation": "manual"
        }
      }
    }
  }
}

Confirming is what places the hold. Mount Stripe Elements from payment_collection, call elements.submit(), create a ConfirmationToken in the browser, and send only its ctoken_... ID to your backend. The browser never confirms the PaymentIntent itself. Server-confirmed payments covers the collection flow and 3D Secure in full.

cURL
curl -X POST https://api.withflintpay.com/v1/payment-intents/pi_1kmn0aExample/confirm \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Idempotency-Key: confirm-camera-rental-001" \
  -d '{"confirmation_token": "ctoken_Example"}'
Response
{
  "data": {
    "payment_intent_id": "pi_1kmn0aExample",
    "status": "requires_capture",
    "amount_money": {"amount": 12900, "currency": "USD"},
    "capture_method": "manual",
    "authorized_money": {"amount": 12900, "currency": "USD"},
    "capturable_money": {"amount": 12900, "currency": "USD"},
    "captured_money": {"amount": 0, "currency": "USD"},
    "released_money": {"amount": 0, "currency": "USD"},
    "authorized_at": "2026-07-03T02:57:20Z",
    "authorization_expires_at": "2026-07-10T02:57:19Z",
    "payment_source": {
      "card": {
        "brand": "visa",
        "last4": "4242"
      }
    }
  }
}

The hold is on. status is requires_capture, the full amount is capturable, and nothing has been charged. If the card needs 3D Secure, the PaymentIntent passes through requires_action first and reaches requires_capture once the buyer completes authentication. The payment_intent.requires_capture webhook is the durable signal that the hold is in place, whichever path it took.

Store payment_intent_id and authorization_expires_at with the rental, booking, or order the hold belongs to.

Capture the payment#

Capture with an empty body to settle the full authorized amount:

cURL
curl -X POST https://api.withflintpay.com/v1/payment-intents/pi_1kmn0aExample/capture \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Idempotency-Key: capture-camera-rental-001" \
  -d '{}'

Or pass amount_money to capture part of the hold. Here the camera came back a day early, so the merchant settles $98.00 of the $129.00 hold:

cURL
curl -X POST https://api.withflintpay.com/v1/payment-intents/pi_1kmn0aExample/capture \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Idempotency-Key: capture-camera-rental-001" \
  -d '{"amount_money": {"amount": 9800, "currency": "USD"}}'
Response
{
  "data": {
    "payment_intent_id": "pi_1kmn0aExample",
    "status": "succeeded",
    "amount_money": {"amount": 9800, "currency": "USD"},
    "capture_method": "manual",
    "authorized_money": {"amount": 12900, "currency": "USD"},
    "capturable_money": {"amount": 0, "currency": "USD"},
    "captured_money": {"amount": 9800, "currency": "USD"},
    "released_money": {"amount": 3100, "currency": "USD"},
    "authorized_at": "2026-07-03T02:57:20Z",
    "captured_at": "2026-07-03T02:57:40Z",
    "authorization_expires_at": "2026-07-10T02:57:19Z"
  }
}

The PaymentIntent is succeeded, amount_money now reflects what settled, and the uncaptured $31.00 appears in released_money on its way back to the buyer.

A hold supports exactly one capture. The uncaptured remainder is released in the same operation, and there is no second capture. Capturing a PaymentIntent that is not in requires_capture, including one you already captured, returns INVALID_STATUS_FOR_CAPTURE. To charge in installments, capture the first amount and collect the rest as a separate payment.

The amount you capture must pass four checks, each with its own error code:

  • Greater than zero, or INVALID_CAPTURE_AMOUNT.
  • In the authorization's currency, or CAPTURE_CURRENCY_MISMATCH.
  • No more than capturable_money, or CAPTURE_AMOUNT_EXCEEDS_CAPTURABLE. You can never capture more than you held. If the final bill grew past the hold, capture the full authorization and collect the difference as a separate payment.
  • At or above the minimum payment amount for your pricing, or PROCESSING_FEE_PRICING_NOT_AUTHORIZED. A partial capture below it fails with the hold still in place. Cancel the hold instead. See Processing fees.

If a risk rule opened a review on the payment, capture returns PAYMENT_REVIEW_OPEN until someone approves the review. Approving never captures; capture again after the approval. Declining the review cancels the hold.

Send an Idempotency-Key on every capture. If the request times out, a retry with the same key replays the original result instead of hitting INVALID_STATUS_FOR_CAPTURE against your own earlier success. See Idempotency.

Cancel the hold#

If you will not be charging, cancel the PaymentIntent to release the hold right away:

cURL
curl -X POST https://api.withflintpay.com/v1/payment-intents/pi_1kmn0aExample/cancel \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Idempotency-Key: cancel-camera-rental-001" \
  -d '{"cancellation_reason": "requested_by_customer"}'
Response
{
  "data": {
    "payment_intent_id": "pi_1kmn0aExample",
    "status": "canceled",
    "cancellation_reason": "requested_by_customer",
    "authorized_money": {"amount": 12900, "currency": "USD"},
    "capturable_money": {"amount": 0, "currency": "USD"},
    "captured_money": {"amount": 0, "currency": "USD"},
    "released_money": {"amount": 12900, "currency": "USD"}
  }
}

cancellation_reason is optional and takes requested_by_customer, duplicate, fraudulent, or abandoned. No money moved, so there is nothing to refund, and the whole hold lands in released_money. Flint releases the authorization immediately. The pending charge can take a few days to leave the buyer's statement, depending on their bank, so tell the buyer what to expect.

Canceling works only before capture. A captured PaymentIntent returns CANNOT_CANCEL_SUCCEEDED_PAYMENT, and the tool for that is a refund. An already-canceled one returns PAYMENT_ALREADY_CANCELED. An expired one returns CANNOT_CANCEL_EXPIRED_PAYMENT, because expiry already released the funds.

Authorization expiry#

Card networks release uncaptured holds after a set time. Each authorization carries its deadline in authorization_expires_at, usually seven days after authorized_at for a card. When the deadline passes without a capture, the PaymentIntent moves to expired, the hold goes back to the buyer, and cancellation_reason reads authorization_expired. A standalone capture after that returns INVALID_STATUS_FOR_CAPTURE, because the PaymentIntent is no longer in requires_capture. The order-scoped capture route reports the same lapse as PAYMENT_AUTHORIZATION_EXPIRED.

An expired hold is a missed decision, and an expensive one. The buyer walked away believing they paid, and you delivered without collecting. Collecting now takes a new payment from the buyer. Treat the deadline as an input to your fulfillment system:

  • Read authorization_expires_at from the confirm response and schedule the capture ahead of it. Do not hardcode seven days. The field is the contract.
  • If the final amount will not be known before the deadline, capture what you can justify before expiry, or cancel and place a new hold closer to fulfillment.
  • Alert on the expiry webhooks. Each one is money you meant to collect.

Cancel or refund?#

Which one applies depends on whether money has moved.

Before capture

Cancel

The buyer was never charged. Cancel the PaymentIntent through its owning route, and the pending hold disappears from their statement without a charge ever posting. No fees apply.

After capture

Refund

The captured amount settled, so reversing any of it is a refund, and only captured_money is refundable. Refunding an uncaptured PaymentIntent returns PAYMENT_INTENT_NOT_REFUNDABLE.

The released remainder after a partial capture is neither. It was never charged, so it needs no refund, and it cannot be recovered. If you released too much, collecting it is a new payment.

Manual capture on orders#

An order carries the line items, tax, and delivery the hold is for, and its payment status follows the hold. A PaymentIntent that belongs to an order is created, confirmed, captured, and canceled through the order's routes. Calling a standalone route for it returns 409 ORDER_PAYMENT_FLOW_REQUIRED. Read the owning order from details[].blocking_resources and follow remediation.next_actions[].url with POST, using a credential with commerce.orders.write. Capturing always lists order_payment_attempt_id in required_fields, and canceling an authorization lists it too.

Finalize the order's pricing first. While a hold is open, changes to the order's financial fields return PAYMENT_ATTEMPT_REQUIRES_CAPTURE. Then create one manual-capture leg. With no amount_money it covers the outstanding balance:

cURL
curl -X POST https://api.withflintpay.com/v1/orders/ord_1kmn0aExample/payment-intents \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Idempotency-Key: pi-order-rental-001" \
  -d '{"capture_method": "manual"}'

Confirm it with the buyer's credential through the order's pay route. Manual capture supports one leg per attempt, so a split payment with delayed capture returns ORDER_DELAYED_CAPTURE_SPLIT_PAYMENT_UNSUPPORTED.

cURL
curl -X POST https://api.withflintpay.com/v1/orders/ord_1kmn0aExample/pay \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Idempotency-Key: pay-order-rental-001" \
  -d '{
    "action": "confirm_payment_intents",
    "payment_intents": [{
      "payment_intent_id": "pi_1kmn0aExample",
      "token": "pm_1kmn0aExample"
    }],
    "expected_outstanding_money": {"amount": 18500, "currency": "USD"}
  }'
Response
{
  "data": {
    "order": {
      "order_id": "ord_1kmn0aExample",
      "status": "open",
      "payment_status": "unpaid",
      "authorization_amounts": {
        "authorized_money": {"amount": 18500, "currency": "USD"},
        "capturable_money": {"amount": 18500, "currency": "USD"},
        "expires_at": "2026-07-10T02:58:26Z"
      },
      "settlement_amounts": {
        "paid_money": {"amount": 0, "currency": "USD"},
        "outstanding_money": {"amount": 18500, "currency": "USD"}
      }
    },
    "payment_attempt": {
      "order_payment_attempt_id": "opat_1kmn0aExample",
      "status": "requires_capture",
      "is_resumable": false
    }
  }
}

The order is still open and unpaid, because only captured money advances payment_status. The hold shows in authorization_amounts, and expires_at is the same deadline as the PaymentIntent's authorization_expires_at. The attempt is finished, not paused: is_resumable is false, and the next step is a capture or a cancel, not a resume.

Fulfillments need a paid order, so creating or changing one while the hold is open returns ORDER_NOT_FULFILLABLE. Capture first, then create the fulfillment. If you deliver outside Flint before charging, capture before authorization_expires_at.

Capture with the attempt ID. Omit amount_money for the full hold, or pass it for a partial capture:

cURL
curl -X POST https://api.withflintpay.com/v1/orders/ord_1kmn0aExample/payment-intents/pi_1kmn0aExample/capture \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Idempotency-Key: capture-order-rental-001" \
  -d '{"order_payment_attempt_id": "opat_1kmn0aExample"}'

A full capture returns the order as closed and paid with outstanding_money at zero. A partial capture releases the remainder and leaves the order open and partially_paid, with the uncaptured amount in outstanding_money. Retrying a completed capture with a different amount returns CAPTURE_AMOUNT_MISMATCH; a retry with the same key and amount replays the result.

To release the hold instead, cancel with the same attempt ID:

cURL
curl -X POST https://api.withflintpay.com/v1/orders/ord_1kmn0aExample/payment-intents/pi_1kmn0aExample/cancel \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Idempotency-Key: cancel-order-rental-001" \
  -d '{
    "order_payment_attempt_id": "opat_1kmn0aExample",
    "cancellation_reason": "requested_by_customer"
  }'

If the order reserves inventory, the reservation stays claimed for as long as the hold is open. Capture consumes it, and cancellation or expiry releases it.

The manual-capture leg needs your API key. A checkout session credential cannot create one, and returns PAYMENT_CAPTURE_METHOD_NOT_ALLOWED if it tries. Create and confirm the leg from your backend.

Webhooks#

Holds resolve minutes or days after they are placed, usually from a scheduled job rather than a request path, so webhooks are the durable record of each transition. The order events fire only for holds on an order.

  1. What happens: You confirm with manual capture
  2. Flint sends: payment_intent.requires_action
    Only if the card needs 3D Secure.
  3. Flint sends: payment_intent.requires_capture
    The hold is in place.
  4. Flint sends: order.payment_authorized
    Carries authorized_money, capturable_money, and authorization_expires_at.
  5. What happens: You capture
  6. Flint sends: payment_intent.succeeded
    The captured amount settled.
  7. Flint sends: order.payment_captured
    Carries captured_money.
  8. Flint sends: order.paid
    Only when the capture covers the whole order. A partial capture fires order.payment_captured alone.
  9. What happens: You cancel, or the hold expires
  10. Flint sends: payment_intent.canceled
    The hold is released. cancellation_reason is authorization_expired for an expiry.
  11. Flint sends: order.payment_authorization_canceledororder.payment_authorization_expired
    Canceled by you, or expired after the deadline. Both carry released_money.

There is no payment_intent.captured event, because a capture is the PaymentIntent succeeding, and no payment_intent.expired event, because an expiry is a cancellation with cancellation_reason set to authorization_expired. Key your handlers on payment_intent.succeeded and payment_intent.canceled and read the reason.

Test it#

Any Flint test card that succeeds places a hold in the sandbox. Confirm with the universal success card and the PaymentIntent lands in requires_capture:

Test cardAny future expiry, any CVC.

Use 4000 0025 0000 3155 to route through requires_action first, and check that your code waits for payment_intent.requires_capture rather than assuming the hold from the confirm response. To rehearse the review gate, create a sandbox review rule and confirm a manual-capture payment; Testing walks through it.

No test card expires a hold early. To check that your expiry handler is wired up, send order.payment_authorization_expired from the test-events endpoint. A test event carries a small fixture rather than a full payload, so it proves delivery and routing, not the business logic behind them. The payment_intent.canceled fixture carries no cancellation_reason, so a standalone expiry handler keyed on the reason needs a real hold to run.

Errors#

  • HTTP 400
    capture_method takes automatic or manual.
  • HTTP 400
    MANUAL_CAPTURE_NOT_ALLOWED_WITH_INVOICE
    Invoice payments always capture automatically.
  • HTTP 400
    A checkout session credential cannot create a manual-capture leg. Create it from your backend with your API key.
  • HTTP 400
    The PaymentIntent is not in requires_capture. It may be unconfirmed, already captured, canceled, or expired. Fetch it and check status.
  • HTTP 400
    You cannot capture more than capturable_money. Capture the full authorization and collect the rest separately.
  • HTTP 400
    Capture in the authorization's currency.
  • HTTP 400
    The capture amount must be greater than zero.
  • HTTP 400
    No authorized price covers the capture amount, usually because it is too small. Cancel the hold and collect the amount separately. See Processing fees.
  • HTTP 409
    A risk review is open on this payment. Approve it, then capture again. Approval does not capture.
  • HTTP 400
    Order-scoped capture only: the hold lapsed before capture and the funds are released. Collect with a new payment. The standalone route reports the lapse as INVALID_STATUS_FOR_CAPTURE.
  • HTTP 400
    Money moved. Issue a refund instead of canceling.
  • HTTP 409
    Follow the order route in remediation.next_actions[].url with POST using commerce.orders.write. Read the order ID from details[].blocking_resources and provide any required_fields.
  • HTTP 409
    The order's financial fields are locked while a hold is open. Capture or cancel first.
  • HTTP 409
    This order authorization was already captured for a different amount. A retry only replays the same amount.
  • HTTP 400
    Use one PaymentIntent for delayed capture.

Error handling covers the envelope these arrive in and the request IDs to log.

Common mistakes#

  • Treating the hold as revenue. requires_capture means the money is reserved, not collected. Until captured_money is positive, you have not been paid.
  • Letting holds expire silently. Every hold needs a scheduled capture-or-cancel decision before authorization_expires_at. Alert on order.payment_authorization_expired and on payment_intent.canceled with reason authorization_expired.
  • Planning to capture twice. One hold, one capture. The remainder is released the moment you capture partially. Installments are separate payments.
  • Waiting for a payment_intent.captured event. It does not exist. A successful capture arrives as payment_intent.succeeded.
  • Resuming a requires_capture attempt. The attempt is finished and is_resumable is false. The next call is capture or cancel.
  • Editing an order under an open hold. Financial changes return PAYMENT_ATTEMPT_REQUIRES_CAPTURE while the authorization is open. Final total first, then pay.
  • Refunding a hold. Before capture there is nothing to refund. Cancel instead.

Next steps#

  • Embedded payments: collecting the card whose hold you are placing, in your own UI.
  • Orders first: why the order is the right anchor for a hold with real line items.
  • Risk controls: review rules that gate capture, and how approval works.
  • Refunds: reversing money after capture.
  • Webhooks: signature verification and retries for the events above.
  • Payments API reference: every field on the PaymentIntent, capture, and cancel endpoints.

Was this helpful?