# Captures & refunds

A **capture** settles the funds reserved by an authorization. A **refund** returns captured funds to the customer after the processor confirms the payment. Both operations act on an existing payment identified by your original `externalOrderRef` string.

## Prerequisites

- For capture, the payment must be manual-capture: created with `autoSettlement: false`, or with `autoSettlement` left out on an account whose default is not automatic capture.
- You need the same `externalOrderRef` string you sent on `/initialize-payment` — this is the `{externalOrderRef}` path parameter.

> [!TIP]
> The `{externalOrderRef}` path parameter is the **string you chose** when you created the payment (e.g. `ORDER-1042`). It is not a numeric ID.

---

## Captures

### When to capture

Capture is only available when:

- the payment is manual-capture (`autoSettlement: false` on the original `/initialize-payment` request, or `autoSettlement` left out on an account whose default is not automatic capture), **and**
- its `lifecycleStatus` is `authorized`.

Otherwise capture returns `409 Conflict` with `"payment action is not allowed for the current payment status"`.

### Full capture

```bash
curl -sS -X POST "$FYNEX_API/payments-api/v1/payments/ORDER-1042/capture" \
  -H "Authorization: Bearer $FYNEX_TOKEN" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json"
```

**Response (200 OK):**

```json
{
  "paymentId": "ORDER-1042",
  "status": "provider_completed",
  "lifecycleStatus": "captured",
  "legacyStatus": "provider_completed",
  "providerCode": "<processor>",
  "providerPaymentId": "pay_01J2EXAMPLE",
  "amount": 49.99,
  "currencyCode": "GBP"
}
```

### Partial capture

Pass an `amount` smaller than the authorized total. The remainder is released automatically.

```bash
curl -sS -X POST "$FYNEX_API/payments-api/v1/payments/ORDER-1042/capture" \
  -H "Authorization: Bearer $FYNEX_TOKEN" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{ "amount": 30.00 }'
```

> [!CAUTION]
> Capture once for the final amount. You cannot issue a second capture on the same payment.
> Capture is also blocked while a refund is pending or after any successful refund on that payment.

### Capture request body

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `amount` | `float64` | No | Major units (e.g. `30.00`). Omit for full-amount capture. Must be `> 0` and `<=` authorized amount. |

### Capture response fields

