# 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

- The payment must have been created with `autoSettlement: false` (for 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:

- `autoSettlement` was `false` on the original `/initialize-payment` request, **and**
- The payment status is `authorized` or `provider_completed`.

Any other status returns `409 Conflict` with `"invalid status transition"`.

### Full capture

#### curl

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

#### JavaScript

```js
import { randomUUID } from 'node:crypto';

const res = await fetch(`${process.env.FYNEX_API}/payments-api/v1/payments/ORDER-1042/capture`, {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.FYNEX_TOKEN}`,
    'Idempotency-Key': randomUUID(),
    'Content-Type': 'application/json',
  },
});
const data = await res.json();
```

#### Python

```python
import os, uuid, requests

res = requests.post(
    f"{os.environ['FYNEX_API']}/payments-api/v1/payments/ORDER-1042/capture",
    headers={
        "Authorization": f"Bearer {os.environ['FYNEX_TOKEN']}",
        "Idempotency-Key": str(uuid.uuid4()),
        "Content-Type": "application/json",
    },
)
data = res.json()
```

**Response (200 OK):**

```json
{
  "paymentId": "ORDER-1042",
  "status": "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.

#### curl

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

#### JavaScript

```js
import { randomUUID } from 'node:crypto';

await fetch(`${process.env.FYNEX_API}/payments-api/v1/payments/ORDER-1042/capture`, {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.FYNEX_TOKEN}`,
    'Idempotency-Key': randomUUID(),
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({ amount: 30.00 }),
});
```

#### Python

```python
import os, uuid, requests

requests.post(
    f"{os.environ['FYNEX_API']}/payments-api/v1/payments/ORDER-1042/capture",
    headers={
        "Authorization": f"Bearer {os.environ['FYNEX_TOKEN']}",
        "Idempotency-Key": str(uuid.uuid4()),
        "Content-Type": "application/json",
    },
    json={"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` | `GenericPaymentStatus` post-capture |
| `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:

- Payment status is `provider_completed`, `settled`, `deposit_confirmed`, `refund_failed`, or `refund_cancelled`, **and**
- The payment is not already `refunded` or `refund_pending`.

Attempting a new refund outside these states returns `409 Conflict`.
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

#### curl

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

#### JavaScript

```js
import { randomUUID } from 'node:crypto';

await fetch(`${process.env.FYNEX_API}/payments-api/v1/payments/ORDER-1042/refund`, {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.FYNEX_TOKEN}`,
    'Idempotency-Key': randomUUID(),
    'Content-Type': 'application/json',
  },
});
```

#### Python

```python
import os, uuid, requests

requests.post(
    f"{os.environ['FYNEX_API']}/payments-api/v1/payments/ORDER-1042/refund",
    headers={
        "Authorization": f"Bearer {os.environ['FYNEX_TOKEN']}",
        "Idempotency-Key": str(uuid.uuid4()),
        "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

The full `GenericPaymentStatus` enum, in lifecycle order:

| Status | Description |
|--------|-------------|
| `draft` | Payment record created but not yet submitted |
| `new` | Submitted to the routing engine |
| `routed` | Assigned to a processor terminal |
| `provider_pending` | Submitted to the upstream processor |
| `authorized` | Funds reserved — ready to capture |
| `provider_completed` | Provider confirmed capture — ready to refund |
| `funds_in_flight` | Settlement in progress |
| `settled` | Funds settled — ready to refund |
| `deposit_confirmed` | Deposit confirmed — ready to refund |
| `refund_pending` | Refund submitted to the processor |
| `refunded` | Refund completed |
| `refund_failed` | Processor rejected the refund |
| `refund_cancelled` | Refund was cancelled |
| `failed` | Payment failed |
| `cancelled` | Payment was cancelled |

**Capture window:** `authorized` → `provider_completed` → *(capture)* → `funds_in_flight` → `settled`

**Refund window:** `provider_completed`, `settled`, `deposit_confirmed`, `refund_failed`, or `refund_cancelled` → `refund_pending` → `refunded` when fully refunded, back to a refundable captured state after a successful partial refund, or `refund_failed`/`refund_cancelled` when the attempt fails/cancels.

> [!CAUTION]
> The statuses `captured`, `partially_captured`, and `partially_refunded` do **not** exist. If you see these names in older code or documentation, they are incorrect.

---

## 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://api.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 (`X-Fynex-Signature: sha256=<hex>`, `X-Fynex-Timestamp: <unix-seconds>`).

See the [Webhooks tag](https://api.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, query the payment via REST or GraphQL:

```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) {
    status
    amount
  }
}
```

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

## See also

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