# Refunds

Return funds for a previous charge, in full or in part.

```
POST   https://api.worthypayments.com/v1/refunds
```

Scope: `refunds:create`, **server-only**. A publishable key (`pk_`) cannot refund; use a
secret key (`sk_`). See [Authentication](./02-authentication.md).

---

## Create a refund

`POST /refunds`

### Request body

| Field | Type | Required | Notes |
|-------|------|:---:|-------|
| `charge` | string | Yes | The id of the charge to refund (the `id` from `POST /charges`, or from a charge synced from your gateway). |
| `amount` | integer | No | **Cents.** Omit for a **full** refund of what remains. Provide a smaller amount for a partial refund. Must be a whole number ≥ `1`; a negative, zero or fractional value is `400 parameter_invalid`. |
| `currency` | string | No | Read only with `amount`. When omitted it defaults to **`usd`**, so a partial refund of a non-USD charge must pass the charge's own currency. A different currency is refused (`REFUND_CURRENCY_MISMATCH`). |
| `reason` | string | No | One of `requested_by_customer`, `duplicate` or `fraudulent`. Any other value is `400 parameter_invalid`. |

The `Idempotency-Key` **header** is required. A missing header is `400 idempotency_key_required`,
and a body field does not take its place.

### Example: full refund

```bash
curl https://api.worthypayments.com/v1/refunds \
  -H "x-api-key: sk_live_xxx" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{ "charge": "TXN-8ac4796a-1940-4c6a-ba54-f151606330e3", "reason": "requested_by_customer" }'
```

### Example: partial refund ($20.00 of a larger charge)

```bash
curl https://api.worthypayments.com/v1/refunds \
  -H "x-api-key: sk_live_xxx" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{ "charge": "TXN-8ac4796a-1940-4c6a-ba54-f151606330e3", "amount": 2000, "currency": "usd" }'
```

### Response: succeeded

```json
{
  "id": "re_TXN-1f0c5a92-6b4d-4a17-9e88-2c7d54a10b33",
  "object": "refund",
  "charge": "TXN-8ac4796a-1940-4c6a-ba54-f151606330e3",
  "amount": 2000,
  "currency": "usd",
  "status": "succeeded",
  "reason": "requested_by_customer",
  "transaction_id": "01J..."
}
```

`amount` is always present: on a full refund (where you omit `amount` in the request) it is
the remaining captured cents the service refunded, not an omitted field. There is no
`created` field on this object.

### Response: failed

A refund that cannot be processed comes back as the standard error envelope. There is no
single `refund_failed` code, branch on what actually arrives:

**Unknown charge → `404`**

```json
{
  "error": {
    "type": "invalid_request_error",
    "code": "resource_missing",
    "message": "TRANSACTION_NOT_FOUND",
    "request_id": "2358c7d9-…"
  }
}
```

**Refused before any money moves → `400`**, with the reason in `message`:

