Solana Subscriptions and Recurring Payments Banner
/Fundamentals

Setting Up Subscriptions and Recurring Payments on Solana

19 min read

Introduction

Recurring billing is a foundational part of internet commerce. SaaS products, API platforms, payroll systems, and countless other businesses depend on the ability to charge their customers on a predictable schedule.

Until now, implementing that experience on Solana has meant building a lot of custom infrastructure. The new Solana Subscriptions Delegation Program (also known as the Solana Subscriptions & Allowances Program) solves this pain point with an open-source, audited onchain primitive for recurring payments and subscription plans.

Now, a user can authorize future token transfers once (subject to explicit onchain constraints), after which an approved merchant or collector can initiate payments without requiring the user to sign. This program is already live on both mainnet and devnet, supporting mints from both the original SPL Token Program and Token-2022.

Instead of every application designing and securing its own delegation system, developers can now integrate a standardized program. Payment flows that previously required weeks of custom development can be integrated in days.

As a launch partner, Helius helped refine the program, and we now use it to power our onchain API plan subscription billing. This allows Helius customers to authorize recurring payments in USDC directly from their Solana wallets.

In this article, we will examine how the program works and use it to build scalable recurring payment flows. We will cover the Subscription Authority architecture, the PDAs that represent individual authorizations, and the three billing models supported by the program:

  • Fixed spending allowances
  • Recurring delegations
  • Merchant-defined subscription plans

Why Onchain Subscriptions Have Been Difficult

Solana’s token programs already support delegated spending. A token account owner can use the Approve or ApproveChecked instructions to authorize another address to transfer or burn tokens on their behalf, up to a specified allowance.

The problem is that a token account stores only one current delegate and one delegated amount. Approving a new delegate replaces the previous delegate and its allowance. This is true for accounts managed by both the original Token Program and Token-2022. When the user approves a second service, it replaces the first. The native allowance is a single value; it has no built-in concept of billing periods, allowance resets, or subscription state.

Applications could work around this by creating a separate token account for every spending relationship, but doing so fragments the user’s balance and creates a much more complicated wallet and application experience. Alternatively, a team could build a custom escrow or delegation program, but doing so reintroduces the development and security burden the shared program is intended to remove.

What was missing was a way to turn the Token Program’s single delegate slot into a programmable gateway for many independent authorizations.

Enter the new Subscriptions Delegation Program

The Subscriptions Delegation Program adds that missing programmable layer without changing either of Solana’s underlying token programs. For every (user, token mint) pair, the program derives a Subscription Authority, a program-derived address (PDA) that becomes the delegate for the user’s token account for that specific mint. It is initialized once and then reused by every subscription or delegation associated with that user and mint.

During initialization, the user signs a transaction that approves the Subscription Authority with an allowance of ~18.4 quintillion, or u64::MAX. This is safe because the Subscription Authority is a PDA that can only sign through the Subscriptions Delegation Program. It cannot independently decide to transfer tokens. 

Before signing a CPI into the Token Program, the Subscriptions Delegation Program must load a valid authorization account and verify its constraints. Depending on the authorization model, those checks can include:

  • The wallet or service allowed to initiate the pull
  • The mint and source token account
  • The remaining total allowance
  • The maximum amount available in the current billing period
  • The authorization’s start time and expiry
  • The subscription plans accepted by the user
  • The merchant or approved collector initiating the charge
  • Any destination restrictions configured by the plan

Only after those checks pass does the program sign as the Subscription Authority and execute the token transfer. If no active authorization matches the requested transfer, the transaction fails. The u64::MAX allowance, therefore, belongs to the program-controlled gateway, not to an individual merchant. A merchant receives only the authority described by its specific delegation account.

The token account still has exactly one delegate (i.e., the Subscription Authority PDA), but the program can place many independent authorization PDAs behind it. Creating a new authorization does not overwrite any of the existing ones. Each authorization has its own state, limits, lifecycle, and revocation path.

Three Authorization Models

The program supports three distinct models: fixed delegations, recurring delegations, and subscription plans.

Fixed Delegations

A fixed delegation authorizes a wallet or service to pull up to a defined total amount. Each transfer reduces the remaining allowance, and the delegation can optionally expire at a specified Unix timestamp.

This model is useful for bounded agent budgets, one-time allowances, time-limited purchasing authority, and other cases where a user wants to define a maximum total exposure.

Recurring Delegations

