Skip to main content

Manual capture

By default a card payment authorizes and captures in one step. With capture_method: "manual" the two are separated: the create places a hold on the cardholder's funds, and you settle it later with a capture - or release it with a void.

Use it when you charge on dispatch rather than on order: physical goods, bookings with a cancellation window, anything where the final amount is only known after the fact.

Requirements

On a direct payment, manual capture currently needs a card that is already on file: the create must carry payment_method.details.card_token, whether as a merchant-initiated charge or a customer-present collect_cvv charge. A card collected fresh through collect_card cannot use it yet, and it is rejected on a zero-amount setup.

Hosted checkout sessions accept capture_method: "manual" without either restriction.

1. Authorize​

Add capture_method: "manual" to a POST /v1/payments that charges a token (for a hosted checkout, see Manual capture on hosted checkout - the capture and void steps below are the same). This example is a merchant-initiated charge, so it authorizes in the create itself:

{
"amount": "42.00",
"currency": "GBP",
"country": "GB",
"payment_method": {
"code": "card",
"details": {
"card_token": "tok_1PqRsT2eZvKYlo2C",
"stored_credential": {
"initiator": "merchant",
"reason": "unscheduled"
}
}
},
"capture_method": "manual",
"merchant_reference": "order-1001"
}

A successful authorization returns status: "authorized" - funds are held, nothing has moved:

{
"order_id": "pay_5b4a3c2d",
"transaction_id": "txn_9f8e7d6c",
"merchant_reference": "order-1001",
"status": "authorized",
"amount": 42.00,
"currency": "GBP"
}

Keep transaction_id - it is the authorization's id, and the most precise way to target the capture later.

With the customer present - the same create without stored_credential - the response is requires_action with a collect_cvv action instead: the customer confirms the security code in Upprove.js, pay() resolves with status: "succeeded" at the authorization (the payment.authorized webhook is the one to wait for), and the hold is then captured or voided exactly as below. With collect_cvv: false the create authorizes at once like the example above; if the issuer demands 3-D Secure you get requires_action and complete the challenge as described in Server-to-server card payments, after which the payment settles to authorized.

2. Capture​

POST /v1/payments/capture. Identify the authorization with exactly one of order_id, transaction_id, or merchant_reference:

REQ_PATH="/v1/payments/capture"
BODY='{
"transaction_id": "txn_9f8e7d6c"
}'
TS=$(date +%s)
SIG=$(printf '%s' "${TS}${REQ_PATH}${BODY}" \
| openssl dgst -sha256 -hmac "$UPPROVE_SIGNING_SECRET" | sed 's/^.*= //')

curl -sS -X POST "https://api.sandbox.upprove.com${REQ_PATH}" \
-H "Authorization: Bearer $UPPROVE_SECRET_KEY" \
-H "X-Signature: t=${TS}, v1=${SIG}" \
-H "Content-Type: application/json" \
--data-raw "$BODY"

Omitting amount captures the full remaining authorized amount. The response carries captured_amount:

{
"order_id": "pay_5b4a3c2d",
"transaction_id": "txn_2c3d4e5f",
"status": "completed",
"amount": 42.00,
"captured_amount": 42.00,
"currency": "GBP"
}
transaction_id must be the authorization's

Passing a capture's or refund's transaction_id is refused. When in doubt, target by order_id or merchant_reference.

Partial and multiple captures​

Send an amount lower than the authorized total to capture part of it. The final flag decides what happens to the rest:

finalEffect
true (default)Settles this amount and releases the uncaptured remainder. The authorization closes.
falseSettles this amount and keeps the remainder capturable.
{
"order_id": "pay_5b4a3c2d",
"amount": "30.00",
"final": false
}

Omitting final is the same as true - a capture closes the authorization unless you explicitly say otherwise.

Multiple captures depend on the provider granting multicapture on the authorization. Where they haven't, the second capture fails with the decline code multicapture_not_supported.

The authorization closes when a final capture settles, when the captured total reaches amount, or when you void it.

3. Void​

To release a hold you no longer need, POST /v1/payments/void with the same one-of target. There is no amount - a void always releases whatever remains:

{
"order_id": "pay_5b4a3c2d"
}
{
"order_id": "pay_5b4a3c2d",
"transaction_id": "txn_7a8b9c0d",
"status": "canceled",
"amount": 42.00,
"voided_amount": 42.00,
"currency": "GBP"
}

