# 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`. |
| `note` | string | No | Your own words for why you refunded, e.g. `"Item arrived damaged"`. Stored on the refund next to `reason` and shown in your dashboard. Trimmed; empty or blank is the same as none. Up to 500 characters; longer, or anything that is not a string, is `400 parameter_invalid`, `param: "note"`. |

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", "note": "Item arrived damaged" }'
```

### 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",
  "note": "Item arrived damaged",
  "transaction_id": "01J..."
}
```

`note` is the one you sent, `null` when you sent none.

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

When we could not read your gateway credential just then (our credential service did not
answer), the refund is **`503`** `upstream_error`. Nothing was sent to the gateway: retry with
the same `Idempotency-Key`.

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`, a `note` over 500 characters) 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 same object as the create
response, with the `reason` and `note` the refund was created with, as stored:

```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",
  "note": "Item arrived damaged",
  "transaction_id": "01J..."
}
```

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

`reason` is one of the three values the create takes, or `null`:

| The refund was made… | `reason` | `note` |
|---|---|---|
| here, with `reason` | the value you sent | the `note` you sent, or `null` |
| here, without `reason` | `null` | the `note` you sent, or `null` |
| in your dashboard, as *Customer Request*, *Duplicate Transaction* or *Fraudulent Transaction* | `requested_by_customer`, `duplicate`, `fraudulent` | what was typed, or `null` |
| in your dashboard, as *Product Issue*, *Service Issue* or *Other* | `null` (none of the three says it) | what was typed, or `null` |
| in your gateway's own portal | `null` | `null` |

## 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 every refund of the charge: those made through the API or the
dashboard (`source: "platform"`) and those made in your gateway's own portal and synced from
the gateway (`source: "gateway"`), each as its own refund object under `charge` = the id you
asked for, with `created` in Unix seconds. To reconcile refund by refund, record each
`status: "succeeded"` refund by its `id`.

A charge's `amount_refunded` (`GET /v1/charges/{id}`) counts a refund from the moment it is
accepted. `amount_refunded_pending` (retrieve only) is the part the gateway has not confirmed
yet: a refund request answered `409 idempotency_in_progress`, which you retry with the same
`Idempotency-Key`. It returns to `0` either way. A refund answered `201` is confirmed and does
not fail later, so there is no refund-failed webhook.

```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", "note": null, "source": "platform", "created": 1792576800, "transaction_id": "01J..." } ], "has_more": false }
```

A listed refund reads `reason` and `note` as `GET /refunds/:id` does.

---

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