A subscription plan lets a merchant publish billing terms that users can accept. After a user subscribes, the merchant or an approved puller can collect up to the plan amount each billing period.
This guide shows the full flow as building blocks. The merchant creates a plan, the subscriber accepts it, and the merchant or puller collects payments from the resulting subscription PDA.
Install
pnpm add @solana/subscriptions @solana/kit @solana/kit-plugin-rpc @solana/kit-plugin-signer @solana-program/token
Create A Plan
The merchant owns the plan. The plan PDA is derived from the merchant address
and planId.
import { address, createClient } from '@solana/kit';import { solanaLocalRpc } from '@solana/kit-plugin-rpc';import { signer } from '@solana/kit-plugin-signer';import { findPlanPda, subscriptionsProgram } from '@solana/subscriptions';const merchantClient = createClient().use(signer(merchantSigner)).use(solanaLocalRpc({ rpcUrl: 'http://127.0.0.1:8899' })).use(subscriptionsProgram());const planId = 1n;const tokenMint = address('TOKEN_MINT_ADDRESS_HERE');const amount = 5_000_000n;const periodHours = 720n;const metadataUri = 'https://example.com/plan.json';const destinations = [merchantSigner.address];const pullers = [address('PULLER_WALLET_ADDRESS_HERE')];await merchantClient.subscriptions.instructions.createPlan({planId,mint: tokenMint,amount,periodHours,endTs: 0n,destinations,pullers,metadataUri,}).sendTransaction();const [planPda] = await findPlanPda({owner: merchantSigner.address,planId,});
Update A Plan
The merchant can update mutable plan fields after creation. Existing subscribers keep the terms they accepted, while new subscribers accept the current plan terms.
A few rules apply:
- A finite
endTscan only be shortened, never extended or cleared (PlanEndTsCannotExtend). - On a
Sunsetplan you can remove pullers (the new set must be a subset of the current one) to revoke a compromised puller; status,endTs, and metadata stay frozen. - Edits work during a plan's final billing period as long as
endTsis unchanged.
import { PlanStatus } from '@solana/subscriptions';const updatedMetadataUri = 'https://example.com/updated-plan.json';const updatedPullers = [address('NEW_PULLER_WALLET_ADDRESS_HERE')];await merchantClient.subscriptions.instructions.updatePlan({owner: merchantSigner,planPda,status: PlanStatus.Active,endTs: 0n,pullers: updatedPullers,metadataUri: updatedMetadataUri,}).sendTransaction();
Subscribe
The subscriber accepts the current plan terms. The subscription PDA is derived from the plan PDA and subscriber address.
import { createClient } from '@solana/kit';import { solanaLocalRpc } from '@solana/kit-plugin-rpc';import { signer } from '@solana/kit-plugin-signer';import { findAssociatedTokenPda, TOKEN_PROGRAM_ADDRESS } from '@solana-program/token';import {fetchMaybeSubscriptionAuthority,findSubscriptionAuthorityPda,findSubscriptionDelegationPda,subscriptionsProgram,} from '@solana/subscriptions';const subscriberClient = createClient().use(signer(subscriberSigner)).use(solanaLocalRpc({ rpcUrl: 'http://127.0.0.1:8899' })).use(subscriptionsProgram());const [subscriberAta] = await findAssociatedTokenPda({mint: tokenMint,owner: subscriberSigner.address,tokenProgram: TOKEN_PROGRAM_ADDRESS,});const [subscriptionAuthorityPda] = await findSubscriptionAuthorityPda({user: subscriberSigner.address,tokenMint,});const subscriptionAuthority = await fetchMaybeSubscriptionAuthority(subscriberClient.rpc,subscriptionAuthorityPda,);if (!subscriptionAuthority.exists) {await subscriberClient.subscriptions.instructions.initSubscriptionAuthority({tokenMint,tokenProgram: TOKEN_PROGRAM_ADDRESS,userAta: subscriberAta,}).sendTransaction();}await subscriberClient.subscriptions.instructions.subscribe({merchant: merchantSigner.address,planId,tokenMint,}).sendTransaction();const [subscriptionPda] = await findSubscriptionDelegationPda({planPda,subscriber: subscriberSigner.address,});
Collect A Payment
The merchant or a whitelisted puller signs collection. When the plan uses a
destination allowlist, the owner of the receiver token account must be listed in
destinations.
const receiverAta = address('MERCHANT_TOKEN_ACCOUNT_ADDRESS_HERE');await merchantClient.subscriptions.instructions.transferSubscription({caller: merchantOrPullerSigner,delegator: subscriberSigner.address,tokenMint,subscriptionPda,planPda,amount: 200_000n,receiverAta,tokenProgram: TOKEN_PROGRAM_ADDRESS,}).sendTransaction();
Cancel And Revoke
Cancelling marks the subscription as ending. Revoking closes the subscription PDA after the cancellation expiry has elapsed. The subscriber signs both transactions.
await subscriberClient.subscriptions.instructions.cancelSubscription({subscriber: subscriberSigner,planPda,subscriptionPda,}).sendTransaction();// Run this after the cancelled subscription's expiresAtTs has elapsed.await subscriberClient.subscriptions.instructions.revokeSubscription({authority: subscriberSigner,planPda,subscriptionPda,}).sendTransaction();
Resume A Subscription
A cancelled subscription can be reactivated before it is revoked. Once revoked,
the subscription account is closed and the subscriber must
subscribe again. Resume requires the subscriber's
SubscriptionAuthority for the plan's mint, which the program validates (owner,
mint, and init_id) and rejects if it is stale or re-initialized.
await subscriberClient.subscriptions.instructions.resumeSubscription({subscriber: subscriberSigner,planPda,tokenMint,}).sendTransaction();
Notes
amountis in base units. For a 6-decimal token,5_000_000means5tokens.- The TypeScript SDK fetches live plan terms during
subscribewhen you omit them. - The Rust
SubscribeBuilderneeds the expected plan terms. Fetch and decode the plan account first, then pass those fields throughSubscribeData. - Only the merchant or a wallet listed in
pullerscan collect payments. - The subscriber signs setup, cancel, and revoke transactions. The merchant or approved puller signs collection transactions.
Is this page helpful?