# Refund a customer's past charges

Send money back to a customer by refunding charges they already paid, instead of sending a
separate payout. For example, a customer who has spent $7,000 with you and is owed $5,000 can be
refunded $5,000 across those earlier card charges. The money goes back to the cards they paid
with, and you skip a payout rail and its fees.

You need two calls, both available today:

```
GET    https://api.worthypayments.com/v1/customers/:id/transactions   (payments:read)
POST   https://api.worthypayments.com/v1/refunds                      (refunds:create, server-only)
```

Before you build this, agree it with your acquirer and your finance team. Refunds count toward
your refund rate, and using refunds in place of payouts changes how the money is booked. This
guide covers the API side only.

---

## 1. List the customer's charges

```bash
curl "https://api.worthypayments.com/v1/customers/cus_8f1c…/transactions?limit=100" \
  -H "x-api-key: sk_live_xxx"
```

`GET /v1/customers/{id}/transactions` returns the customer's **charges only**:
- Every sale and authorization is listed, including those synced from your gateway.
- Refund, void and capture rows never appear here.
- The list is newest first, at most 100 a page.
- To get the next page, pass the previous page's `next_cursor` as `starting_after`, and keep going
  while `has_more` is `true`.

Each row carries what you need to know what it can still give back:

| Field | Meaning |
|---|---|
| `amount` | What the charge was, in cents. |
| `amount_refunded` | What it has already given back, in cents. API refunds and refunds made in your gateway's own portal are both counted. |
| `refunded` | `true` once that is the whole amount. |
| `disputed` | `true` while a chargeback or a matched pre-dispute alert is open on it. |
| `status` | `succeeded` for money taken. |
| `currency` | The charge's currency. Refund in the same one. |
| `created` | When the charge was made. |
| `card` | The card the charge was paid with: `brand`, `bin` (first six to eight digits, left out when we hold none) and `last4`. A refund of this charge goes back to this card. |

## 2. Keep to the person you owe

**Each refund goes back to the card its charge was paid with, and nowhere else.** One
`POST /v1/refunds` refunds exactly one charge. There is no bulk refund and no refund by customer,
so nothing here can send money to every card on an account at once. Which cards receive money is
decided entirely by which charges you refund.

That makes the choice of charges yours to get right. A customer record gathers every charge made
under it, and one account can hold charges paid with several cards: a new card after an old one
expired, a partner's card, or a card that was not the account holder's at all. Refunding "the
customer's charges" without looking at the card can pay someone other than the person you owe.

So before you refund:

1. **Decide which card(s) belong to the person you are paying**, from your own records: the card
   they verified with you, the card on their payout request, or the card they confirm. Identify
   each by `brand`, `bin` and `last4`.
2. **Refund only charges paid with those cards.** Skip every other charge, however refundable it
   looks. A charge whose `card.bin` is missing cannot be matched on all three, so leave it out
   unless your own records settle it.
3. **Send anything left over through your usual payout** to the account you have verified.

## 3. Pick what you can refund

A charge is refundable when **all** of these hold:

- it was paid with one of the cards you kept in step 2;
- `status` is `succeeded`;
- `refunded` is `false`;
- `disputed` is `false`;
- it is less than **180 days** old (`created`);
- what remains, `amount − amount_refunded`, is above zero.

Walk the list and take from each refundable charge what remains, until you reach the amount you
owe. Any order works. Newest first is a sound default, because those cards are the most likely
to still be open. If the refundable total is less than you owe, send the rest through your usual
payout.

## 4. Refund each charge

One `POST /v1/refunds` per charge, with an explicit `amount`:

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

- **`Idempotency-Key`** (header, required). Derive it from the payout and the charge, for example
  `payout-1042:<charge id>`. A retried request then refunds once. A fresh key on every attempt
  can refund twice. See [Idempotency](./12-idempotency.md).