A recurring delegation specifies the amount that may be pulled in each period. When the next period begins, the amount pulled in the previous period resets.

The user controls the terms, including the amount per period, the period length, the start time, and the overall expiry. This makes recurring delegations suitable for ongoing relationships such as payroll, contractor payments, recurring allowances, or custom billing agreements where the payer defines the limits.

Subscription Plans

Subscription plans reverse the setup flow. Instead of every user defining their own recurring terms, a merchant publishes a reusable plan with an amount, billing period, accepted mint, permitted collectors, and optional destination restrictions.

A user reviews and accepts those terms, creating a Subscription Delegation PDA tied to the plan. The accepted billing terms are copied into the user’s subscription account, preventing the merchant from silently changing the core price or billing period for an existing subscriber. The plan owner or an approved puller can then collect up to the plan amount during each billing period.

This distinction is important:

  • Recurring delegations are payer-defined authorizations
  • Subscription plans are merchant-published terms that the payer explicitly opts into

All three models use the same Subscription Authority and ultimately execute transfers through the same underlying delegation architecture.

The Reference Implementation

The Subscriptions Delegation Program was designed and built by Moonsong Labs in partnership with the Solana Foundation and audited by Cantina. Its source code, docs, and clients are all available in the Solana Foundation subscriptions repository.

The onchain program is written in no_std Rust using Pinocchio. Pinocchio provides a lower-level, dependency-light development model. Compared with a typical Anchor implementation, this lets the program more carefully manage compute usage and binary size.

The repository also uses Codama to generate synchronized TypeScript and Rust clients directly from the program interface. For TypeScript applications, the main package is:

Code
pnpm add @solana/subscriptions

There is also an official demo web application that provides an end-to-end implementation that can be easily used on devnet. The program supports both SPL Token and Token-2022 and emits onchain lifecycle and transfer events that applications and indexers can decode using the published IDL.

Use Cases: What Developers Can Build

The program is useful anywhere a user can define the boundaries of a future payment before knowing exactly when the transfer will occur. The user signs once to establish an authorization; a merchant, service, recipient, or agent can then initiate transfers within those limits.

Because every spending arrangement is represented by its own PDA, these use cases can coexist behind the same Subscription Authority. A user could pay for an API plan, provide a weekly budget for an AI agent, and authorize a contractor retainer from the same USDC token account, without any authorization interfering with another.

Recurring API and Infrastructure Billing

Subscription plans are a natural fit for SaaS products, RPC providers, data platforms, and other infrastructure services. A provider can publish a separate onchain plan for each product tier, defining its accepted token mint, price, billing period, approved collectors, and permitted payment destinations. This creates a familiar subscription experience without requiring a card processor.

Bounded Spending for AI Agents

Autonomous agents need the ability to pay for APIs, compute, data, trading services, and other resources without asking for human approval. However, giving an agent unrestricted control over a funded wallet creates an obvious security risk.

Fixed delegations provide a safer alternative. A user can authorize an agent to spend up to a specific token amount and place a hard expiry on that authorization. The user can also revoke the delegation before it expires.

Recurring delegations extend the same model. An agent could receive a daily allowance for API requests or a weekly operating budget, with the available amount resetting at the start of each period.

Onchain Payroll and Contractor Payments

Recurring delegations can support pull-based payroll, retainers, grants, and contractor agreements. A payer authorizes an employee or contractor to collect up to a specified amount per pay period. The authorization can specify the amount per period, the period length, the start time, and the final expiry. When a payment becomes due, the recipient or payroll service submits the transfer transaction.

This is not the same as a traditional push-based payroll transaction. The payer does not automatically send funds on payday. Instead, the recipient receives a narrowly scoped right to pull the agreed amount during each period. The result is a transparent payment agreement that both parties can inspect onchain.

Transfers and delegation activity can be tracked through the program’s emitted events, making it possible to build payroll dashboards and accounting integrations.

Stablecoin Invoice Collection

Payment gateways and B2B billing platforms can use the program to replace repeated payment requests with persistent, bounded authorizations. A customer could authorize a gateway to collect:

  • Up to a fixed total amount for a purchase order
  • Up to a specified amount during each weekly or monthly invoice period
  • The price of a standardized merchant plan each billing cycle

The same architecture can power recurring invoices, merchant acquiring, usage allowances, corporate spending policies, and other workflows in which the payer wants automation without surrendering unlimited control.

