40 min read
Overview
Subscriptions are hard to build on Solana because of a structural limit. An SPL token account has exactly one delegate slot, so when a wallet approves a recurring pull, that single approval takes the slot. Approve a second one and it silently overwrites the first. A wallet can authorize one recurring charge but not two: the same user cannot pay for two different services from the same token account.
The Solana Foundation's Subscriptions program removes that limit. Instead of handing the delegate slot to a single merchant, it gives the slot to a program-controlled account and routes every charge through a separate record for each subscription. One wallet can then run as many independent recurring payments as it wants, each with its own amount, schedule, and expiry, and each as safe as an ordinary token approval.
This guide walks you through building a working subscription service on Solana devnet, using standalone TypeScript scripts. You will set up the full merchant-and-subscriber flow, where a subscriber opts in once and you collect USDC on a recurring schedule without asking them to sign each charge.
You will also handle the two things that make a real integration work: gating access on whether a wallet is actually subscribed, and reacting to subscriptions and cancellations in real time with Quicknode Streams.
- Build a complete recurring-payment lifecycle on Solana devnet with the Solana Foundation Subscriptions program (v0.4.0)
- A merchant publishes a 1 USDC per hour plan, a subscriber opts in once, and the merchant pulls each billing period
- Evaluate subscription status correctly by checking
expiresAtTsagainst onchain time, and gate a members-only endpoint server-side with a signed nonce - Filter Subscriptions-program transactions with a Quicknode Streams filter
- Every script is standalone and runs against a Quicknode Solana devnet endpoint with
@solana/kit
What You Will Need
- A Quicknode account with a Solana devnet endpoint.
- Devnet SOL for the merchant and subscriber wallets, from the Solana faucet or Quicknode's Solana Devnet Faucet.
- Devnet USDC for the subscription payments, from Circle's faucet (network "Solana Devnet").
- Basic familiarity with TypeScript and Solana fundamentals: Program Derived Addresses (PDAs), SPL tokens, and associated token accounts (ATAs).
You will install the following packages in the setup section:
| Package | Version | Used for |
|---|---|---|
@solana/subscriptions | 0.4.0-rc.2 | Subscriptions program client: instructions, PDA derivation, account fetchers, and the subscriptionsProgram() Kit plugin |
@solana/kit | 6.10.0 | Core Solana SDK: addresses, transactions, and RPC |
@solana/kit-plugin-rpc | 0.11.1 | Kit plugins for RPC connections (solanaDevnetRpc, solanaRpcConnection) |
@solana/kit-plugin-signer | 0.10.0 | Kit plugin that loads a signer from a keypair file (signerFromFile) |
@solana-program/token | 0.13.0 | SPL Token helpers: associated token accounts and the token program address |
express | ^5.1.0 | HTTP server for the gating endpoint |
tsx, typescript, @types/express, @types/node | latest | Dev dependencies: run and type-check the TypeScript scripts |
Two npm traps make a bare npm install fail here:
@solana/subscriptions: npm'slatesttag still points at the stale0.3.0, which has an incompatible API. The current build,0.4.0-rc.2, is published under thebetatag, so you must pin the exact version.@solana/kit: kit7.0.0is published, but@solana/subscriptionsdeclares a peer range of^6.4.0. An unpinned install grabs7.xand breaks the plugin. Pin6.10.0.
How Does the Subscriptions Program Work?
The Subscriptions program gives each wallet a program-controlled delegate (a Subscription Authority PDA) that holds the token account's single delegate slot, then routes every charge through per-arrangement delegation PDAs. One wallet can run unlimited independent recurring payments, each with its own amount, period, and expiry, and no per-charge signature.
Recurring billing needs many independent, long-lived approvals per wallet, but the base token program gives each token account only that one slot, so it cannot express them.
The Subscriptions program's fix: for each (user, mint) pair, it creates a Subscription Authority (SA), a PDA that becomes the single delegate on the user's token account. The SA never moves tokens on its own. It only transfers when a separate delegation PDA authorizes that specific transfer: this amount, to this destination, in this billing period. One delegate slot now fans out into unlimited independent delegations:
User token account
└─ delegate = Subscription Authority PDA (u64::MAX approval)
├─ Fixed Delegation PDA (prepaid allowance)
├─ Recurring Delegation PDA (per-period allowance)
├─ Subscription Delegation PDA (linked to a merchant Plan)
└─ ...as many as you want
A transfer succeeds only when the SA is the approved delegate, and a valid delegation PDA says this exact transfer is allowed right now.
The program supports three delegation models:
| Model | Analogy | Who pulls | Limit semantics |
|---|---|---|---|
| Fixed | Prepaid gift card | The delegatee | One running balance that decrements and never refills |
| Recurring | Monthly spend cap | The delegatee | Up to amountPerPeriod per period (seconds-based); resets each period |
| Subscription plan | Merchant plan you opt into | Plan owner or whitelisted pullers | Terms snapshotted from a shared Plan; up to amount per periodHours; resets each period |
This guide builds the subscription-plan model end to end, because it matches the merchant use case: one published plan, many subscribers, cancel and resume support.
A few more facts worth knowing before you build:
- The program works with both the SPL Token program and Token-2022, including mints with transfer hooks and transfer fees.
- Rent for every account the program creates (SA, plan, subscription) is recoverable when the account is closed.
Every state change also emits an onchain event through a self-CPI (Cross-Program Invocation), which is how the Streams section later delivers real-time notifications. Version 0.4.0 defines seven event types:
| Event | Emitted when |
|---|---|
SubscriptionCreated | A subscriber opts into a plan |
SubscriptionCancelled | A subscription is canceled, which sets its access expiry |
SubscriptionTransfer | A subscription-plan payment is pulled |
FixedTransfer | A fixed (prepaid) delegation transfer is pulled |
RecurringTransfer | A recurring delegation transfer is pulled |
SubscriptionResumed | A canceled subscription is resumed |
PlanUpdated | A plan's status, end time, or pullers change |
The Streams section later filters for transactions carrying these events; SubscriptionCreated and SubscriptionCancelled are the ones you will trigger and inspect.
Set Up the Project
Create the project and install the dependencies:
mkdir solana-subscriptions && cd solana-subscriptions
npm init -y
npm pkg set type=module
npm install @solana/subscriptions@0.4.0-rc.2 @solana/kit@6.10.0 @solana/kit-plugin-rpc@0.11.1 @solana/kit-plugin-signer@0.10.0 @solana-program/token@0.13.0 express@^5.1.0
npm install -D tsx typescript @types/express @types/node
Every script in this guide is fully standalone: it builds its own Kit client, inlines its own constants, and re-derives the PDAs it needs from the addresses you pass on the command line. You run each one directly with tsx, passing the .env file with Node's native --env-file flag:
npx tsx --env-file=.env 01-authority.ts
Create and Fund the Demo Wallets
The app uses two standard solana-keygen wallet files:
- Merchant (publishes the plan and collects payments)
- Subscriber (opts in and gets billed)
mkdir -p .keys
solana-keygen new -o .keys/merchant.json --no-bip39-passphrase
solana-keygen new -o .keys/subscriber.json --no-bip39-passphrase
These are throwaway devnet keys. Add these to your .gitignore and never reuse them on mainnet or fund them with real assets.
Both wallets need devnet SOL for fees and rent. Request it at faucet.solana.com or Quicknode's Solana
Devnet Faucet for each address (solana address -k .keys/merchant.json prints an address).
The subscriber needs devnet USDC (the balance the plan bills against). Get it from Circle's faucet: select network "Solana DevNet" and paste the subscriber's address. The faucet transfer also creates the subscriber's USDC token account, which the Subscription Authority setup requires.
The merchant needs a USDC token account to receive payments into. The simplest way to create it is to send the merchant address a small amount of USDC from the same Circle faucet.
Devnet USDC uses Circle's mint 4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU (six decimals), which is the mint constant every script inlines.
Configure the Environment
Create a .env file with your Quicknode Solana devnet endpoint (copy it from the endpoint's page in the Quicknode dashboard). MERCHANT_ADDRESS comes into play later in the guide, when you gate content:
# Your Quicknode Solana devnet endpoint URL (https://...)
QUICKNODE_ENDPOINT=
# Access-gating server (gating/server.ts)
GATE_PORT=3000
# The merchant/plan-owner address the gating server checks subscriptions against
# (printed by 02-create-plan.ts).
MERCHANT_ADDRESS=
Create a Subscription Authority
Before a wallet can hold any delegation or subscription, it needs its Subscription Authority for the mint being spent. One SA exists per (user, mint) pair, it must exist before anything else, and initializing it does two things in one transaction: creates the SA PDA and approves it as the token account's delegate with u64::MAX. That unlimited approval is safe because the SA is program-controlled and only ever moves tokens when a delegation PDA authorizes a specific transfer.
This first script also shows the client pattern every script uses: createClient() with the signerFromFile Kit plugin, the solanaDevnetRpc plugin pointed at your endpoint, and the subscriptionsProgram() plugin that adds client.subscriptions.instructions.* and client.subscriptions.queries.*.
/**
* Step 1 - Subscription Authority: create the subscriber's Subscription
* Authority PDA for the USDC mint.
*/
import { address, createClient } from '@solana/kit';
import { solanaDevnetRpc } from '@solana/kit-plugin-rpc';
import { signerFromFile } from '@solana/kit-plugin-signer';
import {
TOKEN_PROGRAM_ADDRESS,
findAssociatedTokenPda,
} from '@solana-program/token';
import { subscriptionsProgram } from '@solana/subscriptions';
// Devnet USDC (Circle), 6 decimals. Faucet: https://faucet.circle.com
const USDC_MINT = address('4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU');
// Build the Kit client: subscriber wallet from a file, pointed at your
// Quicknode devnet endpoint, with the subscriptions plugin.
const subscriber = await createClient()
.use(signerFromFile('.keys/subscriber.json'))
.use(solanaDevnetRpc({ rpcUrl: process.env.QUICKNODE_ENDPOINT }))
.use(subscriptionsProgram());
const user = subscriber.identity.address;
const { initialized, pda } =
await subscriber.subscriptions.queries.isSubscriptionAuthorityInitialized(
user,
USDC_MINT,
);
console.log(`Subscription Authority PDA: ${pda}`);
console.log(` https://explorer.solana.com/address/${pda}?cluster=devnet`);
if (initialized) {
console.log('Already initialized - nothing to do.');
} else {
const [userAta] = await findAssociatedTokenPda({
owner: user,
mint: USDC_MINT,
tokenProgram: TOKEN_PROGRAM_ADDRESS,
});
console.log('Initializing (creates the PDA + approves it as token delegate)...');
await subscriber.subscriptions.instructions
.initSubscriptionAuthority({
tokenMint: USDC_MINT,
tokenProgram: TOKEN_PROGRAM_ADDRESS,
userAta,
})
.sendTransaction();
console.log('Subscription Authority initialized.');
}
console.log('\nNext: npx tsx --env-file=.env 02-create-plan.ts');
Run it:
npx tsx --env-file=.env 01-authority.ts
Expected output (shortened for clarity):
Subscription Authority PDA: EjzPr...iVjz
https://explorer.solana.com/address/EjzP...iVjz?cluster=devnet
Initializing (creates the PDA + approves it as token delegate)...
Subscription Authority initialized.
Next: npx tsx --env-file=.env 02-create-plan.ts
If you open the subscriber's USDC token account in the explorer now, you will see the SA PDA as its delegate. The SA account costs about 0.00163 SOL in rent, which comes back when it is closed in the cleanup step.
Create a Subscription Plan
The merchant publishes a plan with the billing terms subscribers opt into. A plan's PDA is derived from the merchant's address and a numeric planId, and its fields split into two groups:
- Immutable after creation:
planId,owner,mint, the terms (amount,periodHours,createdAt), anddestinations. Subscribers consent to these exact terms, so they can never change underneath an existing subscription. - Mutable via
updatePlan:status(Active or Sunset),endTs,pullers, andmetadataUri.
destinations is an allowlist of up to four token accounts payments may be sent to; an empty array means any destination is allowed. pullers is an allowlist of up to four extra addresses allowed to collect; the plan owner is always implicitly authorized.
The demo plan is 1 USDC per 1-hour period, on purpose. One hour is the program's minimum billing period, and devnet's clock is real, so there is no way to warp time forward. Keeping the period at the minimum means you can actually watch recurring behavior happen: run the collect script, wait an hour, run it again, and see the per-period allowance reset for real. A production plan would use 24n (daily) or 720n (monthly).
The script runs as the merchant. It derives the plan's address with findPlanPda({ owner, planId }), checks whether that plan already exists with fetchMaybePlan (so re-running the script is safe), and if it does not, publishes it by calling createPlan on the subscriptions plugin and sending the transaction. It finishes by printing the merchant address the subscriber needs in the next step.
/**
* Step 2 - Create a plan (merchant): publish the
* billing terms subscribers will opt into.
*/
import { address, createClient } from '@solana/kit';
import { solanaDevnetRpc } from '@solana/kit-plugin-rpc';
import { signerFromFile } from '@solana/kit-plugin-signer';
import {
fetchMaybePlan,
findPlanPda,
subscriptionsProgram,
} from '@solana/subscriptions';
// Devnet USDC (Circle), 6 decimals. Plan: 1 USDC (1_000_000 base units) per
// 1-hour period. periodHours = 1 is the program minimum (see header).
const USDC_MINT = address('4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU');
const PLAN_ID = 1n;
const PLAN_AMOUNT = 1_000_000n;
const PLAN_PERIOD_HOURS = 1n;
const PLAN_METADATA_URI = 'https://example.com/plan.json';
const merchant = await createClient()
.use(signerFromFile('.keys/merchant.json'))
.use(solanaDevnetRpc({ rpcUrl: process.env.QUICKNODE_ENDPOINT }))
.use(subscriptionsProgram());
const owner = merchant.identity.address;
const [planPda] = await findPlanPda({ owner, planId: PLAN_ID });
console.log(`Plan PDA: ${planPda}`);
console.log(` https://explorer.solana.com/address/${planPda}?cluster=devnet`);
const existing = await fetchMaybePlan(merchant.rpc, planPda);
if (existing.exists) {
console.log('Plan already exists - skipping creation.');
} else {
console.log(
`Creating plan: ${Number(PLAN_AMOUNT) / 1e6} USDC every ${PLAN_PERIOD_HOURS} hour(s), no end date...`,
);
await merchant.subscriptions.instructions
.createPlan({
planId: PLAN_ID,
mint: USDC_MINT,
amount: PLAN_AMOUNT,
periodHours: PLAN_PERIOD_HOURS,
endTs: 0n, // no expiry
destinations: [], // empty = any destination allowed
pullers: [], // owner is always an authorized puller
metadataUri: PLAN_METADATA_URI,
})
.sendTransaction();
console.log('Plan created.');
}
// The subscriber just needs to know the merchant address.
console.log(
`\nNext, the subscriber subscribes to this plan:\n` +
` npx tsx --env-file=.env 03-subscribe.ts ${owner}`,
);
Run it:
npx tsx --env-file=.env 02-create-plan.ts
Expected output:
Plan PDA: 2Tw4...qy5x
https://explorer.solana.com/address/2Tw4...qy5x?cluster=devnet
Creating plan: 1 USDC every 1 hour(s), no end date...
Plan created.
Next, the subscriber subscribes to this plan:
npx tsx --env-file=.env 03-subscribe.ts 7erA...BErg
The merchant address appears in the last line of the output above (7erA...BErg), inside the printed 03-subscribe.ts command. That address, not the plan PDA, is the one piece of information the subscriber needs to locate the plan, and it's the value you pass to 03-subscribe.ts and every later script.
Set MERCHANT_ADDRESS in your .env file to this value now. You will need it later, when you gate members-only content on this plan's subscription status.
Note the Plan PDA 2Tw4...qy5x printed on the first line (yours will be different). You will need it later, in the Streams section, to scope the Stream filter to this specific plan.
Subscribe to the Plan
Now the subscriber opts in, passing the merchant address printed by step 2. subscribe creates a subscription delegation PDA (seeds ["subscription", planPda, subscriber]) that snapshots the plan's terms at the moment of consent. The instruction carries expected* fields (expectedAmount, expectedPeriodHours, expectedCreatedAt, expectedSubscriptionAuthorityInitId) that must match the live plan onchain, which is a consent safeguard. The subscriber agrees to exactly the published terms, and a swapped or re-created plan is rejected.
/**
* Step 3 - Subscribe: the subscriber opts into the merchant's plan.
*/
import { address, createClient } from '@solana/kit';
import { solanaDevnetRpc } from '@solana/kit-plugin-rpc';
import { signerFromFile } from '@solana/kit-plugin-signer';
import {
fetchMaybeSubscriptionDelegation,
findPlanPda,
findSubscriptionDelegationPda,
subscriptionsProgram,
} from '@solana/subscriptions';
const USDC_MINT = address('4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU');
const PLAN_ID = 1n;
if (!process.argv[2]) {
throw new Error(
'Pass the merchant address: npx tsx --env-file=.env 03-subscribe.ts <MERCHANT_ADDRESS>',
);
}
const merchant = address(process.argv[2]);
const subscriber = await createClient()
.use(signerFromFile('.keys/subscriber.json'))
.use(solanaDevnetRpc({ rpcUrl: process.env.QUICKNODE_ENDPOINT }))
.use(subscriptionsProgram());
const [planPda] = await findPlanPda({ owner: merchant, planId: PLAN_ID });
console.log(`Subscribing ${subscriber.identity.address}`);
console.log(` to plan ${planPda} (merchant ${merchant})...`);
await subscriber.subscriptions.instructions
.subscribe({
merchant,
planId: PLAN_ID,
tokenMint: USDC_MINT,
// expectedAmount / expectedPeriodHours / expectedCreatedAt /
// expectedSubscriptionAuthorityInitId are auto-fetched by the plugin client.
})
.sendTransaction();
const [subscriptionPda] = await findSubscriptionDelegationPda({
planPda,
subscriber: subscriber.identity.address,
});
// The transaction has landed, but the RPC node serving the read may not have
// caught up to it yet, so poll briefly instead of failing on the first fetch.
let subscription = await fetchMaybeSubscriptionDelegation(
subscriber.rpc,
subscriptionPda,
);
for (let attempt = 0; !subscription.exists && attempt < 10; attempt++) {
await new Promise((resolve) => setTimeout(resolve, 1_000));
subscription = await fetchMaybeSubscriptionDelegation(
subscriber.rpc,
subscriptionPda,
);
}
if (!subscription.exists) {
throw new Error(
`Subscription ${subscriptionPda} was not visible after 10s. The subscribe ` +
'transaction likely still landed: check the explorer before retrying.',
);
}
console.log('\nSubscribed! Onchain subscription state:');
console.log(` subscription PDA: ${subscriptionPda}`);
console.log(` https://explorer.solana.com/address/${subscriptionPda}?cluster=devnet`);
console.log(` amount/period: ${Number(subscription.data.terms.amount) / 1e6} USDC`);
console.log(` period (hours): ${subscription.data.terms.periodHours}`);
console.log(` expiresAtTs: ${subscription.data.expiresAtTs} (0 = active)`);
console.log(
`\nNext, the merchant collects a payment (pass this subscriber address):\n` +
` npx tsx --env-file=.env 04-collect.ts ${subscriber.identity.address}`,
);
Run it with your merchant address:
npx tsx --env-file=.env 03-subscribe.ts <MERCHANT_ADDRESS>
Expected output:
Subscribing 5khv...1oRqZ
to plan 2Tw4..qy5x (merchant 7erA...SBErg)...
Subscribed! Onchain subscription state:
subscription PDA: F6TfMm1bQknU9dnXDovSBoVvgo3YMSH5HMvaWUMW8Ztf
https://explorer.solana.com/address/F6TfM...W8Ztf?cluster=devnet
amount/period: 1 USDC
period (hours): 1
expiresAtTs: 0 (0 = active)
Next, the merchant collects a payment (pass this subscriber address):
npx tsx --env-file=.env 04-collect.ts 5khv...oRqZ
That is the last signature the subscriber ever provides for billing. Everything from here on is the merchant pulling against the delegation.
Collect a Recurring Payment
Each billing cycle, the plan owner (or a whitelisted puller) calls transferSubscription to pull up to the plan amount from the subscriber. The program does the period math onchain: at transfer time, if the current period has ended, it advances one period and resets amountPulledInPeriod to zero, then checks that this pull stays within the per-period cap. The cap is per-period, not cumulative, so a merchant who misses a cycle cannot double-collect later.
The script pulls once, then immediately tries again in the same period to prove the cap is real:
/**
* Step 4 - Collect a payment (merchant): pull this billing period's amount
* from the subscriber, then demonstrate that a second pull in the SAME
* period is rejected.
*
* In production this script is what your scheduler (a cron job or serverless
* function on your own infrastructure, holding the puller key) runs every
* billing cycle.
*/
import { address, createClient } from '@solana/kit';
import {
TOKEN_PROGRAM_ADDRESS,
findAssociatedTokenPda,
} from '@solana-program/token';
import { solanaDevnetRpc } from '@solana/kit-plugin-rpc';
import { signerFromFile } from '@solana/kit-plugin-signer';
import {
findPlanPda,
findSubscriptionDelegationPda,
subscriptionsProgram,
} from '@solana/subscriptions';
const USDC_MINT = address('4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU');
const PLAN_ID = 1n;
const PLAN_AMOUNT = 1_000_000n; // 1 USDC per period, in base units
if (!process.argv[2]) {
throw new Error(
'Pass the subscriber address: npx tsx --env-file=.env 04-collect.ts <SUBSCRIBER_ADDRESS>',
);
}
const delegator = address(process.argv[2]);
const merchant = await createClient()
.use(signerFromFile('.keys/merchant.json'))
.use(solanaDevnetRpc({ rpcUrl: process.env.QUICKNODE_ENDPOINT }))
.use(subscriptionsProgram());
// The merchant re-derives the plan and subscription PDAs itself.
const [planPda] = await findPlanPda({
owner: merchant.identity.address,
planId: PLAN_ID,
});
const [subscriptionPda] = await findSubscriptionDelegationPda({
planPda,
subscriber: delegator,
});
const [receiverAta] = await findAssociatedTokenPda({
owner: merchant.identity.address,
mint: USDC_MINT,
tokenProgram: TOKEN_PROGRAM_ADDRESS,
});
// getTokenAccountBalance already returns a human-readable uiAmountString.
const { value: balanceBefore } = await merchant.rpc
.getTokenAccountBalance(receiverAta)
.send();
console.log(`Merchant USDC before: ${balanceBefore.uiAmountString}`);
console.log(`\nPulling ${Number(PLAN_AMOUNT) / 1e6} USDC for the current billing period...`);
await merchant.subscriptions.instructions
.transferSubscription({
delegator,
planPda,
subscriptionPda,
tokenMint: USDC_MINT,
amount: PLAN_AMOUNT,
receiverAta,
tokenProgram: TOKEN_PROGRAM_ADDRESS,
})
.sendTransaction();
const { value: balanceAfter } = await merchant.rpc
.getTokenAccountBalance(receiverAta)
.send();
console.log(`Collected! Merchant USDC after: ${balanceAfter.uiAmountString}`);
console.log(` https://explorer.solana.com/address/${receiverAta}?cluster=devnet`);
// Now prove the per-period cap: an immediate second pull must fail, because
// this period's allowance is already fully collected.
console.log('\nAttempting a second pull in the same period (this should FAIL)...');
try {
await merchant.subscriptions.instructions
.transferSubscription({
delegator,
planPda,
subscriptionPda,
tokenMint: USDC_MINT,
amount: PLAN_AMOUNT,
receiverAta,
tokenProgram: TOKEN_PROGRAM_ADDRESS,
})
.sendTransaction();
console.error('Unexpected: the second pull succeeded - it should have been rejected.');
} catch (error) {
console.log('Rejected, as expected:');
console.log(` ${(error as Error).message.split('\n')[0]}`);
console.log(
'\nThe program caps collection at the plan amount per billing period.\n' +
'The allowance resets when the next period starts - this plan uses a\n' +
'1-hour period precisely so you can re-run this script after an hour\n' +
'and watch the pull succeed again. (Real plans use 24h/720h periods.)',
);
}
Run it with your subscriber address:
npx tsx --env-file=.env 04-collect.ts <SUBSCRIBER_ADDRESS>
You will see the merchant's USDC balance rise by 1 USDC on the first pull, then the second attempt fail with a program error, because this period's allowance is already fully collected. This is the recurring guarantee, enforced onchain: the subscriber authorized at most 1 USDC per hour, and not even the merchant can exceed it. Wait an hour and re-run to watch the first pull succeed again against a fresh period.
Automate Collection in Production
transferSubscription did not require any action from the subscriber. That means collection is a scheduling problem on the merchant's side. In production, this collect script (iterating over your subscribers) is exactly what a scheduler you operate runs each billing cycle: a cron job or a serverless function on your own infrastructure, holding the puller key. The Streams pipeline later in this guide tells you in real time what happened onchain (new subscribers, cancellations), while your scheduler decides when to pull.
tuktuk, the permissionless Solana crank service, looks like the obvious way to automate pulls without running your own scheduler, but does not work as a drop-in here.
transferSubscription requires its caller to be a transaction signer that is the plan owner or a whitelisted puller, and a permissionless cranker cannot sign as either. Making it work requires deploying a thin wrapper program whose PDA is whitelisted as the plan's puller and which signs the pull itself via CPI, with tuktuk cranking the wrapper. That is a legitimate architecture, but it is a custom Rust program plus keeper integration, an advanced follow-up rather than part of this guide.
How Do You Check if a Solana Subscription Is Active?
Fetch the subscription account, then compare its expiresAtTs to onchain time (getSlot then getBlockTime), never the machine clock.
A subscription account existing onchain does not mean the subscription is active. When a subscription is canceled and its grace period passes, or when it expires, the account does not disappear. It lingers onchain until someone calls revokeSubscription to reclaim its rent. An existence-only check ("does the PDA exist?") therefore reports stale, expired subscriptions as active, and quietly gives canceled users free access.
| State | exists | expiresAtTs | isActive |
|---|---|---|---|
| Subscribed, no cancellation | true | 0 | true |
| Canceled, grace period running | true | future timestamp | true (paid through the period) |
| Canceled, grace period passed | true | past timestamp | false (the gotcha) |
| Revoked (rent reclaimed) | false | n/a | false |
The check itself is just a handful of lines you can lift into your own code: derive the PDAs, read onchain time, fetch the account, evaluate expiresAtTs. Notice the client here uses only signerFromFile and solanaDevnetRpc: the read path needs no subscriptionsProgram() plugin, because the PDA helpers and fetchMaybeSubscriptionDelegation are plain imports.
/**
* Step 5 - Check status: the RIGHT way to answer "is this wallet actively
* subscribed?"
*/
import { address, createClient } from '@solana/kit';
import { solanaDevnetRpc } from '@solana/kit-plugin-rpc';
import { signerFromFile } from '@solana/kit-plugin-signer';
import {
fetchMaybeSubscriptionDelegation,
findPlanPda,
findSubscriptionDelegationPda,
} from '@solana/subscriptions';
const PLAN_ID = 1n;
if (!process.argv[2]) {
throw new Error(
'Pass the merchant address: npx tsx --env-file=.env 05-check-status.ts <MERCHANT_ADDRESS>',
);
}
const merchant = address(process.argv[2]);
const subscriber = await createClient()
.use(signerFromFile('.keys/subscriber.json'))
.use(solanaDevnetRpc({ rpcUrl: process.env.QUICKNODE_ENDPOINT }));
// Locate the subscription: plan PDA from (merchant, planId), then the
// subscription PDA from (plan, subscriber).
const [planPda] = await findPlanPda({ owner: merchant, planId: PLAN_ID });
const [subscriptionPda] = await findSubscriptionDelegationPda({
planPda,
subscriber: subscriber.identity.address,
});
// Use the chain's clock, not the machine's.
const slot = await subscriber.rpc.getSlot().send();
const blockTime = await subscriber.rpc.getBlockTime(slot).send();
const now = BigInt(blockTime ?? Math.floor(Date.now() / 1000));
// The naive check: does the account exist?
const account = await fetchMaybeSubscriptionDelegation(subscriber.rpc, subscriptionPda);
// The correct check: expiresAtTs === 0 means active with no cancellation
// pending; > 0 but not yet reached means canceled but still in the paid-up
// grace period (still active); once now >= expiresAtTs it is expired - even
// though the account still exists until revokeSubscription reclaims its rent.
const expiresAtTs = account.exists ? BigInt(account.data.expiresAtTs) : null;
const isActive =
expiresAtTs !== null && (expiresAtTs === 0n || now < expiresAtTs);
console.log(`Subscription: ${subscriptionPda}`);
console.log(` account exists: ${account.exists}`);
console.log(` isActive: ${isActive}`);
console.log(` expiresAtTs: ${expiresAtTs ?? 'n/a'}`);
console.log(` onchain now: ${now}`);
if (account.exists && !isActive) {
console.log(
'\n>>> Note the divergence: the account EXISTS but the subscription is\n' +
'>>> NOT active. An existence-only check would wrongly grant access here.',
);
}
Run it now, while the subscription is freshly active:
npx tsx --env-file=.env 05-check-status.ts <MERCHANT_ADDRESS>
Expected output:
Subscription: F6Tf...W8Ztf
account exists: true
isActive: true
expiresAtTs: 0
onchain now: 1783453811
Keep this script handy. After the cancel step later, you will run it again an hour later and watch exists stay true while isActive flips to false.
Gate Members-Only Content
The most common thing to do with isActive is gate content: a members-only page, an API, a download. This section builds that gate as a server-side API and drives it with a headless client, no browser needed. In production the same gate sits behind your webpage. The architecture matters more than the code, and it has two layers:
- Client-side checks are UX only. Anything running in the user's browser can be spoofed, so a client-side read of subscription status may show or hide UI, but must never be the gate.
- The server is the gate. The user proves they control the wallet by signing a server-issued nonce. The server verifies the signature, then runs the
isActivecheck onchain, and only then serves protected content.
Before starting the server, set MERCHANT_ADDRESS in your .env to the merchant address from step 2.
The server builds a read-only client with the solanaRpcConnection plugin because the gate only reads chain state.
/**
* Access-gating server: the server-side gate that protects members-only
* content behind an active subscription.
*
* Two layers, and why the split matters:
* - Any client-side check is UX only - trivially spoofed, never the gate.
* - THIS server is the real gate: the caller proves wallet ownership by
* signing a nonce, and the server verifies (a) the signature and (b) the
* onchain isActive status before serving protected content.
*
* Config: set QUICKNODE_ENDPOINT and MERCHANT_ADDRESS (the plan owner to gate
* on) in .env. Run with: npx tsx --env-file=.env gating/server.ts
*
* Flow:
* GET /api/nonce?wallet=<address> -> { nonce, message }
* POST /api/members { wallet, nonce, signature } -> 200 content | 401
*/
import {
address,
createClient,
getAddressEncoder,
type Address,
type Rpc,
type SolanaRpcApi,
} from '@solana/kit';
import { solanaRpcConnection } from '@solana/kit-plugin-rpc';
import {
fetchMaybeSubscriptionDelegation,
findPlanPda,
findSubscriptionDelegationPda,
} from '@solana/subscriptions';
import express from 'express';
import { randomBytes } from 'node:crypto';
const PORT = Number(process.env.GATE_PORT ?? 3000);
const NONCE_TTL_MS = 5 * 60 * 1000;
const PLAN_ID = 1n;
const RPC_URL = process.env.QUICKNODE_ENDPOINT;
if (!RPC_URL) throw new Error('Set QUICKNODE_ENDPOINT (e.g. via --env-file=.env).');
const MERCHANT_ADDRESS = process.env.MERCHANT_ADDRESS;
if (!MERCHANT_ADDRESS) throw new Error('Set MERCHANT_ADDRESS (the plan owner to gate on).');
// Read-only client: the gate only reads chain state, so it installs just an
// RPC (solanaRpcConnection) - no signer, no transaction sending.
const client = createClient().use(solanaRpcConnection({ rpcUrl: RPC_URL }));
const [planPda] = await findPlanPda({
owner: address(MERCHANT_ADDRESS),
planId: PLAN_ID,
});
// presence != active: the account lingers onchain after expiry until
// revokeSubscription, so evaluate expiresAtTs against onchain time.
async function isActive(
rpc: Rpc<SolanaRpcApi>,
subscriber: Address,
): Promise<{ subscriptionPda: Address; active: boolean; expiresAtTs: bigint | null }> {
const [subscriptionPda] = await findSubscriptionDelegationPda({
planPda,
subscriber,
});
const account = await fetchMaybeSubscriptionDelegation(rpc, subscriptionPda);
if (!account.exists) return { subscriptionPda, active: false, expiresAtTs: null };
const expiresAtTs = BigInt(account.data.expiresAtTs);
const slot = await rpc.getSlot().send();
const now = BigInt((await rpc.getBlockTime(slot).send()) ?? 0);
return {
subscriptionPda,
active: expiresAtTs === 0n || now < expiresAtTs,
expiresAtTs,
};
}
const app = express();
app.use(express.json());
// nonce -> { wallet, expiresAt }; one-time use, short-lived
const nonces = new Map<string, { wallet: string; expiresAt: number }>();
const signInMessage = (wallet: string, nonce: string) =>
`Sign in to the members area\nWallet: ${wallet}\nNonce: ${nonce}`;
app.get('/api/nonce', (req, res) => {
const wallet = String(req.query.wallet ?? '');
if (!wallet) {
res.status(400).json({ error: 'wallet query param required' });
return;
}
const nonce = randomBytes(16).toString('hex');
nonces.set(nonce, { wallet, expiresAt: Date.now() + NONCE_TTL_MS });
res.json({ nonce, message: signInMessage(wallet, nonce) });
});
app.post('/api/members', async (req, res) => {
try {
const { wallet, nonce, signature } = req.body as {
wallet?: string;
nonce?: string;
signature?: string;
};
if (!wallet || !nonce || !signature) {
res.status(400).json({ error: 'wallet, nonce, and signature required' });
return;
}
// 1. The nonce must be one we issued, unexpired, and for this wallet.
const issued = nonces.get(nonce);
nonces.delete(nonce); // one-time use
if (!issued || issued.wallet !== wallet || issued.expiresAt < Date.now()) {
res.status(401).json({ error: 'invalid or expired nonce' });
return;
}
// 2. Verify the ed25519 signature proves ownership of the wallet.
// A wallet address IS the raw ed25519 public key (32 bytes).
const publicKeyBytes = new Uint8Array(
getAddressEncoder().encode(address(wallet)),
);
const publicKey = await crypto.subtle.importKey(
'raw',
publicKeyBytes,
'Ed25519',
false,
['verify'],
);
const messageBytes = new TextEncoder().encode(signInMessage(wallet, nonce));
const signatureBytes = Uint8Array.from(Buffer.from(signature, 'base64'));
const validSignature = await crypto.subtle.verify(
'Ed25519',
publicKey,
signatureBytes,
messageBytes,
);
if (!validSignature) {
res.status(401).json({ error: 'signature verification failed' });
return;
}
// 3. The wallet is proven - now the actual gate: active subscription?
const status = await isActive(client.rpc, address(wallet));
if (!status.active) {
res.status(401).json({ error: 'no active subscription' });
return;
}
res.json({
message: `Welcome, member ${wallet}!`,
content: 'This is the protected members-only content.',
subscription: {
pda: status.subscriptionPda,
expiresAtTs: status.expiresAtTs?.toString(),
},
});
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
app.listen(PORT, () => {
console.log(`Gating server listening on http://localhost:${PORT}`);
console.log(`Gating on plan ${planPda} (merchant ${MERCHANT_ADDRESS})`);
console.log('Try it: npx tsx --env-file=.env gating/sign-in.ts');
});
The sign-in client is the other half of the demo: it plays the subscriber and exercises the gate headlessly, so you can watch the full flow without a browser wallet. It runs the three-step handshake against the server: fetch a one-time nonce, sign the server's message with the subscriber's key (createSignableMessage, then signer.signMessages), and POST the wallet, nonce, and base64 signature to /api/members.
In production that signature would come from the user's browser wallet instead, but the server-side verification is identical.
/**
* Headless sign-in client: proves wallet ownership to the gating server and
* requests the protected content.
*
* Start the server first: npx tsx --env-file=.env gating/server.ts
*/
import { createClient, createSignableMessage } from '@solana/kit';
import { signerFromFile } from '@solana/kit-plugin-signer';
const PORT = Number(process.env.GATE_PORT ?? 3000);
const BASE = `http://localhost:${PORT}`;
// Load the subscriber's wallet via the plugin; client.identity is the signer.
const client = await createClient().use(signerFromFile('.keys/subscriber.json'));
const signer = client.identity;
console.log(`Signing in as ${signer.address}`);
// 1. Get a one-time nonce for this wallet.
const nonceResponse = await fetch(`${BASE}/api/nonce?wallet=${signer.address}`);
const { nonce, message } = (await nonceResponse.json()) as {
nonce: string;
message: string;
};
console.log(`Nonce: ${nonce}`);
// 2. Sign the server's message to prove we control this wallet.
const [signatureDictionary] = await signer.signMessages([
createSignableMessage(message),
]);
const signature = Buffer.from(signatureDictionary[signer.address]).toString(
'base64',
);
// 3. Request the protected content.
const membersResponse = await fetch(`${BASE}/api/members`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ wallet: signer.address, nonce, signature }),
});
const body = await membersResponse.json();
console.log(`\nHTTP ${membersResponse.status}`);
console.log(JSON.stringify(body, null, 2));
if (membersResponse.status === 200) {
console.log('\nAccess granted - the subscription is active.');
} else {
console.log(
'\nAccess denied. If you expected access, check the subscription status\n' +
'with: npx tsx --env-file=.env 05-check-status.ts',
);
}
Run the server in one terminal and the sign-in client in another:
npx tsx --env-file=.env gating/server.ts # terminal 1
npx tsx --env-file=.env gating/sign-in.ts # terminal 2
The server starts up gating on your plan:
Gating server listening on http://localhost:3000
Gating on plan 2Tw4...qy5x (merchant 7erA...SBErg)
Try it: npx tsx --env-file=.env gating/sign-in.ts
And with the subscription active, the sign-in client gets the protected content:
Signing in as 5khv...oRqZ
Nonce: c4e768a506454b0e883543deba940195
HTTP 200
{
"message": "Welcome, member 5khv...oRqZ!",
"content": "This is the protected members-only content.",
"subscription": {
"pda": "F6Tf...8Ztf",
"expiresAtTs": "0"
}
}
Access granted - the subscription is active.
Run it again after the subscription expires (following the cancel step below) and the same request returns HTTP 401.
Cancel, Resume, and Clean Up
Cancellation is subscriber-friendly by design: it does not cut access off instantly. cancelSubscription sets expiresAtTs to the end of the current, already-paid billing period, creating a grace period. Until that timestamp, the subscription still evaluates as active (the subscriber got what they paid for), and the subscriber can change their mind with resumeSubscription, which clears the cancellation entirely.
resumeSubscription now requires tokenMint in its input, used to derive and validate the subscriber's Subscription Authority. Older examples omit it and will not compile against the current SDK.
The script walks cancel, resume, and cancel again, leaving the subscription canceled so you can observe the expiry later:
/**
* Step 6 - Cancel and resume (subscriber).
*
* Usage: pass the merchant address (to locate the plan).
* npx tsx --env-file=.env 06-cancel-resume.ts <MERCHANT_ADDRESS>
*
* This script cancels, shows the grace-period state, resumes, shows it's
* active again, then cancels once more and LEAVES it canceled - so that an
* hour from now you can re-run 05-check-status and see the exists/isActive
* divergence for real.
*/
import { address, createClient } from '@solana/kit';
import { solanaDevnetRpc } from '@solana/kit-plugin-rpc';
import { signerFromFile } from '@solana/kit-plugin-signer';
import {
fetchMaybeSubscriptionDelegation,
findPlanPda,
findSubscriptionDelegationPda,
subscriptionsProgram,
} from '@solana/subscriptions';
const USDC_MINT = address('4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU');
const PLAN_ID = 1n;
if (!process.argv[2]) {
throw new Error(
'Pass the merchant address: npx tsx --env-file=.env 06-cancel-resume.ts <MERCHANT_ADDRESS>',
);
}
const merchant = address(process.argv[2]);
const subscriber = await createClient()
.use(signerFromFile('.keys/subscriber.json'))
.use(solanaDevnetRpc({ rpcUrl: process.env.QUICKNODE_ENDPOINT }))
.use(subscriptionsProgram());
const [planPda] = await findPlanPda({ owner: merchant, planId: PLAN_ID });
const [subscriptionPda] = await findSubscriptionDelegationPda({
planPda,
subscriber: subscriber.identity.address,
});
// Compact status check: active when expiresAtTs is 0 (no cancellation) or the
// grace-period expiry hasn't been reached yet, measured against onchain time.
// Back-to-back transactions can outrun the RPC node serving the reads, so each
// check polls until expiresAtTs reflects the transaction that just landed.
const show = async (
label: string,
settled: (expiresAtTs: bigint) => boolean,
) => {
let expiresAtTs: bigint | null = null;
for (let attempt = 0; attempt < 10; attempt++) {
const account = await fetchMaybeSubscriptionDelegation(subscriber.rpc, subscriptionPda);
expiresAtTs = account.exists ? BigInt(account.data.expiresAtTs) : null;
if (expiresAtTs !== null && settled(expiresAtTs)) break;
await new Promise((resolve) => setTimeout(resolve, 1_000));
}
const slot = await subscriber.rpc.getSlot().send();
const now = BigInt((await subscriber.rpc.getBlockTime(slot).send()) ?? 0);
const isActive =
expiresAtTs !== null && (expiresAtTs === 0n || now < expiresAtTs);
console.log(`${label}: isActive=${isActive}, expiresAtTs=${expiresAtTs}`);
};
console.log('Canceling...');
await subscriber.subscriptions.instructions
.cancelSubscription({ planPda, subscriptionPda })
.sendTransaction();
await show(
'After cancel (grace period until end of paid period)',
(ts) => ts > 0n,
);
console.log('\nResuming (un-cancels before the grace period ends)...');
await subscriber.subscriptions.instructions
.resumeSubscription({ planPda, subscriptionPda, tokenMint: USDC_MINT })
.sendTransaction();
await show('After resume', (ts) => ts === 0n);
console.log('\nCanceling again (leaving it canceled this time)...');
await subscriber.subscriptions.instructions
.cancelSubscription({ planPda, subscriptionPda })
.sendTransaction();
await show('Final state', (ts) => ts > 0n);
console.log(
'\nThe subscription now expires at the end of the current 1-hour period.\n' +
'Re-run 05-check-status after it passes to see the account still existing\n' +
'while isActive flips to false. When done: 07-cleanup.',
);
Run it with your merchant address:
npx tsx --env-file=.env 06-cancel-resume.ts <MERCHANT_ADDRESS>
Expected output:
Canceling...
After cancel (grace period until end of paid period): isActive=true, expiresAtTs=1783456014
Resuming (un-cancels before the grace period ends)...
After resume: isActive=true, expiresAtTs=0
Canceling again (leaving it canceled this time)...
Final state: isActive=true, expiresAtTs=1783456014
The subscription now expires at the end of the current 1-hour period.
Re-run 05-check-status after it passes to see the account still existing
while isActive flips to false. When done: 07-cleanup.
canceled, yet isActive=true with a concrete expiresAtTs is the grace period in action. An hour from now, run 05-check-status again and you will see the divergence live: account exists: true, isActive: false.
Clean Up and Reclaim Rent
Every account this guide created holds a recoverable rent deposit, and cleanup returns all of it. The catch is that the steps are time-gated and order-dependent, so the cleanup script attempts each one, tolerates "not yet" failures, and tells you when to come back:
revokeSubscriptioncloses the subscription PDA and returns its rent, but only once the canceled subscription has actually expired.closeSubscriptionAuthoritycloses the SA and returns its rent. The program lets the user close it at any time, even with subscriptions outstanding, but closing early abandons them: the merchant can no longer collect, and the stranded subscription PDA's rent then has to be reclaimed through the separaterevokeAbandonedSubscriptionpath. The script avoids that by only closing the SA once the subscription is gone.updatePlansunsets the plan (blocking new subscribers) and sets a finiteendTs. Once set, a finiteendTscan only ever be shortened, never extended.deletePlanreclaims the plan's rent, but only afterendTshas passed.
Both parties act here, so this one script loads both local wallets and re-derives everything itself, no arguments needed:
/**
* Step 7 - Clean up: reclaim rent and retire the plan.
*
* Order matters, and some steps are time-gated, so each step here tolerates
* "not yet" failures and tells you when to come back:
* 1. revokeSubscription - subscriber reclaims the subscription PDA's rent.
* Only allowed once the canceled subscription has
* actually expired (grace period passed).
* 2. closeSubscriptionAuthority - subscriber closes the SA and reclaims its
* rent. The program allows this at any time, but
* closing while a subscription still exists
* abandons it, so this script waits for step 1.
* 3. updatePlan (Sunset + finite endTs) - merchant stops new subscribers.
* A finite endTs can only ever be shortened later.
* 4. deletePlan - merchant reclaims the plan PDA's rent. Only
* allowed after endTs has passed.
*/
import { address, createClient } from '@solana/kit';
import { solanaDevnetRpc } from '@solana/kit-plugin-rpc';
import { signerFromFile } from '@solana/kit-plugin-signer';
import {
PlanStatus,
fetchMaybePlan,
fetchMaybeSubscriptionDelegation,
findPlanPda,
findSubscriptionDelegationPda,
subscriptionsProgram,
} from '@solana/subscriptions';
const USDC_MINT = address('4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU');
const PLAN_ID = 1n;
const PLAN_METADATA_URI = 'https://example.com/plan.json';
// Both parties act in cleanup, so this script loads both local wallets and
// re-derives the plan + subscription PDAs itself (no arguments needed).
const merchant = await createClient()
.use(signerFromFile('.keys/merchant.json'))
.use(solanaDevnetRpc({ rpcUrl: process.env.QUICKNODE_ENDPOINT }))
.use(subscriptionsProgram());
const subscriber = await createClient()
.use(signerFromFile('.keys/subscriber.json'))
.use(solanaDevnetRpc({ rpcUrl: process.env.QUICKNODE_ENDPOINT }))
.use(subscriptionsProgram());
const [planPda] = await findPlanPda({
owner: merchant.identity.address,
planId: PLAN_ID,
});
const [subscriptionPda] = await findSubscriptionDelegationPda({
planPda,
subscriber: subscriber.identity.address,
});
const attempt = async (label: string, fn: () => Promise<unknown>) => {
process.stdout.write(`${label}... `);
try {
await fn();
console.log('done.');
return true;
} catch (error) {
console.log('not yet:');
console.log(` ${(error as Error).message.split('\n')[0]}`);
return false;
}
};
// 1. Reclaim the subscription PDA's rent (needs the grace period to have passed).
let subscriptionGone = !(
await fetchMaybeSubscriptionDelegation(subscriber.rpc, subscriptionPda)
).exists;
if (subscriptionGone) {
console.log('Subscription already revoked.');
} else {
subscriptionGone = await attempt('Revoking subscription (reclaims rent)', () =>
subscriber.subscriptions.instructions
.revokeSubscription({ planPda, subscriptionPda })
.sendTransaction(),
);
if (!subscriptionGone) {
console.log(
' A canceled subscription can only be revoked after its grace period\n' +
' ends (up to 1 hour for this plan). Re-run this script later.',
);
}
}
// 2. Close the Subscription Authority. The program allows closing it at any
// time, but closing while a subscription still exists abandons it (the
// merchant can no longer collect, and the stranded PDA's rent then needs
// revokeAbandonedSubscription), so wait until the subscription is gone.
if (subscriptionGone) {
await attempt('Closing Subscription Authority', () =>
subscriber.subscriptions.instructions
.closeSubscriptionAuthority({ tokenMint: USDC_MINT })
.sendTransaction(),
);
} else {
console.log(
'Skipping the Subscription Authority close until the subscription is revoked.',
);
}
// 3. Sunset the plan with a finite endTs so it becomes deletable.
const plan = await fetchMaybePlan(merchant.rpc, planPda);
if (!plan.exists) {
console.log('Plan already deleted - nothing left to do.');
} else {
const slot = await merchant.rpc.getSlot().send();
const blockTime = await merchant.rpc.getBlockTime(slot).send();
const now = BigInt(blockTime ?? Math.floor(Date.now() / 1000));
if (plan.data.status !== PlanStatus.Sunset) {
// Give existing subscriptions until the end of one more period window.
await attempt('Sunsetting plan (blocks new subscribers)', () =>
merchant.subscriptions.instructions
.updatePlan({
planPda,
status: PlanStatus.Sunset,
endTs: now + 2n * 3600n,
pullers: [],
metadataUri: PLAN_METADATA_URI,
})
.sendTransaction(),
);
} else {
console.log('Plan already sunset.');
}
// 4. Delete the plan (only after endTs passes).
const deleted = await attempt('Deleting plan (reclaims rent)', () =>
merchant.subscriptions.instructions.deletePlan({ planPda }).sendTransaction(),
);
if (!deleted) {
console.log(
' deletePlan only succeeds after the plan\'s endTs has passed.\n' +
' Re-run this script once it has to finish reclaiming rent.',
);
}
}
console.log('\nCleanup pass complete.');
Run npx tsx --env-file=.env 07-cleanup.ts once your canceled subscription has expired, and again a couple of hours later to finish the time-gated plan deletion.
When everything is closed, all the rent (subscription PDA, SA, and plan) is back in the wallets that paid it. Note that a Sunset plan refuses new subscribers, so if you want to re-run the lifecycle or trigger Streams events after cleanup, create a fresh plan first (a new planId in 02-create-plan.ts, or delete the old plan fully once its endTs passes).
Stream Subscription Events
Now you will add a Stream to monitor your plan's subscriptions, so that when a wallet subscribes or cancels you learn about it immediately (to send a welcome email, update a database, ping ops) instead of polling every subscription account on a timer. Quicknode Streams watches every block and delivers the transactions you care about to a destination you control.
The Streams filter runs in a sandboxed runtime on Quicknode's side with no npm imports available, so it does the one job that environment is good at: cutting volume. It keeps transactions that touch the Subscriptions program and passes them along raw to whatever destination you configure.
Write the Stream Filter
A Streams filter is a JavaScript function that runs against every block before delivery. It receives the raw dataset in stream.data and returns only what you want delivered, or null to deliver nothing, so your webhook only ever hears about the transactions you care about instead of every devnet block.
Every merchant's plan shares the same Subscriptions program address, so matching on the program alone would deliver every plan's activity across the whole program, not just yours. To monitor only your plan, the filter also checks that each transaction touches your plan PDA (the address you saved when you created the plan). Because that PDA is one of the accounts in every subscribe, cancel, and transfer instruction for your plan, it is a reliable way to narrow delivery to a single plan.
If you run more than one plan, add each plan PDA and match against all of them (swap the constant for an array and check PLAN_PDAS.some((pda) => allKeys.includes(pda))).
function main(stream) {
const PROGRAM_ID = 'De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44';
// Your plan PDA, printed by 02-create-plan.ts. Scopes delivery to this one plan.
const PLAN_PDA = 'YOUR_PLAN_PDA';
// For multiple plans, list them instead and match with .some() below:
// const PLAN_PDAS = ['PLAN_PDA_ONE', 'PLAN_PDA_TWO'];
const blocks = Array.isArray(stream.data) ? stream.data : [stream.data];
const matched = [];
for (const block of blocks) {
const transactions = block && block.transactions;
if (!transactions) continue;
for (const tx of transactions) {
if (!tx || !tx.meta || tx.meta.err) continue; // skip failed txs
const message = tx.transaction && tx.transaction.message;
if (!message) continue;
// Full account list: static keys + any address-lookup-table keys.
const staticKeys = (message.accountKeys || []).map((key) =>
typeof key === 'string' ? key : key.pubkey,
);
const loaded = tx.meta.loadedAddresses || {};
const allKeys = staticKeys.concat(
loaded.writable || [],
loaded.readonly || [],
);
// Simple string checks - no decoding needed. The program must be
// involved (so the tx carries a self-CPI event), and the plan PDA must
// be present (so it is OUR plan, not some other merchant's).
if (!allKeys.includes(PROGRAM_ID)) continue;
if (!allKeys.includes(PLAN_PDA)) continue;
// Multiple plans: if (!PLAN_PDAS.some((pda) => allKeys.includes(pda))) continue;
matched.push({
signature:
(tx.transaction.signatures && tx.transaction.signatures[0]) || null,
slot: block.parentSlot != null ? block.parentSlot + 1 : null,
blockTime: block.blockTime != null ? block.blockTime : null,
transaction: tx, // pass it along raw for the destination to inspect
});
}
}
return matched.length > 0 ? matched : null;
}
The slot field is derived as parentSlot + 1 for display convenience, and Solana skips slots routinely, so a block's actual slot is not always its parent's plus one. Treat the transaction signature and blockTime as the reliable identifiers, not that derived slot.
Create the Stream
Wire it together in the Quicknode dashboard:
- Open webhook.cool and copy the unique URL it generates for you. It gives you an inspectable inbox for deliveries with no server to run, which is enough to see the pipeline work end to end.
- In the Quicknode dashboard, create a Stream on Solana devnet with the Block dataset.
- Paste the filter function above into the Stream's filter, replacing the
PLAN_PDAplaceholder with your own plan PDA (the address you saved when you created the plan). Use the dashboard's test feature against a recent block to confirm it runs (most blocks returnnull, which is correct: no transactions touching your plan). - Set the destination to Webhook, using your webhook.cool URL.
- Start the Stream, then trigger events: run
03-subscribe(against a fresh, non-Sunset plan if you already ran cleanup) or06-cancel-resume, and watch the matched transactions land in your webhook.cool inbox within moments of hitting the chain.
For anything that triggers real side effects (emails, billing records), configure the Stream to deliver at the confirmed or finalized commitment level rather than processed. In production, swap webhook.cool for a receiver on your own infrastructure that verifies the delivery's HMAC signature and decodes the self-CPI events (see the event table above) before acting on them.
When to Use a Recurring Delegation
Use a recurring delegation when a single payer pays a single payee with no shared, published plan. It provides the same per-period pull as a subscription plan but skips plan creation, opt-in consent, and cancel-with-grace semantics. Choose a subscription plan instead whenever many subscribers opt into the same terms you publish once.
The plan model assumes a merchant with many subscribers. If you have a single payer-payee pair (a DAO paying a contributor monthly, an allowance, one bot with a spend cap), the program's recurring delegation model does the same per-period pull without a shared plan: the payer calls createRecurringDelegation naming the payee as delegatee, and the payee calls transferRecurring each period.
Two differences to keep straight:
- Time Units: a recurring delegation's period is
periodLengthS, in seconds, while plans bill inperiodHours. Mixing them up produces wildly wrong billing windows. - No plan-level features: no shared terms to opt into, no cancel-with-grace semantics, just the delegation's own per-period cap and optional expiry.
The Subscription Authority requirement is identical: the payer's SA for the mint must exist first.
Next Steps
- Build a real webhook receiver that decodes the self-CPI events (see the event table above) instead of just inspecting raw deliveries
- Try a Token-2022 mint. The program supports transfer hooks and transfer fees, and the transfer instructions accept
transferHookAccountsfor hook resolution - Make onboarding fully gasless with Kora: run it as the fee payer so a subscriber holding no SOL can still subscribe (and optionally pay the fee in USDC). The program's recorded
payerfield lets Kora front the account rent too, then reclaim it on cleanup viarentRecipient - Build the advanced automation path with a wrapper program whose PDA is a whitelisted puller, cranked on a schedule using tuktuk
If you have questions or want to share what you are building, drop into the Quicknode Discord.
Wrapping Up
Before the Subscriptions program, recurring billing on Solana for multiple subscriptions or plans was not possible. Now your merchant account published terms onchain, your subscriber account consented to an exact snapshot of them, and the program itself enforced the per-period cap when you tried to overdraw it.
Along the way you learned how to check expiresAtTs against onchain time instead of trusting that an account exists, and gating content on the server behind a signed nonce rather than in the browser. With your Stream filtering the program's transactions and delivering them the moment they land, you are not polling for changes. From here, swapping the one-hour demo plan for a monthly one is a single constant.
Frequently Asked Questions
What problem does the Subscription Authority solve compared to a normal token approval?
An SPL token account has exactly one delegate slot, so a second approval overwrites the first and a wallet can only support one recurring pull at a time. The Subscription Authority is a program-controlled PDA that occupies that single slot once per (user, mint) pair, and it only moves tokens when a separate delegation PDA authorizes a specific transfer. This gives one token account unlimited independent subscriptions with the same security model as a normal approval.
Should I use a subscription plan or a recurring delegation?
Use a subscription plan when one merchant serves many subscribers: it gives you shared published terms, a consent snapshot at subscribe time, cancel-with-grace-period semantics, and up to four whitelisted pullers. Use a recurring delegation for a single payer-payee arrangement with no shared plan, such as a DAO paying a contributor. Note the units differ: plans bill in periodHours while recurring delegations use periodLengthS in seconds.
What happens when a subscriber cancels mid-cycle?
Cancellation sets the subscription's expiresAtTs to the end of the current, already-paid billing period rather than cutting access instantly. The subscription still evaluates as active during this grace period, and the subscriber can call resumeSubscription (which requires tokenMint as of v0.4.0) to clear the cancellation. Once the grace period passes, pulls are blocked and the subscription evaluates as inactive.
Why is checking that the subscription account exists not enough to gate access?
Because the account outlives the subscription. After cancellation and expiry, the subscription PDA remains onchain until someone calls revokeSubscription to reclaim its rent, so an existence-only check reports expired subscriptions as active. Always evaluate expiresAtTs against onchain time (getSlot then getBlockTime): 0 means active, a future timestamp means canceled but in grace, and a past timestamp means inactive even though the account exists.
Does the Subscriptions program work with Token-2022 mints?
Yes. As of v0.4.0 the program supports Token-2022 mints including extensions like transfer hooks and transfer fees; the transfer instructions accept a transferHookAccounts input so hook accounts can be forwarded. Earlier versions rejected certain extensions at initialization, but that restriction was removed, so guidance describing a blocked-extensions list is out of date.
Resources
- Subscriptions Program Source and SDK
- Official Solana Docs: Subscriptions
- Subscriptions Demo Webapp
- Solana Kit Docs
- Quicknode Streams
- How to Enable Gasless Transactions on Solana with Kora
We ❤️ Feedback!
Let us know if you have any feedback or requests for new topics. We'd love to hear from you.
