Authorize & capture
View as MarkdownBy default a charge captures the funds immediately. With manual capture you authorize the card first (placing a hold) and capture later. This is what you want when you charge on shipment, confirm stock, or finalize an amount after the order.
Payment Workflow Capture Timing (merchant panel)#
Merchants can set Capture Timing on a Payment Workflow route (canvas Capture
node): instant | manual | delayed (+ hours).
| Canvas mode | Behaviour |
|---|---|
no Capture block (null) | Request wins. capture_method: "manual" still authorizes |
| instant (explicit Capture node) | Request wins. A normal charge is captured at once; capture_method: "manual" still authorizes |
| manual | Authorize only. Capture later via API / panel Capture |
| delayed | Authorize only, then the platform auto-captures after N hours (1 to 168), or 6 hours before the card's authorization would expire if that comes first |
A route can hold money, never capture it early: a manual or delayed
Capture Timing on the matching route turns a charge into an authorization, whatever
the request asked. instant, or no Capture block, leaves the request's
capture_method / CaptureMode as sent, so an authorize-only request is never
captured by a route.
Internal design note (engineers):
docs/superpowers/specs/2026-07-30-capture-timing-design.md.
Authorize only#
Send capture_method: "manual" on the charge. The card is authorized but not captured
(unless a Payment Workflow route overrides it, as above):
curl https://api.worthypayments.com/v1/charges \
-H "x-api-key: sk_test_xxx" -H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"amount": 5000, "currency": "usd",
"capture_method": "manual",
"card": { "number": "ev:...", "cvv": "ev:...", "exp_month": "12", "exp_year": "30" }
}'
{ "id": "TXN-8ac4796a-1940-4c6a-ba54-f151606330e3", "object": "charge", "status": "requires_capture", "amount": 5000, .. }
The charge sits in requires_capture. An authorize emits a charge.authorized webhook
(not charge.succeeded, since nothing is captured yet).
Capture#
Capture the held funds in full, or a smaller amount (partial capture):
# Full capture — Idempotency-Key is required (same as charge create)
curl -X POST https://api.worthypayments.com/v1/charges/TXN-8ac4796a-1940-4c6a-ba54-f151606330e3/capture \
-H "x-api-key: sk_test_xxx" \
-H "Idempotency-Key: capture_ord_1234"
# Partial capture ($30 of a $50 authorization)
curl -X POST https://api.worthypayments.com/v1/charges/TXN-8ac4796a-1940-4c6a-ba54-f151606330e3/capture \
-H "x-api-key: sk_test_xxx" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: capture_ord_1234_partial" \
-d '{ "amount": 3000 }'
{
"id": "TXN-8ac4796a-1940-4c6a-ba54-f151606330e3",
"object": "charge",
"status": "succeeded",
"captured": true,
"amount": 5000,
"amount_captured": 3000,
"auth_code": "TESTOK",
"processor_transaction_id": "10234567890"
}
amount is still 5000. It is what the charge was created for and it never changes.
A partial capture is reported by amount_captured, not by rewriting the authorization.
The capture response is the charge as it now stands, identical to what GET /v1/charges/{id} returns for it, so storing either one gives you the same figures.
A capture emits charge.succeeded. Omitting Idempotency-Key is rejected with
400 idempotency_key_required.
Capturing more than the remaining authorization is rejected before it reaches the
gateway. The payments service compares amount to what is still authorized and returns
400 with code: "invalid_argument" and message CAPTURE_EXCEEDS_AUTHORIZED. That is
not a parameter_invalid naming the field, and it is not a generic processor error, do
not retry the same amount; send a smaller one or capture the remainder by omitting
amount.
A successful capture (full or partial) marks the charge captured. A second POST /charges/:id/capture is then rejected with 400 failed_precondition
(INVALID_STATE_FOR_CAPTURE). The remainder is not left sitting for another capture; send
the amount you actually intend to settle, once.
amount means something different on the capture response than on the charge. The
capture reply above echoes the captured figure (3000); retrieving that same charge
afterwards returns "amount": 5000. The original authorization, which never changes.
Reconcile against amount_captured, which retrieve returns alongside it, and not against
amount. Refunds are then capped at the captured amount, not the authorized one: on
this charge a refund of more than 3000 is rejected with REFUND_EXCEEDS_REMAINING.
Void (cancel)#
To release an authorization without charging, or to cancel a captured charge before its
batch settles, void it. Idempotency-Key is required.
curl -X POST https://api.worthypayments.com/v1/charges/TXN-8ac4796a-1940-4c6a-ba54-f151606330e3/void \
-H "x-api-key: sk_test_xxx" \
-H "Idempotency-Key: void_ord_1234"
{ "id": "TXN-8ac4796a-1940-4c6a-ba54-f151606330e3", "object": "charge", "status": "canceled" }
The response is the charge as it now stands, the same object GET /v1/charges/{id} returns.
A void emits charge.canceled. A captured charge whose batch has not settled yet is
voided too: the charge disappears from the buyer's statement instead of settling and
coming back as a refund. If the charge has already settled (or the gateway refuses the
void because its batch closed), the void is automatically converted to a refund of
everything not yet refunded. The same happens to an unsettled captured charge that was
already partly refunded (a void would hand that part back twice) or that is disputed (the
refund's dispute check decides). The call still succeeds and answers the charge with
status: "succeeded" and its amount_refunded, but what happened is a refund, not a
cancellation. Two
consequences worth designing for:
- The event you receive is the refund event, not
charge.canceled. Code that waits forcharge.canceledafter calling void will wait forever on a charge that had already settled. - A refund is money that left and came back: it settles on the buyer's statement as a separate line and takes days, where a void stops the charge from ever posting (the buyer may still see the pending authorization until their bank drops it). Whether you get a void or a refund depends on how far the charge had settled by the time you called, which is a race against the acquirer, not something you control.
Voiding a charge that was already voided is not a no-op: it is rejected with 400
failed_precondition (INVALID_STATE_FOR_VOID). There is no second gateway call and no
second event, but the call does not return 200.
A concurrent second void, or a retry while the first is still claimed, is 409
aborted / VOID_IN_PROGRESS. Wait for the first to finish; do not fire another.
A void whose answer was lost. When the gateway's answer to a void does not come back (a
502, or a timeout), we cannot know whether the authorization was released, so the charge
stays claimed for the void and nothing else may move it:
You never need to retry inside those 30 minutes. If you do not receive charge.canceled
after them, read the charge: status: canceled means the void landed; any other status means
it did not, and voiding again is safe.
When the gateway refuses. A capture the gateway refuses is the decline envelope, 402
card_declined with its decline_code; a refused void is 400 void_declined with its
decline_code. In both cases the authorization is still held and message carries the
gateway's reason. The refusal is the gateway's answer, so a retry with the same
Idempotency-Key replays it. A gateway error, where the outcome is not known, is 502;
retry with the same key.
TEST-mode charges whose processor id starts with TEST- complete in our orchestration
and never call an acquirer.
Lifecycle#
Authorizations expire if not captured. We cancel an uncaptured online authorization at the end of these windows:
| Card | Customer-initiated | Merchant-initiated (a saved card, e.g. a renewal) |
|---|---|---|
| Visa | 7 days | 4 days 18 hours |
| Mastercard, American Express, Discover | 7 days | 7 days |
They are never longer than the card networks' own limits (Visa 10 days customer-initiated and 5 days merchant-initiated, Discover 10 days, Mastercard and American Express 7 days), so a capture inside them is still within the authorization's validity.
A delayed auto-capture runs at the delay you set, or 6 hours before the authorization expires if that comes first, so a 168-hour delay on a Visa renewal is captured after about 4.5 days. If a capture attempt does not go through, the platform tries again after 1 minute, then waits twice as long each time, up to an hour between tries, until the authorization expires.
Every authorized charge tells you its deadline: capture_before on the charge (and
"Capture before" in the dashboard). An authorization nobody captured by then, manual or
delayed, is canceled when its window ends: the charge reads status: canceled and can no longer be captured. The authorization is
not voided at the gateway, so the hold stays until the buyer's bank drops it, which can take a
few more days; void the charge before capture_before to release it sooner. You receive charge.expired and, at the same moment,
charge.canceled (or transaction.expired and transaction.voided). The check runs
every 15 minutes, so the cancellation lands within a quarter of an hour of the window
closing. If you still need the money, take a new payment. This applies to charges made
through the platform; an authorization synced in from your gateway account stays as the
gateway reports it.
Both capture and void accept an Idempotency-Key.
