Subscription Plan

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 endTs can only be shortened, never extended or cleared (PlanEndTsCannotExtend).
  • On a Sunset plan 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 endTs is 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's updatePlan fetches 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

  • amount is in base units. For a 6-decimal token, 5_000_000 means 5 tokens.
  • The TypeScript SDK fetches live plan terms during subscribe when you omit them.
  • The Rust SubscribeBuilder needs the expected plan terms. Fetch and decode the plan account first, then pass those fields through SubscribeData.
  • Only the merchant or a wallet listed in pullers can collect payments.
  • The subscriber signs setup, cancel, and revoke transactions. The merchant or approved puller signs collection transactions.

Is this page helpful?

Table of Contents

Edit Page
© 2026 Solana Foundation. All rights reserved.