- **`amount` and `currency`**. Always pass both. Without `currency` the request is read as `usd`,
  so a partial refund of a non-USD charge would be refused.
- **`reason`**. One of `requested_by_customer`, `duplicate` or `fraudulent`, or leave it out.

```js
// Node 18+. `owed` in cents; `charges` are the rows from step 1 (`created` is Unix seconds).
// `ownCards` are the payee's verified cards from step 2, e.g.
// [{ brand: "visa", bin: "424242", last4: "4242" }].
const DAY_MS = 86_400_000;

function paidWithOwnCard(c, ownCards) {
  const card = c.card ?? {};
  return ownCards.some(
    (own) =>
      card.brand === own.brand &&
      card.last4 === own.last4 &&
      card.bin != null &&
      card.bin.startsWith(own.bin),
  );
}

function refundable(c, ownCards) {
  const ageDays = (Date.now() - c.created * 1000) / DAY_MS;
  return (
    paidWithOwnCard(c, ownCards) &&
    c.status === "succeeded" &&
    !c.refunded &&
    !c.disputed &&
    ageDays < 180
  );
}

async function refundOne(c, amount, payoutId, apiKey) {
  for (;;) {
    const res = await fetch("https://api.worthypayments.com/v1/refunds", {
      method: "POST",
      headers: {
        "x-api-key": apiKey,
        // Same key on every retry of this charge in this payout: it refunds once.
        "Idempotency-Key": `payout-${payoutId}:${c.id}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ charge: c.id, amount, currency: c.currency }),
    });
    if (res.status !== 429) return res.ok;
    // The per-key request ceiling was hit: wait as told, then retry the same request.
    const wait = Number(res.headers.get("Retry-After") ?? 60) * 1000;
    await new Promise((r) => setTimeout(r, wait));
  }
}

async function refundOwed(charges, ownCards, owed, payoutId, apiKey) {
  let left = owed;
  for (const c of charges) {
    if (left <= 0) break;
    const remaining = c.amount - c.amount_refunded;
    if (!refundable(c, ownCards) || remaining <= 0) continue;
    const amount = Math.min(remaining, left);
    // A refused refund (see below) moves no money: skip that charge, try the next.
    if (await refundOne(c, amount, payoutId, apiKey)) left -= amount;
  }
  return left; // what still has to go through your usual payout
}
```

## What the API refuses for you

The server checks every refund itself, so a list that is a moment out of date cannot make you
pay twice. A refused refund moves no money and answers `400`, with the reason in the message:

| Message | Means | Do |
|---|---|---|
| `TRANSACTION_DISPUTED` | A chargeback or matched alert is open on the charge. The cardholder may already be getting this money back. | Skip it. |
| `REFUND_EXCEEDS_REMAINING` | `amount` is more than what remains, which a refund made meanwhile can cause. | Read the charge again and refund what remains. |
| `ALREADY_FULLY_REFUNDED` | Nothing is left. | Skip it. |
| `REFUND_WINDOW_EXPIRED` | The charge is over 180 days old. | Skip it. |
| `INVALID_STATE_FOR_REFUND` | The charge never captured money. | Skip it. |
| `REFUND_CURRENCY_MISMATCH` | `currency` is not the charge's. | Send the charge's `currency`. |

A `500` is the one ambiguous answer: the refund may or may not have gone through. Retry with the
**same** `Idempotency-Key`, or check `GET /v1/charges/{id}`, before you count it either way.

## Pace, then confirm

- **Rate limit.** There is no per-minute refund budget, only the general per-key request
  ceiling. Back off on a `429` by `Retry-After`. See [Rate limits](./14-rate-limits.md).
- **Confirmation.** Each refund raises a `charge.refunded` webhook ([Webhooks](./16-webhooks.md)).
  `GET /v1/charges/{id}` shows the new `amount_refunded`. Book the payout as settled by refunds
  for the sum that succeeded.

See also [Refunds](./06-refunds.md) and [Customers](./18-customers.md).
