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

Manual capture currently needs a server-to-server charge: the create must carry payment_method.details.card_token. Browser-collected card payments cannot use it yet, and it is rejected on a zero-amount setup.

1. Authorize

Add capture_method: "manual" to a POST /v1/payments that charges a token:

{
"amount": "42.00",
"currency": "GBP",
"country": "GB",
"payment_method": {
"code": "card",
"details": {"card_token": "tok_1PqRsT2eZvKYlo2C"}
},
"capture_method": "manual",
"merchant_reference": "order-1001",
"success_url": "https://shop.example.com/ok",
"cancel_url": "https://shop.example.com/cancel"
}

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.

If the issuer demands 3-D Secure you get requires_action instead; 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 "$ORCHESTR_SIGNING_SECRET" | sed 's/^.*= //')

curl -sS -X POST "https://api.sandbox.upprove.com${REQ_PATH}" \
-H "Authorization: Bearer $ORCHESTR_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.

Statuses

statusMeaningcaptured_amountvoided_amount
authorizedFunds held, nothing moved.
settlingCapture accepted by the provider, not yet settled.
completedThe capture settled and closed the authorization.Set
canceledA void released the hold.Set if a capture settled firstSet
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.

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