# Authorize & capture

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 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):

```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

```mermaid
flowchart LR
    A["Authorize<br/>capture_method: manual,<br/>or a manual / delayed workflow"] --> R([requires_capture])
    R -->|"Capture (API or panel)"| S1([succeeded])
    R -->|"Delayed auto-capture (cron)"| S2([succeeded])
    R -->|Void| C([canceled])
    R -->|"Not captured in time"| X([canceled: expired])
    B["Sale<br/>default, or an instant workflow"] --> S3([succeeded])
    S1 -.-> E1[["charge.succeeded"]]
    S2 -.-> E1
    C -.-> E2[["charge.canceled"]]
    X -.-> E2
    X -.-> E3[["charge.expired"]]
    S3 -.-> E1
```

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`](./12-idempotency.md).
