Changelog
Every change to a public surface — a route, a field, an event, an error body, an SDK export — newest first. A breaking change always links a migration guide.
Machine readable
https://docs.thru.la/changelog.json carries the same list as { generatedAt, entries: [{ date, title, breaking, changes, migrationGuide, url }] }. Compare entries[0].date with what you last saw instead of re-reading this page. The whole page is also markdown.
Receipts and credit notes
Every payment that receives money now produces a numbered receipt with a public page, and every refund produces a credit note. Additive: nothing you already listen to changed shape.
New event family
receipt.*:receipt.issued,receipt.supplemented,receipt.credit_note.issued. The event carries the document itself, including its publicurl.New endpoints:
GET /v1/payments/:id/receipts(the whole chain for one payment, with received, refunded and net),GET /v1/receipts(keyset walk, optionalkindfilter),GET /v1/receipts/:id.New public endpoint
GET /v1/public/receipts/:id— no API key. It is what the customer opens, and it stripsreference.A partial refund now issues a credit note and emits an event. Before this it emitted nothing, touched no row, and past the newest 100 refunds could not be found again.
GET /v1/public/invoices/:publicIdnow carries the latestreceipt.A merchant billing profile (the letterhead a receipt is issued under) is edited in the console under Settings → Business.
Every time money is credited to a payment, thru now writes a numbered document with the on-chain evidence behind it and a public page anyone holding the link can read. Every refund writes a credit note pointing back at that receipt.
The document chain
A receipt that has been issued is never rewritten. That is not a preference, it is forced: the two ordinary cases both make the amount grow after the receipt exists — an underpayment topped up, and a late transfer credited inside the 7-day window. So an amount change appends a document instead of editing one:
receipt → supplement (carries only the difference) → credit_note (a refund).
Read any one of them and the public page shows the whole chain, so "which document is the real one" never has to be answered.
What to listen to
| Event | Fires when |
|---|---|
receipt.issued | The first document for a payment. |
receipt.supplemented | More money was credited to a payment that already had a receipt. |
receipt.credit_note.issued | A refund completed — partial or full. |
Each one carries the document, not just a payment id, so a merchant that only wants to forward the link does not need a second call.
The partial-refund fix
This is the part that was actually broken, and it is worth naming. A partial refund used to emit
no event, leave the payment row untouched, and — because GET /v1/refunds returns only the newest
100 with no pagination — become unfindable past that. The customer got money back with no document
and the merchant got no notification. A partial refund now issues a credit note like any other, in
the same transaction that completes the refund.
What did not change
- The receipt is issued inside the confirmation transaction and never touches the network. Delivery is a separate service that runs after the commit and swallows its own errors, because a throw there would make the chain listener retry the whole confirmation.
- thru only emails a receipt on the invoice path, because an invoice is the one place thru holds
a customer email address.
emailedAttells you whether anybody was actually written to; on every other path, forwarding the link is yours to do. - There is no
.pdfendpoint. The public page is the PDF — it has a print stylesheet, and the browser's "Save as PDF" is the export.
The flow.* webhook filter is refused — use payment.flow.*
The webhook API used to accept the filter `flow.*`, which could never match any event. It is now rejected with 400. No delivery changes, because it never delivered anything.
Read the migration guideThe wildcard
flow.*is no longer a valideventTypesentry. Sending it toPOST /v1/webhooksor an update now returns 400.The flow family is offered as
payment.flow.*, which is the prefix its three event types actually share (payment.flow.step,payment.flow.approved,payment.flow.rejected).No endpoint starts or stops receiving anything:
flow.*matched zero events, so a subscriber on it was already receiving nothing.checkout.*andfacilitator.*are unchanged and still accepted, even though their types share a longer prefix.
A webhook filter is matched by literal prefix, and the wildcard list was built by taking each
console group heading and appending .*. The flow group's heading is flow, but its event
types are payment.flow.step, payment.flow.approved and payment.flow.rejected. So the API
offered flow.*, accepted it on write, showed a healthy endpoint in the console — and could never
deliver a single event to it.
That is the worst shape a configuration bug can take on this surface: it looks configured, it looks healthy, and it is silently inert for ever.
What changed
flow.* is gone from the catalogue and is rejected as an unknown filter. The family is subscribed
to as payment.flow.*.
Who this affects
Almost certainly nobody. flow.* never matched an event, so an endpoint filtered on it received
nothing before this change and receives nothing after it. The break is on write: if your
provisioning code sends flow.* in eventTypes, that call now fails with 400 instead of quietly
succeeding.
Deliberately not changed
checkout.* and facilitator.* were not narrowed to checkout.session.* and
facilitator.payment.*, even though that is the prefix their types share. Those filters are
already saved by real endpoints and already documented; narrowing them would have turned working
configuration into 400s. Exactly one entry moved.
An event for money that moves no status, second payouts, and a reconciliation walk
Three gaps an integrator hit while building a recovery flow: money could land with no webhook, a second payout could not be attributed, and there was no way to find payments whose events you missed.
New event
payment.amount_increased, in thepayment.*family. It fires when more money lands on a payment whose status does not change — an underpaid payment topped up but still short, a further transfer onto an overpaid one, a manual recovery credit onto a confirmed one.GET /v1/paymentsandGET /v1/sweepsgainupdatedAfter,limitand a keyset cursor over(updatedAt, id), in the same envelopeGET /v1/checkout/sessionsalready returned.A page of that walk is clamped to rows older than a 60-second safety lag, so the cursor cannot advance past a row that was still committing.
settlement.completedcarries the payout sequence and the lifetime total, so a second payout for the same payment can be attributed.requiresManualPayoutnarrowed to what it says: a chain with no sweep support, a held sweep, or native SUI.
Three gaps, all found by an integrator building a recovery flow, all real.
1. Money could arrive and no webhook fire
A payment event required the status to change. So an underpaid payment that was topped up but was still short, or a further transfer onto an already-overpaid payment, credited silently.
payment.amount_increased is a new type rather than a re-fire of payment.confirmed /
.overpaid / .underpaid, because everybody already deployed reads those as "the payment
became this", and re-firing one would have changed that meaning underneath them. A subscriber on
the payment.* wildcard picks the new type up with no change at all; a subscriber on the exact
types has to add it.
It fires only while the status is already one of confirmed, underpaid or overpaid — never
on detected or confirming, whose amount is not final. The payload is the usual
{ payment, blockchainTransaction, checkoutSession? }, so credit payment.receivedAmount minus
what you have already credited, exactly as on the status-change events.
2. A second payout could not be attributed
Funds credited to an already-swept payment used to be stranded, because a completed sweep was a
permanent stop. A sweep can now re-open, each attempt is recorded, and settlement.completed
carries the sequence number and the lifetime total so a merchant can tell a second payout from a
duplicate webhook.
The payout's identity is the run, not the transaction hash — one payout run can broadcast more than one transaction.
3. There was no way to find what you missed
GET /v1/payments and GET /v1/sweeps now take updatedAfter, limit and a keyset cursor.
Walk from your last watermark and you get every row that changed, in (updatedAt, id) order.
The 60-second lag is the part worth understanding: updatedAt is stamped when the UPDATE runs,
but the row only becomes visible at COMMIT. Without the clamp, a walk could step past a row that
was still in flight and never see it again — which is the exact lost event the endpoint exists to
recover. So a page never contains rows newer than 60 seconds, and your watermark is always at least
a minute behind now. That is the correct behaviour, not a bug to work around.
@thru-payment/server 0.3.1Amount-based products — a locked amount per checkout session
A product is now `fixed` or `custom_amount`. On a custom-amount product your server names the amount per checkout session, bounded by the product, and the shopper cannot change it. Additive: a fixed-price integration is untouched.
Product.pricingModeisfixed(unchanged) orcustom_amount. A custom-amount product has no price; it hasminAmount/maxAmountinstead.POST /v1/checkout/sessionsacceptsamounton a custom-amount product;POST /v1/public/products/:slug/checkoutaccepts it too, so the hosted page can let the shopper name it.The amount is locked on the session and copied to the bound payment’s
expectedAmount. No public redeem body accepts an amount — a test fails if one is ever added.An amount outside the bounds is a 400 whose body carries
minAmount,maxAmountandcurrencyas fields. The effective minimum ismax(the merchant’s, 0.01).The checkout-session payload gains
amountandcurrency. Both arenullon a fixed-price session, so one handler reads either kind.Every
payment.*event andGET /v1/payments/:idnow carrycheckoutSession { id, reference, metadata }for a session-bound payment.Idempotent create compares
(source, amount): the same key with a different amount is a 409.Every rail on a custom-amount product must be a token thru actually detects, refused at write time —
enabledChainshad been bypassing that check.@thru-payment/server0.3.1 is the published version and addspayments.retrieve.
A Product is now either fixed — a plan, a SKU, unchanged in every way — or
custom_amount: credit, a top-up, a bulk quantity, where the amount differs per purchase.
Built for a customer selling USD credit 1:1 against stablecoins. It is not a new product line and it changes nothing for a fixed-price integration.
How the amount travels
Your server names it on the session:
POST /v1/checkout/sessions
{ "productSlug": "ai-credit-3f7a1c", "amount": "37", "reference": "usr_4821" }
From there it is locked. It lives on the session, it is copied to the bound payment's
expectedAmount, and no public DTO — nothing a shopper's browser can reach — accepts an amount.
The hosted page can offer the shopper the choice, but that choice is made before the session
exists, through POST /v1/public/products/:slug/checkout, and is validated by the same rule.
The bounds
An amount outside minAmount / maxAmount is a 400 whose body carries the bounds as fields, not
just as prose, so your UI can render the real limits without hard-coding them. The effective minimum
is max(the merchant's minimum, 0.01).
Reading what was actually paid
A fixed-price session grants on checkout.session.completed. A custom-amount session credits
receivedAmount, which lives on the payment — so listen to payment.*, and use the
checkoutSession { id, reference, metadata } now carried on every one of those events to find out
whose credit it is.
The rail caveat, stated plainly
The amount maps 1:1 to the rail's token. 37 on a USDC rail is 37 USDC; on a BNB rail it is 37 BNB. That is deliberate — a merchant may want to sell that way — so give a USD-denominated custom-amount product stablecoin rails only.
Every rail on such a product (the product's own, each networks row, and each enabledChains
entry) must be a token thru detects on a chain it watches, and is refused at write time.
enabledChains used to bypass that: a session redeemed on such a chain minted an address nothing
was watching.
Arc mainnet is on
Arc (Circle’s stablecoin L1) mainnet payments are now detected. Until this shipped, the console would let you create one and nothing would ever confirm it.
A mainnet listener for
arcis running, so an Arc mainnet payment is detected, confirmed and swept like any other chain.Arc chainId 5042 and the USDC contract were verified on chain before the listener was turned on.
Before this,
arcwas already offered in the console’s payment chains while no mainnet listener existed — an Arc mainnet payment could be created and would never be detected.Chain RPC reads now rotate off endpoint rate limits (JSON-RPC -32005 / -32029) instead of treating them as the chain’s answer; a BNB detection stall on 2026-09-17 was caused by exactly that.
Arc is Circle's stablecoin L1. The code had been ready since the testnet integration; what
kept mainnet off was operational — no RPC URL was configured and no mainnet listener existed, while
arc was already offered as a payment chain in the console. So an Arc mainnet payment could be
created, quoted an address, and never detected.
That gap is closed: the mainnet listener runs beside the testnet one, chainId 5042 and the USDC contract were verified on chain first, and Arc's fee floor was checked so that native sweeps are not dropped.
The RPC fix that shipped with it
BNB deposit detection had been stalled for two days, retrying the same block every five seconds. Nothing was lost — no BNB payment was created in the gap — but one made in that window would not have been detected.
The pool of three endpoints was supposed to prevent exactly this and could not: two of the three answer every log query with a rate-limit error while answering everything else normally, and the adapter treated any JSON-RPC error as the chain's considered answer and never rotated. Rate-limit codes now rotate to the next endpoint; every other JSON-RPC error stays fatal, because it would be identical everywhere.
Check the
supported-chainstopic before you offer a chain. The API still accepts payments on chain/token pairs that are not being watched, and those never confirm.
Multiple settlement addresses, routed per product
A merchant can hold several settlement addresses per chain and network, and route each product’s sales to one of them. Everything unrouted goes to that chain’s default. Additive: one address per chain keeps behaving exactly as before.
Several settlement addresses are allowed per (chain, network). Exactly one is the default for each; existing addresses became the defaults.
The routing key is the product, per (chain, network). Everything unrouted — including payments with no product, created through
POST /v1/payments— goes to the default.A new permission,
settlement.route, governs routing. It can only choose among addresses that already went throughsettlement.writeplus the cool-down, so it cannot introduce a new destination.The cool-down survives, per address. A new address and a changed address both wait it out before a sweep can land there; an address inside its cool-down is never a destination, whatever routes to it.
Routing to an address that is already active takes effect immediately — that address was vetted when it activated.
Promoting a due address change is now conditional on the exact pending change that was read, so a cancel can no longer be overwritten by a racing promotion. The promotion is written to the merchant audit trail.
The console gets a routing board: drag a product onto an address, or use "Move to…".
A merchant used to have exactly one settlement address per chain and network. Now they can hold several and decide, per product, where that product's sales land.
The rules
| Question | Answer |
|---|---|
| What is the routing key? | The product, per (chain, network). |
| Where does an unrouted payment go? | The default address for that chain/network. That includes payments with no product at all, created through POST /v1/payments. |
| How many defaults? | Exactly one per (chain, network), enforced in the database. Existing rows became the defaults, so nothing moved on the day this shipped. |
| Who may route? | Holders of the new settlement.route permission. Owner and admin have it. |
settlement.route deliberately cannot introduce a destination. It may only pick among
addresses that already passed settlement.write and finished their cool-down, so the ability to
point money somewhere new stays behind the stronger permission.
The cool-down still applies
A new address and a changed address both wait out the cool-down before a sweep can land there, and cancelling reverts either. An address inside its cool-down is never a destination, no matter what routes to it — the sweep falls back to the default. Routing to an address that is already active is immediate, because that address was vetted when it activated.
Operator note
This is forward-only. Once any merchant holds a second address on a chain, the previous image must not be redeployed: it reads settlement accounts by a unique that no longer exists and ignores the activation time, so it could sweep to an address still inside its cool-down.
@thru-payment/* 0.2.0Direct Pay and subscriptions are removed
The recurring-billing product is gone: 13 endpoints, the `subscription.*` event family, six fields on the checkout-session payload, and the tables behind them. thru is a payments rail. This is the breaking change on this list.
Read the migration guideAll 11 merchant endpoints under
/v1/direct-pay/*and both public ones are removed and return 404.The
subscription.*event family (activated,extended,expired,plan_change_scheduled,plan_changed) is removed from the catalogue. Nothing emits it.Six fields leave the checkout-session payload:
kind,subscriptionId,planId,payerAddress,subscriptionExpiresAt,periodSeconds. Usesource(product|invoice) where you usedkind.POST /v1/productswithkind: "subscription"is a 400. A legacy subscription product answers 410 on its public page and 409 on a checkout session; the row itself survives.The tables are dropped. thru can no longer answer "who had access, until when" — an export was taken immediately before the migration ran.
@thru-payment/server,checkout-coreandpay-sdkship the break as 0.2.0. The removed fields are deleted from the types rather than deprecated-and-nulled, on purpose: the compiler is the only place you can be told.@thru-payment/x402is untouched. Agent payments are a separate rail.A stored webhook filter naming
subscription.*is now silently dropped when you save the endpoint, rather than refused — you did not make a typo, thru withdrew the events.
ADR-0004 retires Direct Pay. This is the one entry on this list that can break a running integration, and it has a full migration guide.
Why
A chain transfer is a push. There is no pull, no mandate, no authorization and no instrument on file. Nothing in thru could ever take money from a wallet that had not already sent it. So every mechanism a merchant means by "subscription" — the charge that happens without the customer acting, the retry, the dunning email, the immediate downgrade with a refund — was absent or redefined into something else:
- A renewal was the customer remembering, unprompted, to send another transfer.
- An upgrade could only be applied when an inbound payment arrived to fund it.
- A downgrade could not take effect until the paid period ran out, because thru never held the money to refund from.
- Lapsing was the only enforcement available, and it arrived by doing nothing.
That is a defensible product. It is not the product the word describes, and thru's own surfaces had drifted into describing the word rather than the thing — the marketing site said "charges on a schedule", the in-product guide said "Thru pulls each period". Neither was ever true, and an integrator who built against them would have built something the API could not support.
What thru is instead
It takes a payment, hosts the page, and tells you whether the money landed — by webhook, by a signed browser return, and by a list endpoint you can sweep. It does not model access, does not keep an expiry clock, and does not send reminders.
A merchant billing for a period sells the period as a checkout item, keeps the expiry clock in
their own application, and issues the next link when it runs down.
checkout.session.completed carries their own reference and the exact amount, which is
everything the grant needs.
Deliberately not done
Nothing was built to replace it. If recurring billing comes back it will need a mechanism that can actually pull — a delegated allowance, a session key, an on-chain subscription contract — and that is a new decision, not a restoration of this one.
If you had live subscriptions: the rows are gone. thru took a full export immediately before the tables were dropped and still has it. If you did not mirror
subscriptionExpiresAtinto your own store, ask for that file rather than guessing the dates.