# Refunds

> Refund a paid checkout session, fully or in part. Refunds are non-custodial — you sign them from your own wallet — and LayerHop routes the settlement token back to the payer's original chain and token, then sends checkout.session.refunded.

Source: https://docs.layerhop.com/guides/refunds

A **refund** sends money back to the customer who paid a [checkout session](/guides/checkout). LayerHop never holds your funds, so it cannot refund for you: you sign the refund from **your own wallet** (by default the session's payout address, which received the payment). What LayerHop does is the routing: it quotes the way back from your settlement token to the **payer's original chain and token** (or the settlement token itself, if you prefer), builds the transaction, tracks it, and when it lands adds it to the session's `refundedAmount` and sends a `checkout.session.refunded` [webhook](/guides/webhooks).

You can refund the whole amount or part of it, in as many refunds as you like, up to the amount paid. LayerHop takes no fee on refunds; only the route's own provider fees and gas apply.

## How it works

1. **Create** the refund for a `paid` session, with the [API](#refund-with-the-api) or from the [dashboard](#refund-from-the-dashboard). LayerHop records it as `pending` and returns a quote for the route back to the payer.
2. **Build** the transaction with [`POST /v1/orders`](/api-reference#build-the-transaction) and the quote's `quoteId`, exactly like a swap.
3. **Sign** it from the `from` wallet and broadcast it.
4. **Report** it with [`POST /v1/orders/{quoteId}/submitted`](/api-reference#report-the-broadcast-transaction). The refund becomes `submitted` and LayerHop tracks it as an order.
5. When the route delivers, the refund is `completed`, the session's `refundedAmount` grows, and `checkout.session.refunded` is sent. If the route fails or is refunded by its provider, the refund is `failed` and the amount can be refunded again.

| Refund status | Meaning |
|---|---|
| `pending` | Created and quoted; waiting for you to sign and report the transaction. Does not count against the refundable amount. |
| `submitted` | The transaction was reported and is being tracked. |
| `completed` | Delivered to the payer. Counted in the session's `refundedAmount`. |
| `failed` | The refund transaction failed. Nothing was refunded; you can create a new refund. |

## Where does the refund go?

| `to` | The payer receives | Notes |
|---|---|---|
| `original` (default) | The token they paid with, on the chain they paid from, at the address they paid from | A swap or bridge route from your settlement token, through LayerHop's providers. When the payer paid with the settlement token itself, this is a plain transfer. |
| `settlement` | Your settlement token, on the settlement chain, at the payer's address | A plain transfer when the chain matches. Only when the payer's address can receive on that chain (EVM to EVM, or Solana to Solana). |

The payer's address is the one recorded when they paid (`payment.payer` on the session). You cannot send a refund elsewhere.

`amount` is in base units of the **settlement token** and is what leaves your wallet. For an `original` refund through a swap or bridge, the payer receives the quote's `dstAmount` in their token (at least `dstAmountMin`).

## Refund with the API

Create the refund with your API key. Omit `amount` to refund everything not yet refunded.

```bash
curl -X POST https://api.layerhop.com/v1/checkout/sessions/cs_01k7t9m2c4x8q6r0v3b5n7p9d/refunds \
  -H "x-api-key: $LAYERHOP_API_KEY" -H "content-type: application/json" \
  -d '{ "amount": "20000000", "reason": "Partial refund: one seat cancelled" }'
```

```json
{
  "refund": {
    "id": "re_01k7w2…",
    "object": "refund",
    "sessionId": "cs_01k7t9m2c4x8q6r0v3b5n7p9d",
    "livemode": true,
    "status": "pending",
    "statusDetail": null,
    "amount": "20000000",
    "reason": "Partial refund: one seat cancelled",
    "quoteId": "q_01k7w2…",
    "to": { "chainId": 42161, "token": "0x0000000000000000000000000000000000000000", "address": "0xPayer…" },
    "txHash": null,
    "createdAt": "2026-10-18T15:02:11.000Z",
    "updatedAt": "2026-10-18T15:02:11.000Z"
  },
  "quote": {
    "quoteId": "q_01k7w2…",
    "provider": "relay",
    "srcAmount": "20000000",
    "dstAmount": "6371190000000000",
    "dstAmountMin": "6307478100000000",
    "estimatedSeconds": 12,
    "expiresAt": "2026-10-18T15:04:11.000Z",
    "from": "0xYourPayoutAddress"
  },
  "next": "Build the transaction with POST /v1/orders { quoteId }, sign it from the `from` wallet, then report it with POST /v1/orders/{quoteId}/submitted."
}
```

| Field | Notes |
|---|---|
| `amount` | Settlement-token base units. Default: everything still refundable (paid amount minus completed and in-flight refunds). |
| `reason` | For your records (up to 500 characters). Returned on the refund and in the webhook. |
| `from` | The wallet that signs and sends the refund. Default: the session's `recipient` (your payout address). |
| `to` | `original` (default) or `settlement`. See [Where does the refund go?](#where-does-the-refund-go) |

Then build, sign and report, as for any LayerHop order. With the TypeScript SDK:

```ts
import { LayerHop } from "@layerhop/sdk";

const lh = new LayerHop({ apiKey: process.env.LAYERHOP_API_KEY });

// 1. Create the refund and get its route
const { refund, quote } = await lh.checkout.refunds.create("cs_01k7t9m2c4x8q6r0v3b5n7p9d", {
  amount: "20000000", // 20 USDC; omit for a full refund
  reason: "Partial refund: one seat cancelled",
});

// 2. Build the transaction for the `from` wallet
const tx = await lh.buildTx(quote.quoteId);

// 3. Sign and send it from quote.from (your payout wallet), e.g. with viem:
const hash = await walletClient.sendTransaction({ to: tx.to, data: tx.data, value: BigInt(tx.value) });

// 4. Report it so LayerHop tracks the refund
await lh.reportSubmitted(quote.quoteId, hash);

// Later: list the session's refunds
const refunds = await lh.checkout.refunds.list("cs_01k7t9m2c4x8q6r0v3b5n7p9d");
```

If the built transaction needs an ERC-20 approval, it comes with an `approval` like any other order; see [Approvals](/concepts/approvals). With Python: `lh.checkout.refunds.create(session_id, amount="20000000", reason="…")` and `lh.checkout.refunds.list(session_id)`.

> [!NOTE]
> The quote expires like any LayerHop quote. If it expires before you build the transaction, create a new refund; a `pending` refund that is never reported does not reduce the refundable amount.

Errors you may see: `CONFLICT` when the session is not `paid`, has no recorded payer, or has nothing left to refund; `INVALID_PARAMS` when `amount` exceeds what is still refundable or `to: "settlement"` is impossible for the payer's chain; `NO_ROUTE` when no provider can route back to the payer's token (try `to: "settlement"` or a different amount).

## Refund from the dashboard

In the [dashboard](https://dashboard.layerhop.com), open **Payments**, pick a paid session and choose **Refund**. Enter the amount (full by default) and a reason, connect the wallet that received the payment, and sign. The dashboard creates the refund, builds the transaction, asks your wallet to sign it and reports it for you; the session then shows the refund's status and, once it completes, the refunded amount. (The dashboard uses `POST /v1/dashboard/checkout/sessions/{id}/refunds` with your sign-in session.)

## The session after a refund

The session stays `paid`: a refund does not undo the payment, it records money returned. Its `refundedAmount` (settlement-token base units, `"0"` until a refund completes) is the total of completed refunds. A session with `refundedAmount` equal to `amount` is fully refunded.

## Webhook

`checkout.session.refunded` is sent each time a refund completes, with the updated session and the refund:

```json
{
  "id": "evt_…",
  "type": "checkout.session.refunded",
  "livemode": true,
  "createdAt": "2026-10-18T15:03:40.000Z",
  "data": {
    "session": { "id": "cs_01k7t9m2c4x8q6r0v3b5n7p9d", "status": "paid", "amount": "49000000", "refundedAmount": "20000000", "…": "…" },
    "refund": {
      "id": "re_01k7w2…",
      "object": "refund",
      "amount": "20000000",
      "status": "completed",
      "reason": "Partial refund: one seat cancelled",
      "txHash": "0x…",
      "to": { "chainId": 42161, "token": "0x0000000000000000000000000000000000000000", "address": "0xPayer…" },
      "createdAt": "2026-10-18T15:02:11.000Z"
    }
  }
}
```

The refund's order also sends the usual `order.*` events (it carries `refundId`).

## Test mode

Refunds of test sessions (`cs_test_…`) are routed by the [sandbox](/guides/test-mode): the built transaction is a placeholder (`tx.sandbox: true`) that moves nothing, and once you report any well-formed transaction hash for it the refund completes about 10 seconds later and sends `checkout.session.refunded` to your test endpoints. Refund ids are `re_test_…`.
