Refunds and settlement
Refunds
POST /v1/payments/:id/refund, with x-api-key. The body is optional:
{
"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
confirmedoroverpaidcan be refunded; anything else returns409. Anunderpaidpayment cannot be refunded through the API. - The most you can refund is
receivedAmount−feeAmount− earlier refunds. A largeramountreturns400with 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
409with the reason. - A full refund (all that remains) moves the payment to
refundedand firespayment.refunded. If the payment came from a checkout session, the session moves tofailedandcheckout.session.failedfires. A partial refund fires no event. GET /v1/refundslists your newest 100 refunds. There is no pagination and noupdatedAfteron this endpoint, so it is not a complete recovery path for a missedpayment.refunded. A full refund also flips the payment torefunded, which moves that payment'supdatedAt— 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.