---
title: "Refunds and settlement"
description: "Refund a payment (full or partial, and what fires), and how received funds are forwarded to your settlement addresses, with per-product routing and the change cool-down"
source: https://docs.thru.la/refunds-and-settlement.md
html: https://docs.thru.la/refunds-and-settlement
index: https://docs.thru.la/llms.txt
---
# Refunds and settlement

## Refunds

`POST /v1/payments/:id/refund`, with `x-api-key`. The body is optional:

```json
{
  "amount": "5",
  "reason": "Customer request"
}
```

| Field | Rules |
|---|---|
| `amount` | Optional decimal string in token units. Leave it out to refund everything that remains. |
| `reason` | Optional, up to 1000 characters |
| `toAddress` | Optional, up to 128 characters. The default is the address that sent the payment's first transfer. |

- Only payments in `confirmed` or `overpaid` can be refunded; anything else returns `409`. **An `underpaid` payment cannot be refunded through the API.**
- The most you can refund is `receivedAmount` − `feeAmount` − earlier refunds. A larger `amount` returns `400` with the maximum.
- To return only an overpayment, refund `receivedAmount` − `expectedAmount`.
- If the payer sent from an exchange, the default address belongs to the exchange. Ask the payer for an address and pass `toAddress`.
- **Refunds currently send the chain's NATIVE asset only.** The refund is sent on the payment's own chain, but if the payment's token is not that chain's native asset the call is a `400` — `"<Chain> withdrawals currently support native <TOKEN> only"`. In practice that means an **Arc USDC payment can be refunded** (USDC is Arc's native asset) while a USDC payment on Base or BNB Chain, a Sui USDC payment and a Robinhood USDG payment **cannot**. Use an off-platform transfer for those and reconcile it yourself.
- **A refund is funded from a payment address that still holds a balance — not from your settlement wallet and not from thru.** thru looks for one of your own payments on that chain whose custodial address has not been swept yet and has enough for the amount plus gas. If none does, the call is a `409`. So in an account where the auto-sweep is keeping up, refunds will usually fail: the money is already in your settlement wallet, and thru cannot pull it back. Plan refunds as something you send from your own wallet, and treat the API as a convenience for the window before a payment is swept.
- If sending fails for any other reason the call returns `409` with the reason.
- **A full refund** (all that remains) moves the payment to `refunded` and fires `payment.refunded`. If the payment came from a checkout session, the session moves to `failed` and `checkout.session.failed` fires. **A partial refund fires no event.**
- `GET /v1/refunds` lists your newest 100 refunds. **There is no pagination and no `updatedAfter` on this endpoint**, so it is not a complete recovery path for a missed `payment.refunded`. A full refund also flips the payment to `refunded`, which moves that payment's `updatedAt` — so the payments walk will show you the payment even when the refund list has rolled past 100. A partial refund emits no event at all; record it when you create it.

## Receipts and credit notes

thru issues an accounting document whenever money is credited to a payment — on every path, not
only invoices. You do not ask for it and the customer does not either.

| Document | Issued when | What its `amount` is |
|---|---|---|
| `receipt` | The first money documented on a payment | The transfers it covers. One per payment, ever. |
| `supplement` | Money arrives after a document already covered the earlier money | **The delta only** |
| `credit_note` | Any refund, including a partial one | The amount refunded |

**Nothing issued is ever rewritten**, and that is the reason for the chain rather than one
updatable document. A receipt has to follow the amount actually received AND stay true once a
customer is holding the PDF; both cannot hold if the row is edited. Two ordinary situations grow
the amount after a receipt exists — an underpaid payment topping up, and a late transfer recovered
by support weeks later — so the chain is the common case, not an exotic one.

Every document also carries `receivedAmountAtIssue`, the running total as it stood. **Show a
customer the newest document's total; do not add the amounts up yourself.**

### Reading them

| Endpoint | Returns |
|---|---|
| `GET /v1/payments/:id/receipts` | Every document for one payment, oldest first, plus `received`, `refunded` and `net` |
| `GET /v1/receipts` | Your newest 100, or the same keyset walk `GET /v1/payments` has — `updatedAfter`, `cursor`, `limit`, same envelope, same 60-second lag. Optional `kind` filter |
| `GET /v1/receipts/:id` | One document |

Filtering this walk by `kind` is safe, unlike filtering the payments walk by `status`: a
document's kind is frozen at issue, so a row cannot leave the filtered set while you are walking it.

### The hosted document

Every document has `publicId`, and `https://thru.la/r/<publicId>` renders it for anyone with the
link — no key. That page is also the PDF: it prints, and the browser's own "Save as PDF" produces
the file. There is no `.pdf` endpoint.

It shows the WHOLE chain whichever document was linked, so a customer holding an older email still
sees the current, complete story rather than a figure that understates what was received. It states
every transfer behind the total with its confirmation depth at issue and the threshold it was
judged against — the part a crypto receipt can do that a paper one cannot. Your `reference` is
never rendered there; it is often an internal id, so it stays in the API and the console.

### Delivery, honestly

thru emails the document **only when it has a customer address, which in practice means the invoice
path**. A checkout session carries your own `reference`, not an email, and a payment link carries
nothing. Everywhere else the `receipt.*` webhook carries the document and your return page can
link it — **forwarding it to your customer is yours to do.** `emailedAt` on the document tells you
whether anything was actually sent.

### The letterhead

A document prints the issuing entity from **Settings → Business**: legal name, address, tax
identifier. Set it before you take real payments — the details are SNAPSHOTTED onto each document at
issue, so filling them in later fixes future documents and not past ones. Until then a document
prints "issuer details not set" rather than guessing from your workspace name.

## Settlement: where your money goes

Each payment is received on an address thru controls. Once the payment is `confirmed` or `overpaid`, thru forwards the funds to your **settlement address** for that chain and network. `settlement.completed` fires when the funds have been sent to you, and `settlement.failed` if sending failed. The full payload, and the fields that matter when a payment pays out twice, are in `webhooks`.

- **Set a settlement address for every chain and network you accept before you go live.** If there is none, the funds stay on the payment address and nothing is forwarded.
- Native SUI is not forwarded automatically. Withdraw it from Treasury in the console.
- `settlement.*` events report treasury movements. Never use them as the signal to fulfil an order: the payer paid when the payment was confirmed, not when you were paid out.

### A payment can pay out more than once

If money lands on a payment after it has already been paid out — a payer sent a second transfer, or support credited a late one — the payout is **re-opened** and the next run forwards the rest. You get a second `settlement.completed` for the same `paymentId` with `sequence: 2`. **Dedupe on `runId`, order on `sequence`, and never key your ledger on `paymentId` alone.**

### Reading your payouts: `GET /v1/sweeps`

Newest 100 by default; add `updatedAfter`, `cursor` or `limit` for the same keyset walk (and the same 60-second lag) `GET /v1/payments` has — see `payments`, "Reconciliation". Each row is a payment's **current payout state**, plus:

| Field | Meaning |
|---|---|
| `runs` | Every payout attempt for this payment, oldest first: `{ id, sequence, status, amount, toAddress, txHash, gasFundingTxHash, trigger, error, startedAt, completedAt }`. `id` here is the `runId` the webhook carries. |
| `sweptTotal` | Lifetime forwarded for this payment. **`amount` is the LAST run's transfer, not the total.** |
| `runCount` | How many runs actually forwarded. |
| `payoutState` | `scheduled`, `reopened`, `paid_out`, `held`, `awaiting_settlement_address` or `manual_required`. (A payment with no payout record yet has no row here at all, so you will never read a seventh `not_started` value from this endpoint.) |
| `requiresManualPayout` | `true` only for `manual_required` — money no run will ever forward (a chain with no payout rail, native SUI, a payout past its retry cap). Everything else is already scheduled. |
| `payoutNote` | Plain-language reason, whenever there is something to know. |
| `reopenedAt`, `reopenReason` | Set while money is waiting for a further run. |

`id` on the row is the payout **record** for that payment (one per payment, overwritten by each run) — not a payout id. Use `runs[].id`.

**How much has not been forwarded yet:** `receivedAmount` (from `GET /v1/payments`) minus `sweptTotal` (here). There is no separate field, deliberately: a stored number could disagree with the two it is derived from.

**On a native-asset rail that difference is never zero, and that is correct.** Where the asset being paid is the chain's own gas asset, the payout must leave enough behind to pay for itself, so the amount forwarded is the balance minus a gas reserve. **Arc is the case that surprises people: its USDC *is* the native asset**, so an Arc payout is net of roughly 0.0005 USDC and that dust stays on the payment address for good. The same applies to native ETH, BNB, SOL and TRX. On token rails — ERC-20, SPL, TRC-20, Sui coins — thru pays the gas from its own tank and the full balance is forwarded, so the difference really is zero.

**The platform fee is not deducted here.** `feeBps`/`feeAmount` are recorded on the payment (both `0` platform-wide today), and the payout forwards the address balance without consulting them. Reconcile the payout against `receivedAmount`, not against `receivedAmount − feeAmount`.

**A payout run that finds nothing to send emits no event.** It closes as `completed` with `amount: "0"` and no `txHash`, and you will see it in `runs[]` but never as a `settlement.completed`. Nor is there an event when a run merely starts, or when a payout is marked `held` or `skipped` — only the two terminal outcomes are announced.

### Managing settlement addresses

Use the console: **Treasury → Settlement**. You can hold several addresses per chain and network (up to 20). One of them is the **default**, which receives everything not routed elsewhere, including all direct payments and invoices. You can route an individual product's sales to a different address.

The first address for a chain and network is active immediately. Adding another address, or changing an existing one, is **staged behind a cool-down** (24 hours by default) during which it can be cancelled. The events `settlement.address.created`, `settlement.address.change_staged` and `settlement.address.change_cancelled` report these changes. Subscribe to `settlement.*` so you notice a change you did not make.

The same operations are available with an API key:

| Endpoint | Does |
|---|---|
| `GET /v1/settlement` | Lists your settlement addresses and the products routed to each |
| `GET /v1/settlement/routing` | Addresses, products and current routes in one read |
| `POST /v1/settlement/addresses` `{ chain, network?, address, label? }` | Adds an address |
| `PATCH /v1/settlement/addresses/:id` `{ label }` | Renames it |
| `POST /v1/settlement/addresses/:id/default` | Makes it the default |
| `PUT /v1/settlement/addresses/:id/address` `{ address }` | Stages a change of address |
| `POST /v1/settlement/addresses/:id/cancel` | Cancels a staged change |
| `DELETE /v1/settlement/addresses/:id` | Removes an address that is not the default and has no products routed to it |
| `PUT /v1/settlement/routes` `{ changes: [{ productId, chain, network?, settlementAccountId }] }` | Routes products to addresses; `settlementAccountId: null` sends a product back to the default |
| `PUT /v1/settlement` `{ chain, network?, address }` | Sets the default address for a chain and network |
| `POST /v1/settlement/cancel` `{ chain, network? }` | Cancels a staged change of the default |

`network` defaults to `mainnet` on all of these. Because an API key can change where your money goes, guard it accordingly.

---

Related: [payments](https://docs.thru.la/payments.md) · [webhooks](https://docs.thru.la/webhooks.md) · [going-live](https://docs.thru.la/going-live.md)
