---
updatedAt: 2026-09-30T15:54:20.000Z
---

Fetch the complete documentation index at: https://docs.avvio.xyz/llms.txt. Use this file to discover all available pages before exploring further. Append .md to any documentation page URL to get its markdown version.

# Refund a payment

Give a checkout buyer their card payment back, in full or in part, from the
dashboard or your server.

A `paid` card payment can be refunded at any time until nothing is left,
unless the buyer is disputing it. The money goes back to the card, usually
within 5 to 10 business days, and the original processing fee is not
returned. Bank and stablecoin payments cannot be reversed at the source; send
the payer a payout instead.

## Summary

1. Get a key with the `refunds` scope, or an owner or admin seat.
2. Refund: `POST /checkout/organizations/{orgId}/payments/{paymentId}/refund`.
3. Handle `checkout_payment.partially_refunded` and `checkout_payment.refunded`.

## 1. Get permission to refund

Refunding is a signing decision, like choosing where a payout lands, so a
`write` key cannot refund by default. In the dashboard only an **owner** or
**admin** sees the refund action; operators and viewers see the payment
without it.

For the API, an owner or admin creates a key under **Developers**, chooses
**Transact** and ticks **Refund checkout payments**. The key gets
`scopes: ["write", "refunds"]`, and only an owner or admin can issue or rotate
it. A key without the scope gets `403 INSUFFICIENT_SCOPE` and nothing moves.

## 2. Refund the payment

### From your server

```http
POST /checkout/organizations/{orgId}/payments/{paymentId}/refund
x-api-key: avvio_live_…
Idempotency-Key: 5c1a6c7e-2f0d-4a3b-9c1e-7d2b8f4a6e10
Content-Type: application/json

{ "amount": "12.50", "reason": "requested_by_customer", "note": "Session moved to October" }
```

`paymentId` is the payment's `id` on `GET …/links/{linkId}/payments`, and
`paymentId` on every `checkout_payment.*` event.

| Field    | Required | Meaning |
| -------- | -------- | ------- |
| `amount` | no       | Decimal string in the payment's currency, at most its decimal places. **Omit it to refund everything left** |
| `reason` | no       | `requested_by_customer`, `duplicate`, `fraudulent` or `other`. Shown in the dashboard, echoed on the event |
| `note`   | no       | Up to 255 characters, for your team. Echoed on the event, never shown to the buyer |

The response is the payment after the refund, plus `refund`, the piece this
call made:

```jsonc
{
  "id": "cmf9x1b2c0003q8b7h6j8k0lm",
  "status": "paid",                    // "refunded" once nothing is left
  "amountBase": "15000",
  "refundedBase": "1250",              // cumulative, in minor units
  "currency": "USD",
  "decimals": 2,
  "refunds": [
    {
      "id": "cmfa0…",
      "paymentId": "cmf9x1b2c0003q8b7h6j8k0lm",
      "amountBase": "1250",
      "reason": "requested_by_customer",
      "source": "api",                 // api | dashboard | acquirer
      "status": "succeeded",           // pending until the processor confirms it
      "actor": "API key Booking server", // "API key <name>"; a person's name, or their email if no name is set; or null
      "createdAt": "2026-09-21T10:14:02.000Z"
    }
  ],
  "refund": { "id": "cmfa0…", "amountBase": "1250", "…": "…" }
}
```

What is left is `amountBase - refundedBase`. Every payment read lists all its
refunds under `refunds`, whoever made them. The SDK and CLI make the same call:

```js
const { PayoutsClient } = require('@avvio/payments');
const avvio = new PayoutsClient(); // AVVIO_API_KEY, AVVIO_ORG_ID from the environment

// Part of it
const partial = await avvio.refundCheckoutPayment(paymentId, {
  amount: '12.50',
  reason: 'requested_by_customer',
  note: 'Session moved to October',
  idempotencyKey: order.refundKey,   // yours, stored with the order
});

// Everything that is left
const rest = await avvio.refundCheckoutPayment(paymentId, { reason: 'other' });
```

```bash
avvio-payments checkout refund pay_… --amount 12.50 --reason duplicate
avvio-payments checkout refund pay_… --full
```