voided_amount is what was actually released - the whole hold if nothing was captured, or only the remainder if a partial capture settled first. It is not necessarily amount.

A void is not idempotent

Voiding a payment that is already canceled returns 409 payment_unexpected_state, not a repeat of the original result. If a void times out, check the payment's state before retrying rather than firing again.

POST /v1/payments/{id}/3ds/complete is idempotent - don't generalize from one to the other.

Manual capture on hosted checkout​

Manual capture works on a hosted checkout session too. Add capture_method: "manual" to POST /v1/checkout/sessions:

{
"amount": "42.00",
"currency": "GBP",
"capture_method": "manual",
"merchant_reference": "order-1001",
"customer": {
"email": "jane@example.com"
},
"success_url": "https://shop.example.com/checkout/success",
"cancel_url": "https://shop.example.com/checkout/cancel"
}

The response echoes it, and so do GET /v1/checkout/sessions/{id} and the search:

{
"id": "cs_7e6d5c4b",
"url": "https://checkout.sandbox.upprove.com/cs_7e6d5c4b",
"status": "open",
"capture_method": "manual",
"merchant_reference": "order-1001",
"order_id": "pay_5b4a3c2d",
"amount": 42.00,
"currency": "GBP",
"created_at": "2024-06-22T10:00:00Z"
}

When the customer pays by card on the hosted page, the payment stops at the authorization. From the customer's side that is the end of the checkout: the session becomes completed, they see the success page and return to your success_url. Nothing has moved yet.

You are told twice, in this order:

EventMeaning
checkout.session.completedThe session is completed. Its payload carries capture_method: "manual" and the order_id and transaction_id you capture with.
payment.authorizedThe authorization succeeded and funds are held.

Do not fulfil on checkout.session.completed alone when the session is manual capture: the money arrives only when you capture. Target the capture (or void) with the session's order_id, its transaction_id (the authorization), or your merchant_reference - exactly as for a direct payment. The closing capture emits payment.succeeded; the session is already completed and does not change again.

capture_method applies to card payments. An alternative payment method the customer picks on the hosted page settles as it always has, and the session completes when that payment succeeds.

Statuses​

statusMeaningcaptured_amountvoided_amount
authorizedFunds held, nothing moved.--
settlingCapture accepted by the provider, not yet settled.--
completedThe authorization closed with money settled - by a capture, or by a void behind earlier captures.SetSet on a void
canceledA void released the whole hold; nothing was captured.-Set
failedThe capture or void did not succeed.--

Webhooks​

Manual capture has its own events. Subscribe to them - on the 3-D Secure path you are not in the request that decides the outcome, so payment.authorized is your only notification:

EventFires when
payment.authorizedThe authorization succeeded and funds are held.
payment.partially_capturedA capture settled but left the authorization open (final: false).
payment.capture_failedA capture did not settle. No money moved; the authorization stands.
payment.succeededThe capture that closed the authorization settled. Fires once per payment.
payment.canceledA void released the hold with nothing captured.

payment.succeeded carries captured_amount and the closing capture's capture_transaction_id and capture_provider_transaction_id. A void behind settled captures closes the payment the same way.

On a hosted checkout session, checkout.session.completed fires alongside payment.authorized, at authorization - see Manual capture on hosted checkout.

See Webhook events for the payload.

Errors​

codeHTTPWhen it happens
payment_unexpected_state409Capturing an already-captured payment, voiding after a final capture, or operating on a non-card payment. The message names the verb, the current status, and the expected one.
capture_amount_exceeds_authorized400The requested amount is more than was authorized, or is not positive.
capture_amount_exceeds_remaining400The requested amount is more than the uncaptured remainder, counting captures still in flight.
refund_requires_transaction_target400Refunding by order_id a payment captured in parts. Name the specific capture by transaction_id.

A capture or void the provider refuses is not an error: it returns 200 with status: "failed". Only the pre-flight checks above produce 4xx.

Refunding a partially captured payment​

A refund reverses exactly one settlement. When a payment has more than one settled capture, refunding by order_id is ambiguous and is refused with refund_requires_transaction_target - pass the transaction_id of the capture you want to reverse instead.

Next steps​