# 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 **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 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.

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

```mermaid
sequenceDiagram
    participant You
    participant API as WorthyPayments
    participant GW as Gateway
    You->>API: POST /v1/charges/{id}/void (key K)
    API->>GW: void
    GW--xAPI: no answer
    API-->>You: 502 (not stored: retry with K)
    You->>API: POST .../void (key K, within 30 min)
    API-->>You: 409 VOID_IN_PROGRESS (the first void still holds the charge)
    Note over API: 30 minutes after the claim, checked every minute
    API->>GW: is this authorization still live?
    alt the void landed
        API-->>You: charge.canceled (once)
    else the authorization is still live
        Note over API: claim released, the charge is as it was before the void
        You->>API: POST .../void (again)
    end
```

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

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