| `message` | `code` | Means |
|---|---|---|
| `REFUND_EXCEEDS_REMAINING` | `invalid_argument` | `amount` is more than what remains. Refunds made through the API and in your gateway's own portal both count. |
| `ALREADY_FULLY_REFUNDED` | `failed_precondition` | Nothing is left to refund. |
| `TRANSACTION_DISPUTED` | `failed_precondition` | A chargeback or a matched pre-dispute alert is open on the charge. The cardholder may already be getting the money back, and a refund on top would pay twice. |
| `REFUND_WINDOW_EXPIRED` | `failed_precondition` | The charge is more than 180 days old (for a gateway-synced charge, from the gateway's own date). |
| `INVALID_STATE_FOR_REFUND` | `failed_precondition` | The charge never captured money. |
| `REFUND_CURRENCY_MISMATCH` | `invalid_argument` | `currency` is not the charge's. |

**Refused by the gateway → `400`** with `code: "refund_declined"`, the gateway's
`decline_code`, and its reason in `message`. No money moved. The refusal is the gateway's
answer: a retry with the same `Idempotency-Key` replays it; send a new key once you have
changed what made the gateway refuse.

```json
{
  "error": {
    "type": "invalid_request_error",
    "code": "refund_declined",
    "decline_code": "generic_decline",
    "message": "Refund amount may not exceed the transaction balance",
    "request_id": "2358c7d9-…"
  }
}
```

A processor failure is **`500`** with `code: "upstream_error"`. A `500` is the ambiguous case:
the refund may or may not have been submitted. Reconcile with
`GET /v1/charges/{id}` (its `amount_refunded`), or retry with the **same** `Idempotency-Key`,
rather than retrying blindly. Edge validation (missing `charge`, a non-integer or non-positive
`amount`, an unknown `reason`) is the separate `400` `parameter_invalid`.

See [Errors](./10-errors.md) for the full envelope and retry guidance.

---

## Retrieve a refund

`GET /refunds/:id` requires `payments:read`. It returns the fuller stored shape, including
`currency` and `reason`:

```json
{
  "id": "re_TXN-1f0c5a92-6b4d-4a17-9e88-2c7d54a10b33",
  "object": "refund",
  "charge": "TXN-8ac4796a-1940-4c6a-ba54-f151606330e3",
  "amount": 2000,
  "currency": "usd",
  "status": "succeeded",
  "reason": "duplicate",
  "transaction_id": "01J..."
}
```

`status` maps to `succeeded` / `pending` / `failed`. Unknown id → `404 resource_missing`.

## List refunds

`GET /refunds` requires `payments:read`. Cursor-paginated, newest first. Accepts
`limit` (default 10, max 100), `starting_after`, and `charge` to filter to one charge.
See [Pagination](./13-pagination.md).

The `charge` filter lists the refunds made through the API. A refund made in your gateway's own
portal points at the gateway's id and is not listed under the charge's `id`. For the full total
a charge has given back, read its `amount_refunded` (`GET /v1/charges/{id}`).

```bash
curl "https://api.worthypayments.com/v1/refunds?charge=TXN-8ac4796a-1940-4c6a-ba54-f151606330e3" -H "x-api-key: sk_live_xxx"
```
```json
{ "object": "list", "data": [ { "id": "re_TXN-1f0c5a92-6b4d-4a17-9e88-2c7d54a10b33", "object": "refund", "charge": "TXN-8ac4796a-1940-4c6a-ba54-f151606330e3", "amount": 2000, "currency": "usd", "status": "succeeded", "reason": "duplicate", "transaction_id": "01J..." } ], "has_more": false }
```

---

## Notes

- Refunds settle through the same acquirer (`payarc`, `nuvei`, `worthy`) that
  processed the original charge. You don't specify it.
- A refund goes back to the card the charge was paid with, and only there. Each request
  refunds one charge; there is no bulk refund.
- Partial refunds can be issued multiple times against one charge until the original
  amount is exhausted.
- To refund a customer's past charges, for example instead of sending a payout, see
  [Refund a customer's past charges](./06b-refund-past-charges.md).
- Refunds are server-only by design: keep your `sk_` key on the backend and never trigger
  refunds from the browser.

### Refunding the same charge twice

The protection against a double refund is your `Idempotency-Key`, not the charge id. Two
refunds against the same charge are perfectly legal (that is what partial refunds are) so
the API cannot tell an intentional second partial refund apart from an accidental replay.
**It only knows they are the same request if you send the same key.**

The practical rule: derive the key from the thing you are refunding, not from the moment
you are refunding it. A key like `refund:order-1042:full` collapses a retry into one
refund; a fresh `uuidgen` on every attempt does not, and a timed-out request retried
with a new key refunds twice.

Once the original amount is exhausted, further refunds are rejected upstream. You get a
`400` with `code: "invalid_argument"` and message `REFUND_EXCEEDS_REMAINING`.

### Rate limits

Refunds are not counted against a per-minute refund budget; only the general per-key
request ceiling applies (see [Rate limits](./14-rate-limits.md)). A bulk refund script that
does hit a `429 rate_limit_exceeded` should back off by the `Retry-After` header.