The CLI requires `--full` for a full refund, so a forgotten `--amount` never
refunds everything.

> [!CAUTION]
> The card processor's refund endpoint has no idempotency of its own. Retry a
> timed-out refund under a new `Idempotency-Key` and the buyer can be refunded
> twice. Resend with the same key to get the original answer
> ([Idempotency](/idempotency/)).

One refund runs at a time per payment. For a genuine second refund of the same
amount within 15 minutes, send `X-Allow-Duplicate: true` (`allowDuplicate: true`
in the SDK). After a `502 REFUND_OUTCOME_UNKNOWN` the refund sits as
`status: "pending"` while we re-read it; the event follows if money moved.

### From the dashboard

1. Open **Checkout**, then the link. Its **Payments** table shows each
   payment's status and refunds.
2. In the row's **Actions** menu, choose **Refund…**.
3. Choose **Full refund** (**Refund what's left** after a partial one) or
   **Part of it** and an amount. Pick a reason and, optionally, a note the
   buyer never sees.
4. Check the summary and press **Refund**.

The row lists each refund with its date, reason and who made it (the key's
name, for an API refund). When the action is unavailable, the menu says why:
disputed, charged back, already refunded in full, or not paid.

## 3. Handle the refund events

| Event | When | Payment `status` |
| --- | --- | --- |
| `checkout_payment.partially_refunded` | Part of the payment went back | still `paid` |
| `checkout_payment.refunded` | Refunded in full, in one go or as the last piece | `refunded` |

Subscribe to both by name; an empty `events` list does not include checkout
events. Both fill two fields the `paid` event leaves empty:

```jsonc
{
  "type": "checkout_payment.partially_refunded",
  "data": {
    "paymentId": "cmf9x1b2c0003q8b7h6j8k0lm",
    "clientReferenceId": "order_1042",
    "status": "paid",
    "amount": "150.00",
    "refunded": "12.50",                 // cumulative, decimal string
    "refund": {                          // the piece this event is about
      "id": "cmfa0…",
      "amount": "12.50",
      "reason": "requested_by_customer",
      "note": "Session moved to October",
      "source": "api",                   // api | dashboard | acquirer
      "at": "2026-09-21T10:14:02.000Z"
    }
    // …the rest of WebhookCheckoutPayment
  }
}
```

```js
switch (event.type) {
  case 'checkout_payment.partially_refunded':
    // The order stays fulfilled. Record the piece and the running total.
    await orders.noteRefund(event.data.clientReferenceId, event.data.refund, event.data.refunded);
    break;
  case 'checkout_payment.refunded':
    // Nothing is left. Reverse the fulfilment.
    await orders.cancel(event.data.clientReferenceId, 'refunded');
    break;
}
```

Refunds made in the card processor's own tools arrive the same way within
minutes, with `source: "acquirer"` and no actor.

## When a refund is refused

Every refusal leaves the money where it was, except `REFUND_OUTCOME_UNKNOWN`,
which may have moved it. [Errors](/errors/#refunds) lists the refund types
(`REFUND_NOT_ALLOWED`, `REFUND_DISPUTED`, `REFUND_EXCEEDS_REMAINING`,
`REFUND_IN_PROGRESS`, `REFUND_OUTCOME_UNKNOWN`) with what to do. A malformed
amount, reason or note is a `400 VALIDATION_ERROR`, and a same-amount repeat
under a new key is a `409 DUPLICATE_REQUEST_DETECTED`.

## Disputes and chargebacks

A buyer can dispute a card payment weeks after paying. While the dispute is
open, `disputeSubstatus` is set, no event fires, and refunds are refused,
since a refund plus a lost dispute would pay the buyer twice. A lost dispute
takes the money back and sends `checkout_payment.reversed`; a `reversed`
payment cannot be refunded. A won dispute returns the payment to `paid`.

## Check the outcome

Read the payment from `GET /links/{linkId}/payments`: `status` and
`refundedBase` say what has gone back. In the sandbox a total ending `.03` is
refunded in full 45 seconds after payment, and `.06` is half refunded. You can
also refund a sandbox payment yourself, or call
`POST /checkout/…/payments/{paymentId}/simulate` with `{"action":"refund"}`.