Content and Media Micropayments

Recurring delegations can also support usage-based payment models for publishers, streaming platforms, research providers, and other media services.

A user can authorize a monthly spending allowance that is drawn down incrementally whenever they access paid content. For example, opening an article might consume 0.10 USDC from a 10 USDC monthly allowance, while premium reports or video streams could carry higher prices. The platform submits each payment as the content is accessed.

This enables “pay for what you read” models without requiring a wallet signature for every article or forcing users into a fixed, all-or-nothing subscription. Publishers gain a scalable way to monetize individual access events, while users retain a predictable spending cap and can revoke the authorization at any time.

Build a Subscription Flow on Devnet with Helius

In this tutorial section, we will build the lifecycle of a merchant subscription with the following steps:

  • A customer initializes a Subscription Authority for their token account
  • A merchant publishes a Subscription plan
  • The customer accepts the plan
  • The merchant collects a payment
  • The customer cancels the subscription

The examples use @solana/subscriptions@0.4.0, the latest published TypeScript client at the time of writing. Pinning the package versions keeps the tutorial stable even if the SDK changes later.

We will model two actors:

Role

Responsibility

Customer

Owns the tokens, initializes the Subscription Authority, subscribes to, and cancels the plan

Merchant

Publishes the plan and submits collection transactions

For the token, we will create a custom six-decimal devnet mint and issue 100 test tokens to the customer. This avoids depending on a separate stablecoin faucet while preserving the same base-unit arithmetic used by six-decimal tokens such as USDC.

Local JSON keypairs make the flow easy to run from the command line. In a real application, customer transactions would normally be signed through a browser or mobile wallet, while the merchant collector would use a securely managed backend signer.

Prerequisites

This tutorial assumes you have:

  • A recent Node.js release
  • pnpm
  • The Solana CLI
  • A paid Helius API key
  • Devnet SOL for both test wallets

Set Up the Project

Create a new project with a keys directory to store the customer and merchant keypairs:

Code
mkdir helius-subscriptions-devnet
cd helius-subscriptions-devnet

pnpm init
mkdir -p src keys

Add "type": "module" to package.json, then install the dependencies:

Code
pnpm add \
  @solana/subscriptions@0.4.0 \
  @solana/kit@6.10.0 \
  @solana/kit-plugin-rpc@0.12.1 \
  @solana/kit-plugin-signer@0.12.1 \
  @solana-program/token@0.13.0 \
  dotenv

pnpm add -D typescript tsx @types/node

Create tsconfig.json:

tsconfig.json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "strict": true,
    "noEmit": true,
    "skipLibCheck": true,
    "types": ["node"]
  },
  "include": ["src"]
}

Create the Merchant and Customer Wallets

Generate one keypair for each actor:

Code
solana-keygen new \
  --no-bip39-passphrase \
  --outfile keys/merchant.json

solana-keygen new \
  --no-bip39-passphrase \
  --outfile keys/customer.json

These keypairs are only for the devnet tutorial. Do not commit production keys to a repository or store a production merchant signer as an unencrypted JSON file.

Create .gitignore:

.gitignore
node_modules/
.env
keys/
state.json

Create .env and add your Helius API key:

.env
HELIUS_API_KEY=YOUR_HELIUS_API_KEY
MERCHANT_KEYPAIR=./keys/merchant.json
CUSTOMER_KEYPAIR=./keys/customer.json

Both wallets need SOL to pay transaction fees and create their respective PDA accounts.

Code
export HELIUS_DEVNET_URL="https://devnet.helius-rpc.com/?api-key=YOUR_HELIUS_API_KEY"

solana airdrop 1 \
  "$(solana-keygen pubkey keys/merchant.json)" \
  --url "$HELIUS_DEVNET_URL"

solana airdrop 1 \
  "$(solana-keygen pubkey keys/customer.json)" \
  --url "$HELIUS_DEVNET_URL"

Helius also provides a devnet faucet through its dashboard. Requesting Devnet SOL through the Helius faucet or Helius RPC requires a paid Helius plan.

Create a Shared Helius Client

Each script needs the same Helius connection, program plugins, configuration values, and addresses. We will put all of these into src/config.ts:

config.ts

import "dotenv/config";

import { readFileSync, writeFileSync } from "node:fs";

