---
title: "From Direct Pay subscriptions to checkout sessions"
description: "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."
source: https://docs.thru.la/migrations/direct-pay-to-checkout-sessions.md
html: https://docs.thru.la/migrations/direct-pay-to-checkout-sessions
index: https://docs.thru.la/llms.txt
---
# 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.

| | |
| --- | --- |
| Changed | 2026-09-17 |
| Effort | An afternoon if you stored entitlements from thru. Ten minutes if you only ever read `reference`, `status` and the amount. |
| Changelog entry | [Direct Pay and subscriptions are removed](https://docs.thru.la/changelog#2026-09-17-direct-pay-removed) |

---

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](https://github.com/thru-payment/thru-infra/blob/master/docs/adr/0004-retire-direct-pay.md)
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:

| Was | Now |
| --- | --- |
| `POST /direct-pay/plans` | A catalog item: `POST /v1/products` with `kind: "one_off"`. |
| `POST /direct-pay/subscriptions` | Nothing. 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-change` | Sell 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 have | What happens now | What 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 row | **Gone.** 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.