| Field | Type | Description |
|-------|------|-------------|
| `paymentId` | `string` | Your `externalOrderRef` |
| `status` | `string` | Payment status post-capture — the previous values until the switch (see [Payment Lifecycle](https://docs.fynex.ai/payments-api/v2/docs#tag/payment-lifecycle)) |
| `lifecycleStatus` | `string` | `captured` after a successful capture |
| `legacyStatus` | `string` | Deprecated: the previous status value |
| `providerCode` | `string` | Upstream processor identifier |
| `providerPaymentId` | `string` | Provider's reference for the transaction |
| `amount` | `float64` | Captured amount in major units |
| `currencyCode` | `string` | 3-letter ISO currency |
| `failureCode` | `int` | Present if processor declined |
| `failureDescription` | `string` | Human-readable decline reason |

---

## Refunds

### When to refund

Refund is only available when:

- the payment's `lifecycleStatus` is `captured`, **and**
- its `settlementStatus` is not `in_flight`, **and**
- no refund on the payment is still `pending`, **and**
- refunds are enabled for your account.

Attempting a new refund outside these conditions is refused (`409 Conflict` when the payment's status does not allow it).

> [!NOTE]
> A card refund also waits for the acquirer's settlement batch. Refunding on the day the payment was captured answers `409` with `settlement not yet available for refund`: nothing is refunded, the payment stays refundable, and the same request succeeds after the next batch, usually the next day. Send it again with a fresh `Idempotency-Key`. See [Errors](https://docs.fynex.ai/payments-api/v2/docs#tag/errors).

> [!NOTE]
> An order that a coupon and promotional credit paid **in full** took no cash, so there is nothing to refund: `POST /refund` answers `409` ("this order was paid entirely by promotions; cancel the promotional order instead"). Cancel it with `POST /payments-api/v1/payments/{payment_id}/cancel-promotion` and a JSON body `{"reason": "..."}` (1 to 512 characters, same `Idempotency-Key` header). The buyer's credit and coupon are given back, the payment becomes `refunded`, and you receive a `PaymentPromotionCancelled` webhook instead of `PaymentRefunded`. An order that took any cash must have that cash refunded first. A payment that a promotion part-paid can be captured only in full (a partial `amount` answers `409`).
If your original `POST /refund` timed out while the refund is still pending,
retry with the **same** `Idempotency-Key` to replay the existing refund row.
Using a different key is treated as a new refund attempt and stays blocked until
the pending refund reaches `succeeded`, `failed`, or `cancelled`.

### Full refund

```bash
curl -sS -X POST "$FYNEX_API/payments-api/v1/payments/ORDER-1042/refund" \
  -H "Authorization: Bearer $FYNEX_TOKEN" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json"
```

### Partial refund

```bash
curl -sS -X POST "$FYNEX_API/payments-api/v1/payments/ORDER-1042/refund" \
  -H "Authorization: Bearer $FYNEX_TOKEN" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{ "amount": 10.00 }'
```

Issue multiple partial refunds as long as the running total of successful refunds stays at or below the captured amount. Omit `amount` to refund the remaining refundable balance.

### What customers see

Refunds typically reach the customer's bank within 3–10 business days, depending on the issuer. The funds appear as a separate credit transaction — the original charge is not reversed.

### Refund response (200 OK)

```json
{
  "id": "6f9b84e1-3b83-4fb9-9f42-a8ac27d11d6b",
  "paymentId": "ORDER-1042",
  "status": "pending",
  "amount": 10.00,
  "currencyCode": "GBP",
  "providerRefundId": "rfnd_01J2EXAMPLE",
  "createdAt": "2026-05-11T12:34:56Z",
  "updatedAt": "2026-05-11T12:34:56Z"
}
```

The status will be `pending` immediately after the request. Once the provider processes it, the refund moves to `succeeded` or `failed`.

### Refund response fields

| Field | Type | Description |
|-------|------|-------------|
| `id` | `string` | Unique ID of this refund ledger row |
| `paymentId` | `string` | Your `externalOrderRef` for the parent payment |
| `status` | `string` | `pending`, `succeeded`, `failed`, or `cancelled` |
| `amount` | `float64` | Refunded amount in major units |
| `currencyCode` | `string` | 3-letter ISO currency |
| `providerRefundId` | `string` | Provider's reference for the refund (present once issued) |
| `failureCode` | `int` | Present if the provider rejected the refund |
| `failureDescription` | `string` | Human-readable failure reason |
| `createdAt` | `string` | When the refund was created |
| `updatedAt` | `string` | When the refund row was last updated |
| `completedAt` | `string` | When the refund reached a terminal state (present once complete) |

---

## Status reference

Payment status is defined in [Payment Lifecycle](https://docs.fynex.ai/payments-api/v2/docs#tag/payment-lifecycle). A capture moves `lifecycleStatus` from `authorized` to `captured`; a partial capture, a partial refund and a refund in flight leave it `captured`, and it becomes `refunded` only once the whole captured amount is refunded.

---

## Idempotency

Both endpoints require an `Idempotency-Key` UUID header. Always send one — network glitches can cause duplicate submissions without it.

The key is forwarded to the upstream processor as its own capture/refund idempotency key, so provider-side duplicates are also prevented.

See [Idempotency & retries](https://docs.fynex.ai/payments-api/v2/docs#tag/idempotency) for the full retry pattern.

---

## Dynamic webhook URL

Both `POST /payments/{payment_id}/capture` and `POST /payments/{payment_id}/refund` accept an optional `webhookUrl` field on the request body. When set, the resulting webhook event is delivered to that URL **in addition** to the seller's configured `SellerWebhookConfig` URLs.

The dynamic URL must:

- Be HTTPS (`http://` rejected with `webhook_url_not_https`)
- Be ≤ 1024 chars (`webhook_url_too_long`)
- Resolve via DNS (`webhook_url_dns_failed`)
- Have every resolved IP covered by an active entry in the seller's webhook allowlist (`webhook_url_not_allowlisted`)
- Not resolve to a private / loopback / link-local / multicast range, even if explicitly allowlisted (`webhook_url_resolves_to_private_ip` — defense-in-depth)

The seller must have at least one active `SellerWebhookConfig` row before `webhookUrl` is accepted — otherwise the request fails with `webhook_url_requires_configured_webhook` (422).

Manage the per-seller allowlist via `GET` / `POST` / `DELETE /payments-api/v1/webhooks/allowlist`. Outgoing deliveries to the dynamic URL are signed with the lexicographically-first active config's secret using HMAC-SHA256 over `<X-Fynex-Timestamp>.<raw body>` (`X-Fynex-Signature: sha256=<hex>`, `X-Fynex-Timestamp: <unix-seconds>`).

See the [Webhooks tag](https://docs.fynex.ai/payments-api/v2/docs#tag/webhooks) for the full signature-verification flow.

### Example

```bash
curl -sS -X POST "$FYNEX_API/payments-api/v1/payments/ORDER-1042/refund" \
  -H "Authorization: Bearer $FYNEX_TOKEN" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
    "amount": 10.00,
    "webhookUrl": "https://merchant.example.com/webhooks/refunds/ORDER-1042"
  }'
```

---

## Status codes

| Status | When |
|--------|------|
| `200` | Success |
| `400` | Missing/invalid `Idempotency-Key`; bad body or amount |
| `401` | Missing or invalid bearer token |
| `404` | Payment not found for this `externalOrderRef` and seller |
| `409` | Status precondition not met; already refunded; capture attempted while/after refunding; `autoSettlement` was `true` |
| `502` | Upstream processor error |

---

## Verify the result

Capture and refund events fire **outbound webhooks** — they're delivered to every active `SellerWebhookConfig` URL (plus any per-request `webhookUrl` you set, see the section above). The fastest way to learn the terminal status is to receive that webhook and verify its `X-Fynex-Signature` header.

If you'd rather poll, read the payment over REST with your bearer token —
`GET /payments-api/v1/payments/{payment_id}`, where the path value is your
`externalOrderRef` — and watch `lifecycleStatus` become `refunded` once the whole
amount is refunded. A partial or failed refund leaves it `captured`, so follow the
refund itself on `GET /refunds/{refund_id}`.
From a dashboard session the GraphQL query reads the same payment. Its
`lifecycleStatus` is the same value as on REST; its `status` is the internal value:

```graphql
# id is Int! — use the numeric internal ID.
# genericPayment looks a payment up by its numeric id; there is no
# externalOrderRef argument on the GraphQL query.
query {
  genericPayment(id: 42) {
    lifecycleStatus
    amount
  }
}
```

Or use the SSE stream on the hosted checkout page. See [Polling & SSE](https://docs.fynex.ai/payments-api/v2/docs#tag/polling-sse).

## See also

- **[Polling & SSE](https://docs.fynex.ai/payments-api/v2/docs#tag/polling-sse)** — Poll payment status or subscribe to server-sent events.
- **[Errors](https://docs.fynex.ai/payments-api/v2/docs#tag/errors)** — Interpret 409s and provider failure codes.
- **[Disputes & chargebacks](https://docs.fynex.ai/payments-api/v2/docs#tag/disputes)** — If a customer disputes a payment, see the Disputes & chargebacks guide.