import {
  address,
  createClient,
  type Address,
} from "@solana/kit";
import { solanaDevnetRpc } from "@solana/kit-plugin-rpc";
import { signerFromFile } from "@solana/kit-plugin-signer";
import {
  associatedTokenProgram,
  tokenProgram,
} from "@solana-program/token";
import { subscriptionsProgram } from "@solana/subscriptions";

function requiredEnv(name: string): string {
  const value = process.env[name];

  if (!value) {
    throw new Error(`Missing ${name} in .env`);
  }

  return value;
}

const apiKey = requiredEnv("HELIUS_API_KEY");

export const HELIUS_RPC_URL =
  `https://devnet.helius-rpc.com/?api-key=${encodeURIComponent(apiKey)}` as const;

export const HELIUS_WS_URL =
  `wss://devnet.helius-rpc.com/?api-key=${encodeURIComponent(apiKey)}` as const;

export const MERCHANT_KEYPAIR =
  process.env.MERCHANT_KEYPAIR ?? "./keys/merchant.json";

export const CUSTOMER_KEYPAIR =
  process.env.CUSTOMER_KEYPAIR ?? "./keys/customer.json";

export const TOKEN_DECIMALS = 6;

// Five tokens when the mint has six decimals.
export const PLAN_AMOUNT = 5_000_000n;

// Keep the devnet period short so we can test multiple billing cycles.
export const PLAN_PERIOD_HOURS = 1n;

const STATE_FILE = "./state.json";

type StateJson = {
  tokenMint: string;
  planId: string;
};

export async function createAppClient(keypairPath: string) {
  return await createClient()
    .use(signerFromFile(keypairPath))
    .use(
      solanaDevnetRpc({
        rpcUrl: HELIUS_RPC_URL,
        rpcSubscriptionsUrl: HELIUS_WS_URL,
      }),
    )
    .use(tokenProgram())
    .use(associatedTokenProgram())
    .use(subscriptionsProgram());
}

export function saveState(
  tokenMint: Address,
  planId: bigint,
): void {
  writeFileSync(
    STATE_FILE,
    JSON.stringify(
      {
        tokenMint,
        planId: planId.toString(),
      },
      null,
      2,
    ),
  );
}

export function loadState(): {
  tokenMint: Address;
  planId: bigint;
} {
  const parsed = JSON.parse(
    readFileSync(STATE_FILE, "utf8"),
  ) as StateJson;

  if (!parsed.tokenMint || !parsed.planId) {
    throw new Error(
      "state.json is missing tokenMint or planId",
    );
  }

  return {
    tokenMint: address(parsed.tokenMint),
    planId: BigInt(parsed.planId),
  };
}

export function printSignature(
  label: string,
  signature: unknown,
): void {
  const value = String(signature);

  console.log(`${label}: ${value}`);
  console.log(
    `Orb: https://orb.helius.dev/tx/${value}?cluster=devnet`,
  );
}

signerFromFile sets the loaded keypair as both the client identity and fee payer. The Helius RPC plugin handles transaction planning, submission, and confirmation, while the token and subscriptions plugins add their respective account and instruction helpers. Each script prints an Orb (Helius’ block explorer) URL for the resulting transaction.

Create the Devnet Test Mint

Before initializing a Subscription Authority, the customer must have an existing token account for the plan mint. We will create a six-decimal test mint, issue 100 tokens to the customer, and create an empty destination token account for the merchant.

The Solana Kit token plugin exposes helpers for creating mints, associated token accounts, and minting tokens. Create src/00-bootstrap.ts:

00-bootstrap.ts
import { generateKeyPairSigner } from "@solana/kit";
import {
  findAssociatedTokenPda,
  TOKEN_PROGRAM_ADDRESS,
} from "@solana-program/token";

import {
  createAppClient,
  CUSTOMER_KEYPAIR,
  MERCHANT_KEYPAIR,
  printSignature,
  saveState,
  TOKEN_DECIMALS,
} from "./config.js";

const [merchantClient, customerClient] =
  await Promise.all([
    createAppClient(MERCHANT_KEYPAIR),
    createAppClient(CUSTOMER_KEYPAIR),
  ]);

const mint = await generateKeyPairSigner();

const createMintResult =
  await merchantClient.token.instructions
    .createMint({
      newMint: mint,
      decimals: TOKEN_DECIMALS,
      mintAuthority: merchantClient.identity.address,
      freezeAuthority: null,
    })
    .sendTransaction();

