---
title: "Changelog"
description: "Every change to a public thru API surface, newest first."
source: https://docs.thru.la/changelog.md
html: https://docs.thru.la/changelog
index: https://docs.thru.la/llms.txt
---
# 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 version: [https://docs.thru.la/changelog.json](https://docs.thru.la/changelog.json) —
`{ generatedAt, entries: [{ date, title, breaking, changes, migrationGuide, url }] }`, newest
first. Compare `entries[0].date` with what you last saw instead of parsing any of this prose.

---

## Receipts and credit notes

2026-09-21 · [permalink](https://docs.thru.la/changelog#2026-09-21-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 public `url`.
- New endpoints: `GET /v1/payments/:id/receipts` (the whole chain for one payment, with received, refunded and net), `GET /v1/receipts` (keyset walk, optional `kind` filter), `GET /v1/receipts/:id`.
- New public endpoint `GET /v1/public/receipts/:id` — no API key. It is what the customer opens, and it strips `reference`.
- 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/:publicId` now carries the latest `receipt`.
- 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. `emailedAt` tells you whether anybody was actually written to; on every
  other path, forwarding the link is yours to do.
- There is no `.pdf` endpoint. 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.*`

**BREAKING** · 2026-09-21 · [permalink](https://docs.thru.la/changelog#2026-09-21-flow-wildcard-removed)

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.

> **Migration guide:** https://docs.thru.la/migrations/flow-wildcard-to-payment-flow.md

- The wildcard `flow.*` is no longer a valid `eventTypes` entry. Sending it to `POST /v1/webhooks` or 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.*` and `facilitator.*` 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

2026-09-20 · [permalink](https://docs.thru.la/changelog#2026-09-20-amount-increased-and-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 the `payment.*` 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/payments` and `GET /v1/sweeps` gain `updatedAfter`, `limit` and a keyset cursor over `(updatedAt, id)`, in the same envelope `GET /v1/checkout/sessions` already 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.completed` carries the payout sequence and the lifetime total, so a second payout for the same payment can be attributed.
- `requiresManualPayout` narrowed 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.

---

## Amount-based products — a locked amount per checkout session

2026-09-19 · `@thru-payment/server 0.3.1` · [permalink](https://docs.thru.la/changelog#2026-09-19-custom-amount-products)

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.pricingMode` is `fixed` (unchanged) or `custom_amount`. A custom-amount product has no price; it has `minAmount` / `maxAmount` instead.
- `POST /v1/checkout/sessions` accepts `amount` on a custom-amount product; `POST /v1/public/products/:slug/checkout` accepts 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`, `maxAmount` and `currency` as fields. The effective minimum is `max(the merchant’s, 0.01)`.
- The checkout-session payload gains `amount` and `currency`. Both are `null` on a fixed-price session, so one handler reads either kind.
- Every `payment.*` event and `GET /v1/payments/:id` now carry `checkoutSession { 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 — `enabledChains` had been bypassing that check.
- `@thru-payment/server` 0.3.1 is the published version and adds `payments.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:

```json
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

2026-09-19 · [permalink](https://docs.thru.la/changelog#2026-09-19-arc-mainnet)

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 `arc` is 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, `arc` was 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-chains` topic 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

2026-09-18 · [permalink](https://docs.thru.la/changelog#2026-09-18-settlement-routing)

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 through `settlement.write` plus 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.

---

## Direct Pay and subscriptions are removed

**BREAKING** · 2026-09-17 · `@thru-payment/* 0.2.0` · [permalink](https://docs.thru.la/changelog#2026-09-17-direct-pay-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.

> **Migration guide:** https://docs.thru.la/migrations/direct-pay-to-checkout-sessions.md

- All 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`. Use `source` (`product` | `invoice`) where you used `kind`.
- `POST /v1/products` with `kind: "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-core` and `pay-sdk` ship 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/x402` is 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](https://github.com/thru-payment/thru-infra/blob/master/docs/adr/0004-retire-direct-pay.md)
retires Direct Pay. This is the one entry on this list that can break a running integration, and it
has a [full migration guide](/migrations/direct-pay-to-checkout-sessions).

## 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 `subscriptionExpiresAt` into your
> own store, ask for that file rather than guessing the dates.
