Authorize & capture

View as Markdown

By 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 modeBehaviour
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
manualAuthorize only. Capture later via API / panel Capture
delayedAuthorize 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):

bash
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" }
  }'
json
{ "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):

bash
# 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 }'
json
{
  "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.

bash
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"
json
{ "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 full refund. The call still succeeds and answers the charge with 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 for charge.canceled after 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.

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:

CardCustomer-initiatedMerchant-initiated (a saved card, e.g. a renewal)
Visa7 days4 days 18 hours
Mastercard, American Express, Discover7 days7 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.