const fundCustomerResult =
  await merchantClient.token.instructions
    .mintToATA({
      mint: mint.address,
      owner: customerClient.identity.address,
      mintAuthority: merchantClient.identity,
      amount: 100_000_000n,
      decimals: TOKEN_DECIMALS,
    })
    .sendTransaction();

const createMerchantAtaResult =
  await merchantClient.associatedToken.instructions
    .createAssociatedTokenIdempotent({
      mint: mint.address,
      owner: merchantClient.identity.address,
      tokenProgram: TOKEN_PROGRAM_ADDRESS,
    })
    .sendTransaction();

const [customerAta] = await findAssociatedTokenPda({
  mint: mint.address,
  owner: customerClient.identity.address,
  tokenProgram: TOKEN_PROGRAM_ADDRESS,
});

const [merchantAta] = await findAssociatedTokenPda({
  mint: mint.address,
  owner: merchantClient.identity.address,
  tokenProgram: TOKEN_PROGRAM_ADDRESS,
});

// Plan PDAs are derived from the merchant and plan ID.
// Using the current timestamp gives each test run a fresh ID.
const planId = BigInt(Date.now());

saveState(mint.address, planId);

console.log("Mint:", mint.address);
console.log("Plan ID:", planId.toString());

console.log("Customer:", customerClient.identity.address);
console.log("Customer ATA:", customerAta);

console.log("Merchant:", merchantClient.identity.address);
console.log("Merchant ATA:", merchantAta);

printSignature(
  "Create mint",
  createMintResult.context.signature,
);

printSignature(
  "Fund customer",
  fundCustomerResult.context.signature,
);

printSignature(
  "Create merchant ATA",
  createMerchantAtaResult.context.signature,
);

Run the script. It will create state.json, which contains information on the generated mint and a unique plan ID. The customer now owns 100 test tokens, and the merchant has an empty token account ready to receive subscription payments.

Initialize the Customer’s Subscription Authority

A Subscription Authority is created for a specific (customer, mint) pair. The customer’s token account must already exist before initialization.

The initialization transaction creates the Subscription Authority PDA and approves it as the delegate for the customer’s token account. The same authority can then be reused for every fixed delegation, recurring delegation, and Subscription Plan involving that customer and mint. The customer signs this transaction.

Create src/01-init-authority.ts:

01-init-authority.ts

import {
  findAssociatedTokenPda,
  TOKEN_PROGRAM_ADDRESS,
} from "@solana-program/token";
import {
  fetchMaybeSubscriptionAuthority,
  findSubscriptionAuthorityPda,
} from "@solana/subscriptions";

import {
  createAppClient,
  CUSTOMER_KEYPAIR,
  loadState,
  printSignature,
} from "./config.js";

const customerClient =
  await createAppClient(CUSTOMER_KEYPAIR);

const { tokenMint } = loadState();

const [customerAta] = await findAssociatedTokenPda({
  mint: tokenMint,
  owner: customerClient.identity.address,
  tokenProgram: TOKEN_PROGRAM_ADDRESS,
});

const [subscriptionAuthorityPda] =
  await findSubscriptionAuthorityPda({
    user: customerClient.identity.address,
    tokenMint,
  });

const existing =
  await fetchMaybeSubscriptionAuthority(
    customerClient.rpc,
    subscriptionAuthorityPda,
  );

if (existing.exists) {
  console.log(
    "Subscription Authority already exists:",
    subscriptionAuthorityPda,
  );

  process.exit(0);
}

const result =
  await customerClient.subscriptions.instructions
    .initSubscriptionAuthority({
      tokenMint,
      tokenProgram: TOKEN_PROGRAM_ADDRESS,
      userAta: customerAta,
    })
    .sendTransaction();

console.log(
  "Subscription Authority:",
  subscriptionAuthorityPda,
);

printSignature(
  "Initialize authority",
  result.context.signature,
);

Run the script as the customer. It first checks whether the PDA already exists. This makes the command safe to rerun and avoids submitting a duplicate initialization transaction. Once confirmed, the customer’s token account has the Subscription Authority as its Token Program delegate.

Create a Merchant Subscription Plan

The merchant now publishes the billing terms that customers can accept. A Plan PDA is derived from the merchant address and the plan ID.

A plan defines the payment mint, maximum amount per period, period duration, approved collectors, permitted destinations, and optional offchain metadata.

Create src/02-create-plan.ts:

02-create-plan.ts

