thru

From Direct Pay subscriptions to checkout sessions

Direct Pay is removed. Sell the period as a checkout item, keep the expiry clock in your own application, and issue the next link when it runs down. There is no replacement endpoint, because there is no replacement object.

Changed2026-09-17
EffortAn afternoon if you stored entitlements from thru. Ten minutes if you only ever read reference, status and the amount.

Direct Pay is gone: 13 public endpoints, the subscription.* event family, six fields on the checkout-session payload, and the three tables behind them. thru is a payments rail — ADR-0004 has the reasoning.

The honest headline: if you were selling recurring access, thru no longer models it for you. You now own the expiry clock. This guide is how to own it in about the same number of lines you had before.


1. Decide first: were you actually using it?

bash
grep -rn "subscriptionExpiresAt\|periodSeconds\|subscriptionId\|planId\|payerAddress\|direct-pay" src/
  • No hits, and you only read reference, status and the amount → upgrade the SDK and you are done. Nothing you touch changed.
  • Hits → sections 3 onwards.

2. Upgrade the SDK

bash
npm i @thru-payment/server@^0.2.0
# and, if you use them:
npm i @thru-payment/checkout-core@^0.2.0 @thru-payment/pay-sdk@^0.2.0

Then run your typechecker. Every break is a field or an export listed below; there are no silent behaviour changes to hunt for.

The removed fields are deleted from the types, not deprecated and nulled. That is deliberate: a field that survives as a permanent null lets expiresAt: session.subscriptionExpiresAt keep compiling and write a null into every entitlement row. The compiler is the only place you can be told.

@thru-payment/x402 is untouched — agent payments are a separate rail and nothing there moved.

3. The entitlement write — the important one

If you were doing this:

ts
// BEFORE — 0.1.x
const session = await thru.checkout.sessions.retrieve(ret.sessionId);
if (session.status === 'completed') {
  await db.entitlements.upsert({
    userId: session.reference,
    plan: session.metadata?.plan,
    expiresAt: session.subscriptionExpiresAt,   // ← thru owned the clock
  });
}

you now own the clock:

ts
// AFTER — 0.2.0
const session = await thru.checkout.sessions.retrieve(ret.sessionId);
if (session.status !== 'completed') return;

// Idempotency: the same session may arrive twice (webhook AND browser return).
// sessionId is the natural key — one session is one sale, forever.
const inserted = await db.grants.insertIfAbsent({ key: session.sessionId });
if (!inserted) return;

const PERIOD_DAYS = 30;
const now = new Date();
const current = await db.entitlements.find({ userId: session.reference });

// Extend from whichever is later: an unexpired entitlement, or now. Extending from `now`
// unconditionally would silently burn the days an early renewer had left.
const base = current?.expiresAt && current.expiresAt > now ? current.expiresAt : now;

await db.entitlements.upsert({
  userId: session.reference,
  plan: session.metadata?.plan,
  expiresAt: new Date(base.getTime() + PERIOD_DAYS * 86_400_000),
});

Two things to keep from the old flow, because they were right:

  • Grant on the retrieve, never on the signed return. A signature attests "at second ts, thru observed session X in status Y". It cannot attest freshness: a completed page can be replayed from browser history a week later, and a shopper handed pending can still go on to underpay. verifyThruReturn returns only { verified, sessionId, status } on purpose — there is nothing on it to grant from.
  • Gate on livemode (or network === 'mainnet') if your production entitlements must not be granted by a testnet payment.

4. Branching on kind

ts
// BEFORE
if (session.kind === 'subscription') { /* … */ } else { /* … */ }

// AFTER — `source` is the surviving discriminator
if (session.source === 'invoice') { /* … */ } else { /* … */ }   // 'product' | 'invoice'

kind is gone because it had one possible value left. The other five removed fields (subscriptionId, planId, payerAddress, subscriptionExpiresAt, periodSeconds) described an object that no longer exists.

Nothing else on the payload changed. Every remaining field keeps its name, type and meaning.

5. The endpoints you had pointed at /v1/direct-pay/*

There is no replacement endpoint, because there is no replacement object. The operations map onto your own system:

WasNow
POST /direct-pay/plansA catalog item: POST /v1/products with kind: "one_off".
POST /direct-pay/subscriptionsNothing. A customer is a row in your database.
GET /direct-pay/entitlement?userRef=Your own entitlement table. thru never knew your users; it only ever echoed your reference back.
POST /direct-pay/subscriptions/:id/plan-changeSell the new plan as a checkout. Proration, if you want it, is your price calculation — you know what the customer has left.

All of them now return 404.

6. Rows you already have

Your product rows survive; your subscription rows do not.

You haveWhat happens nowWhat to do
A product with kind: "subscription"Its public page returns 410; a checkout session against it returns 409. The row survives and still says truthfully what it was.Create a one-off item at the same price and swap the link.
A live subscription rowGone. The table is dropped. Nothing was watching its address after 2026-09-17 anyway, so a transfer to it was never going to be credited.Take the expiries from the export thru made before the drop, write them into your own entitlement table, and never read thru for them again.
A webhook endpoint filtered on subscription.*Matches nothing. Saving the endpoint now drops the entry silently instead of erroring — you did not make a typo, thru withdrew the events.Add checkout.session.* if you have not already.

Ask for the export. The rows are gone and the API to read them went with the rest of Direct Pay. thru took a full export immediately before the drop and still has it. If you did not mirror subscriptionExpiresAt into your own store, ask for that file — do not guess the dates.

7. The recurring flow, end to end, after the change

  1. Sell the period as a checkout item. One catalog row per plan-and-period, priced in the token you want.
  2. Create a session when the customer clicks it, carrying your own reference (your user id) and an idempotencyKey derived from something stable, so a double-click cannot open two checkouts.
  3. Grant on checkout.session.completed, using the code in section 3. Dedupe on the session id — the webhook and the browser return will both arrive.
  4. Keep the expiry clock yourself, and send the customer the next link when it runs down. That reminder is now your email, on your schedule. thru does not send it, and never really did.

The full version of that flow, with the return-URL rules and the signature verification, is in the checkout-sessions topic.

8. What this does not give you back

Recurring billing, in the sense of money moving without the customer acting. thru cannot pull from a wallet, so it cannot do that, and no amount of API design here would change it. If that is a requirement, the honest answer is that this rail is the wrong one for it today.