thru

Refunds and settlement

Refunds

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

json
{
  "amount": "5",
  "reason": "Customer request"
}
FieldRules
amountOptional decimal string in token units. Leave it out to refund everything that remains.
reasonOptional, up to 1000 characters
toAddressOptional, 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 receivedAmountfeeAmount − earlier refunds. A larger amount returns 400 with the maximum.
  • To return only an overpayment, refund receivedAmountexpectedAmount.
  • 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.

DocumentIssued whenWhat its amount is
receiptThe first money documented on a paymentThe transfers it covers. One per payment, ever.
supplementMoney arrives after a document already covered the earlier moneyThe delta only
credit_noteAny refund, including a partial oneThe 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

EndpointReturns
GET /v1/payments/:id/receiptsEvery document for one payment, oldest first, plus received, refunded and net
GET /v1/receiptsYour 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/:idOne 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:

FieldMeaning
runsEvery 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.
sweptTotalLifetime forwarded for this payment. amount is the LAST run's transfer, not the total.
runCountHow many runs actually forwarded.
payoutStatescheduled, 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.)
requiresManualPayouttrue 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.
payoutNotePlain-language reason, whenever there is something to know.
reopenedAt, reopenReasonSet 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:

EndpointDoes
GET /v1/settlementLists your settlement addresses and the products routed to each
GET /v1/settlement/routingAddresses, 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/defaultMakes it the default
PUT /v1/settlement/addresses/:id/address { address }Stages a change of address
POST /v1/settlement/addresses/:id/cancelCancels a staged change
DELETE /v1/settlement/addresses/:idRemoves 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.