import {
  TOKEN_PROGRAM_ADDRESS,
} from "@solana-program/token";
import {
  fetchMaybePlan,
  findPlanPda,
} from "@solana/subscriptions";

import {
  createAppClient,
  loadState,
  MERCHANT_KEYPAIR,
  PLAN_AMOUNT,
  PLAN_PERIOD_HOURS,
  printSignature,
} from "./config.js";

const merchantClient =
  await createAppClient(MERCHANT_KEYPAIR);

const { tokenMint, planId } = loadState();

const [planPda] = await findPlanPda({
  owner: merchantClient.identity.address,
  planId,
});

const existing = await fetchMaybePlan(
  merchantClient.rpc,
  planPda,
);

if (existing.exists) {
  console.log("Plan already exists:", planPda);
  process.exit(0);
}

const result =
  await merchantClient.subscriptions.instructions
    .createPlan({
      planId,
      mint: tokenMint,

      // Five tokens per billing period.
      amount: PLAN_AMOUNT,

      // One-hour periods for this devnet test.
      periodHours: PLAN_PERIOD_HOURS,

      // No scheduled plan-wide end.
      endTs: 0n,

      // The owner of the receiving token account
      // must be included in this list.
      destinations: [
        merchantClient.identity.address,
      ],

      // An empty pullers list means only the merchant
      // can initiate collections.
      pullers: [],

      metadataUri:
        "https://example.com/helius-devnet-plan.json",

      tokenProgram: TOKEN_PROGRAM_ADDRESS,
    })
    .sendTransaction();

console.log("Plan PDA:", planPda);

printSignature(
  "Create plan",
  result.context.signature,
);

Run the script as the merchant. A few of the fields are particularly important:

amount

Token values are expressed in base units. Our mint has six decimals, so 5 tokens = 5,000,000 base units. The plan allows the merchant to collect a cumulative maximum of five tokens during each billing period. The merchant could collect this entire amount in a single transaction or split it across several smaller transactions.

periodHours

We use the minimum, which is one hour. In this way, we can subscribe, collect a payment, wait an hour, and demonstrate that the allowance resets without waiting a month. A production plan would use the interval defined by the product’s actual billing terms.

destinations

The destination allowlist contains wallet owners, not token account addresses. When collecting a payment, the program checks the owner of the receiving token account. Because our merchant’s wallet is in destinations, its associated token account is a valid receiver.

pullers

A merchant is always permitted to collect from its own plan. Additional billing-service wallets can be added to pullers. We leave this list empty so that only the merchant keypair can collect payments. Under our configuration, only the merchant or a wallet included in the plan’s puller list can submit a valid collection transaction. 

Have the Customer Subscribe

The customer now reviews and accepts the merchant’s current plan terms. The resulting Subscription Delegation PDA is derived from the plan PDA + customer address. 

The TypeScript plugin fetches the current plan account duringsubscribe, so we do not need to manually pass the expected amount, period, or creation timestamp. Those values are included in the transaction as the terms. Create src/03-subscribe.ts:

03-subscribe.ts

import {
  fetchMaybeSubscriptionDelegation,
  findPlanPda,
  findSubscriptionDelegationPda,
} from "@solana/subscriptions";

import {
  createAppClient,
  CUSTOMER_KEYPAIR,
  loadState,
  MERCHANT_KEYPAIR,
  printSignature,
} from "./config.js";

const [customerClient, merchantClient] =
  await Promise.all([
    createAppClient(CUSTOMER_KEYPAIR),
    createAppClient(MERCHANT_KEYPAIR),
  ]);

const { tokenMint, planId } = loadState();

const [planPda] = await findPlanPda({
  owner: merchantClient.identity.address,
  planId,
});

const [subscriptionPda] =
  await findSubscriptionDelegationPda({
    planPda,
    subscriber: customerClient.identity.address,
  });

const existing =
  await fetchMaybeSubscriptionDelegation(
    customerClient.rpc,
    subscriptionPda,
  );

if (existing.exists) {
  console.log(
    "Customer is already subscribed:",
    subscriptionPda,
  );

  process.exit(0);
}

const result =
  await customerClient.subscriptions.instructions
    .subscribe({
      merchant: merchantClient.identity.address,
      planId,
      tokenMint,
    })
    .sendTransaction();

console.log("Subscription PDA:", subscriptionPda);

printSignature(
  "Subscribe",
  result.context.signature,
);

