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.
A sponsor can fund the plan's rent by passing the optional payer while the
merchant remains the plan owner. Deleting the plan refunds the owner, not the
payer, so gate sponsorship off-chain.
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. - The instruction carries the plan state observed at signing
(
expectedCreatedAt,expectedEndTs,expectedPullers,expectedMetadataUri). If the live plan no longer matches, the update is rejected (StalePlanApproval), so a stale signed update cannot restore removed pullers or revert later edits. The plugin client'supdatePlanfetches the live state for you; when building manually, fetch the plan and pass the fields yourself.
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.
To bundle authority initialization and subscribe into a single transaction, pass
the sentinel UNKNOWN_INIT_ID (exported by the TypeScript SDK) as
expectedSubscriptionAuthorityInitId. The program accepts the authority only if
it was created in the current slot, so the sentinel works for fresh signups; a
returning user whose authority was created in an earlier slot must pass the real
initId (the plugin client fetches it for you) or the call fails with
StaleSubscriptionAuthority.
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();
Cancel Immediately
When both the subscriber and the current plan owner sign,
cancelSubscriptionNow expires the subscription at cancellation time instead of
the end of the billing period. It can also shorten a pending grace-period
cancellation. The approval is bound to the period start observed at signing; if
the subscription has changed since, the transaction is rejected
(StaleSubscriptionApproval).
import { fetchSubscriptionDelegation } from '@solana/subscriptions';const subscription = await fetchSubscriptionDelegation(subscriberClient.rpc,subscriptionPda,);await subscriberClient.subscriptions.instructions.cancelSubscriptionNow({subscriber: subscriberSigner,merchant: merchantSigner,planPda,expectedCurrentPeriodStartTs: subscription.data.currentPeriodStartTs,}).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.
The instruction carries the expiry the subscriber observed at signing
(expectedExpiresAtTs); a mismatch is rejected (StaleSubscriptionApproval),
so a stale signed resume cannot clear a later cancellation the subscriber never
approved.
import { fetchSubscriptionDelegation } from '@solana/subscriptions';const subscription = await fetchSubscriptionDelegation(subscriberClient.rpc,subscriptionPda,);await subscriberClient.subscriptions.instructions.resumeSubscription({subscriber: subscriberSigner,planPda,tokenMint,expectedExpiresAtTs: subscription.data.expiresAtTs,}).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?