Run the script as the customer. The customer signs this transaction to accept a new spending authorization. The subscription account stores the accepted terms. 

Once a plan is created, the planId, owner, mint, amount, periodHours, createdAt and destinations are immutable, meaning the terms cannot be changed. If the merchant later updates mutable fields on the plan, existing subscribers retain the terms they originally accepted, while new subscribers receive the current version of the plan.

The customer now has an active subscription, but no payment has been made yet.

Have the Merchant Collect a Payment

The merchant can now collect up to the plan’s five-token allowance during the current billing period. The Subscriptions Program does not execute this transaction automatically when a timer expires. A merchant backend, billing worker, cron job, or approved puller will still need to submit the collection transaction. Create src/04-collect.ts:

04-collect.ts

import {
  findAssociatedTokenPda,
  TOKEN_PROGRAM_ADDRESS,
} from "@solana-program/token";
import {
  findPlanPda,
  findSubscriptionDelegationPda,
} from "@solana/subscriptions";

import {
  createAppClient,
  CUSTOMER_KEYPAIR,
  loadState,
  MERCHANT_KEYPAIR,
  PLAN_AMOUNT,
  printSignature,
} from "./config.js";

const [merchantClient, customerClient] =
  await Promise.all([
    createAppClient(MERCHANT_KEYPAIR),
    createAppClient(CUSTOMER_KEYPAIR),
  ]);

const { tokenMint, planId } = loadState();

const [merchantAta] =
  await findAssociatedTokenPda({
    mint: tokenMint,
    owner: merchantClient.identity.address,
    tokenProgram: TOKEN_PROGRAM_ADDRESS,
  });

const [customerAta] =
  await findAssociatedTokenPda({
    mint: tokenMint,
    owner: customerClient.identity.address,
    tokenProgram: TOKEN_PROGRAM_ADDRESS,
  });

const [planPda] = await findPlanPda({
  owner: merchantClient.identity.address,
  planId,
});

const [subscriptionPda] =
  await findSubscriptionDelegationPda({
    planPda,
    subscriber: customerClient.identity.address,
  });

const beforeCustomer =
  await merchantClient.rpc
    .getTokenAccountBalance(customerAta)
    .send();

const beforeMerchant =
  await merchantClient.rpc
    .getTokenAccountBalance(merchantAta)
    .send();

const result =
  await merchantClient.subscriptions.instructions
    .transferSubscription({
      caller: merchantClient.identity,

      // The customer whose balance is being charged.
      delegator: customerClient.identity.address,

      tokenMint,
      subscriptionPda,
      planPda,

      // Collect the full five-token period allowance.
      amount: PLAN_AMOUNT,

      receiverAta: merchantAta,
      tokenProgram: TOKEN_PROGRAM_ADDRESS,
    })
    .sendTransaction();

const afterCustomer =
  await merchantClient.rpc
    .getTokenAccountBalance(customerAta)
    .send();

const afterMerchant =
  await merchantClient.rpc
    .getTokenAccountBalance(merchantAta)
    .send();

console.log(
  "Customer:",
  beforeCustomer.value.uiAmountString,
  "->",
  afterCustomer.value.uiAmountString,
);

console.log(
  "Merchant:",
  beforeMerchant.value.uiAmountString,
  "->",
  afterMerchant.value.uiAmountString,
);

printSignature(
  "Collect payment",
  result.context.signature,
);

Run the script as the merchant. During execution, the program verifies that:

  • The caller is the merchant or an approved puller
  • The subscription belongs to the customer and plan
  • The subscription has not expired
  • The requested amount fits within the current period’s remaining allowance
  • The merchant wallet is an approved destination
  • The receiving token account belongs to the approved destination
  • The mint and Token Program match the accepted plan

Because this transaction collects the full five-token allowance, running it again during the same one-hour period should fail. Once the next period begins, the period allowance resets, and the merchant can run the collection script again. In a production billing system, the script’s equivalent would normally run only when an invoice becomes due.

Customer Cancels their Subscription

The customer can cancel the subscription without the merchant's cooperation. The standard cancellation flow does not immediately close the Subscription Delegation account. Instead, it marks the subscription as ending and assigns an expiresAtTs. Once that expiry has elapsed, the customer can revoke the subscription and close the PDA. Create src/05-cancel.ts:

05-cancel.ts

import {
  fetchSubscriptionDelegation,
  findPlanPda,
  findSubscriptionDelegationPda,
} from "@solana/subscriptions";

import {
  createAppClient,
  CUSTOMER_KEYPAIR,
  loadState,
  MERCHANT_KEYPAIR,
  printSignature,
} from "./config.js";

const [customerClient, merchantClient] =
  await Promise.all([
    createAppClient(CUSTOMER_KEYPAIR),
    createAppClient(MERCHANT_KEYPAIR),
  ]);

const { planId } = loadState();

const [planPda] = await findPlanPda({
  owner: merchantClient.identity.address,
  planId,
});

const [subscriptionPda] =
  await findSubscriptionDelegationPda({
    planPda,
    subscriber: customerClient.identity.address,
  });

const result =
  await customerClient.subscriptions.instructions
    .cancelSubscription({
      planPda,
      subscriptionPda,
    })
    .sendTransaction();

const subscription =
  await fetchSubscriptionDelegation(
    customerClient.rpc,
    subscriptionPda,
  );

const expiresAt = new Date(
  Number(subscription.data.expiresAtTs) * 1_000,
);

console.log("Subscription PDA:", subscriptionPda);

console.log(
  "Cancellation effective at:",
  expiresAt.toISOString(),
);

printSignature(
  "Cancel subscription",
  result.context.signature,
);

Run the script as the customer. The output includes the timestamp at which the cancellation becomes effective. The standard cancelSubscription instruction implements a grace period through the end of the active billing period.

Operationally, cancellation should not be treated as immediately zeroing the current period’s authorization. Any current-period allowance that remains available may still be collectible until expiresAtTs. The merchant cannot begin another billing period after the cancellation becomes effective.

This matches common subscription behavior in which cancellation stops the next renewal rather than retroactively ending the period the customer has already entered.

Practical Case Study: Helius Onchain Subscription Plans

Helius was one of the launch partners that helped shape the Subscriptions Delegation Program before its mainnet release. We use the program to support automatic USDC renewals of our API plans. Our goal is to give crypto-paying customers the convenience of a conventional SaaS subscription while keeping the payment authorization and settlement entirely on Solana.

To enable automatic payments, a customer adds a Solana wallet from the Payment Method section of the Helius billing dashboard

During setup, the customer signs a one-time approval for the official Solana Subscriptions Program and authorizes Helius to collect subscription payments in USDC from that wallet.

When a renewal invoice becomes due, Helius’s billing system submits the collection transaction. The customer does not need to open a payment link, reconnect their wallet, or sign another transfer. The Subscriptions Delegation Program supplies the reusable onchain authorization, while Helius continues to manage the invoice schedule, account state, and product entitlements.

A customer can connect up to three wallets, but only the wallet marked as the default payment method is used for automatic renewals. Helius does not attempt to split a charge across wallets or fall through to another connected wallet if the default wallet cannot cover the invoice.

Customers can change the default wallet from the dashboard. They can also remove a wallet, which requires a signature and revokes its authorization for automatic payments. Removing the only connected wallet returns the account to manual payment links.

Onchain authorization does not, of course, guarantee that the wallet will contain enough USDC when the next invoice becomes due. In this scenario:

  • Helius does not charge the wallet for that renewal.
  • The customer receives a payment link by email and in the dashboard.
  • The invoice is not retried automatically against the wallet.
  • After the customer tops up, later renewals can once again be collected automatically.

This fallback keeps the billing state straightforward. A failed automatic collection becomes an ordinary open invoice rather than an indefinite series of onchain retry transactions.

The implementation provides a practical example of how the Subscriptions Delegation Program fits into a production billing stack. The program does not replace invoicing, account management, notifications, or entitlement enforcement. It replaces the part that previously required the customer to authorize every renewal or required a centralized payment processor to store and exercise that authorization.

Conclusion

The Solana Subscriptions program introduces a standardized way to build recurring payments directly onchain. By combining subscription authorities, merchant plans, and delegated token transfers, developers can implement subscription billing without relying on offchain payment infrastructure or custom payment logic.

If you're building recurring payments on Solana, the Subscriptions program is the natural place to start. With the TypeScript SDK and Helius' RPCs and APIs, integrating onchain subscription billing is straightforward, allowing you to focus on your application rather than the underlying payment mechanics.

Further Resources

Related Articles

Subscribe to Helius

Stay up-to-date with the latest in Solana development and receive updates when we post