Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.chainstack.com/llms.txt

Use this file to discover all available pages before exploring further.

TLDR:
  • On June 2, 2026, the Solana Foundation shipped Subscriptions & Allowances — an audited, open-source reference program (program ID De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44) that makes recurring billing and delegated spending native to Solana, live on mainnet and devnet.
  • The trick it solves: an SPL token account can have only one delegate. The program routes every arrangement through a per-(user, mint) Subscription Authority PDA that holds the single u64::MAX approval, then gates each transfer through individual delegation PDAs — so one token account can power unlimited simultaneous subscriptions and allowances.
  • It exposes three models: fixed delegations (allowances), recurring delegations, and subscription plans.
  • There is no Python SDK — the official TypeScript client is even Codama-generated — so we build the instructions by hand in Python with solders and run the full create-plan, subscribe, and collect lifecycle against a Chainstack Solana devnet node.

What shipped

On June 2, 2026, the Solana Foundation released Subscriptions & Allowances: a single shared program that any team can build on for recurring billing and delegated spending, instead of stitching together off-chain billing infrastructure. It is a Solana Foundation reference program — repo solana-program/subscriptions — written in Pinocchio (a lightweight no_std framework), with IDL and clients generated by Codama, audited by Cantina, and deployed under a Squads multisig upgrade authority. Design partners at launch include Helius (API-tier billing), Confirmo, Dynamic, Majority, Mesh, and Meow. This guide explains the one idea that makes the whole thing work, disambiguates it from the two adjacent primitives people confuse it with, and then walks the full lifecycle in Python against a Chainstack node.

The one-delegate problem

Solana’s SPL Token program already lets you hand spending authority to someone else. The Approve instruction records a delegate and an allowance on your token account: the delegate can move up to that many tokens out of your account without you signing each transfer. Revoke clears it. This is the primitive behind every “approve then pull” flow on Solana. It has one hard limit, stated plainly in the program’s architecture notes:
Solana’s SPL Token delegate model allows only one delegate per token account.
Approving a new delegate silently overwrites the previous one. So a single USDC account can authorize exactly one service at a time. Want to subscribe to two newsletters and give an AI agent a budget, all from the same balance? You can’t — the third approval wipes the first two. This constraint has been an open request since 2022 (solana-program-library issue #3858). The Subscriptions program fixes it with one level of indirection.

Not to be confused with

Before the mechanics, clear up three things that sound alike:
PrimitiveWhat it isLimit
Approve delegate (spl-token)The raw SPL Token instruction that sets one delegate + allowance on a token accountOne delegate per account
Spend permissionsSolana’s product framing of that same approve-delegate primitive for buildersSame — one delegate per account
Subscriptions programA program built on top of approve-delegate that multiplexes many policy-controlled delegations behind a single PDA delegateUnlimited simultaneous arrangements
“Spend permissions” on Solana is not a separate mechanism — it is the approve-delegate primitive described from a payments angle. The Subscriptions program is the thing that removes the one-delegate ceiling.

How it works

For each (user, mint) pair, the program derives a Subscription Authority (SA) PDA:
seeds = ["SubscriptionAuthority", user_pubkey, mint_pubkey]
On initialization, the program calls SPL Approve so the SA PDA becomes the user’s single delegate, with a u64::MAX allowance. From the token program’s point of view there is exactly one delegate — no conflict with the one-delegate rule. The SA itself can’t move anything. It only transfers when a separate delegation PDA authorizes a specific amount to a specific party. Those delegation PDAs are where all the real limits live:
delegation (fixed/recurring) = ["delegation", subscription_authority, delegator, delegatee, nonce]
subscription                  = ["subscription", plan_pda, subscriber]
So the SPL allowance is a blunt “yes, everything” switch, and the program is the policy engine in front of it. You can create as many delegation PDAs as you like against the one SA — each with its own cap, cadence, expiry, and authorized puller. That is how a single token account supports unlimited concurrent subscriptions and allowances while staying exactly as safe as a normal approve-delegate (the SA can never move funds without an active delegation that permits it).
Each delegation PDA records the SA’s init_id (the slot at which the SA was created). Every transfer checks it against the live SA. Closing and re-creating the SA in a later slot changes init_id, which invalidates old delegations — a coarse kill switch. To stop one specific delegation immediately, revoke that delegation PDA.

The three models

One program, three delegation types:
ModelWho creates itCap behaviorWho can pull
Fixed delegation (allowance)The token ownerOne total amount, decremented per transfer; optional expiryThe delegatee only
Recurring delegationThe token owneramount_per_period that resets each period; skipped periods don’t stackThe delegatee only
Subscription planThe merchantPlan-defined amount per period_hours, resets each periodPlan owner + up to 4 whitelisted pullers
Fixed and recurring delegations are pull arrangements the user sets up for a specific delegatee — ideal for allowances and payroll. Subscription plans flip the direction: a merchant publishes immutable terms once, and any number of users subscribe to the same plan, each getting their own subscription PDA. The merchant (or a whitelisted puller) collects each cycle. Subscription plan terms (amount, period_hours, and an on-chain created_at fingerprint) are immutable and are snapshotted into each subscriber’s PDA at subscribe time. A merchant can’t change the price out from under existing subscribers — and can’t delete-and-recreate a plan at the same address with new terms, because the snapshot would no longer match (you’d get a PlanTermsMismatch).

Token-2022 and stablecoins

The program works with both SPL Token and Token-2022 mints, but Token-2022 extensions that would make delegated transfers unsafe are a problem.
The program’s README and the official docs state that seven extensions are rejected at SA initialization: ConfidentialTransfer, NonTransferable, PermanentDelegate, TransferHook, TransferFee, MintCloseAuthority, and Pausable. That is the design intent, and it’s the right mental model for what mints are safe.The shipped code is narrower: the runtime check (validate_mint_extensions) currently rejects only a mint with a configured TransferHook (an inert, permanently-unconfigured hook is allowed). The ConfidentialTransfer error code is even marked “reserved for backwards compatibility.” So don’t rely on the program to bounce a fee-bearing or permanent-delegate mint for you — treat the seven-item list as the set of mints you should not use, and pick your mint deliberately.
In practice:
  • USDC works — it’s a plain mint with no problematic extensions. (Solana’s announcement copy says the program works “including confidential transfers”; that contradicts both the README and the code, so don’t build on it.)
  • PYUSD does not — PayPal’s Solana mint carries Token-2022 extensions (permanent delegate / transfer fee) that the design rules out for delegated pulls.
We’ll use a plain SPL mint for the walkthrough.

Prerequisites

  • Python 3.10+
  • pip install solana solders (the solana package ships the spl.token helpers we use for mint setup)
  • A Chainstack Solana devnet node endpoint
  • Some devnet SOL from the Chainstack faucet for fees and rent

Run Solana mainnet and devnet nodes on Chainstack

Start for free and get your app to production levels immediately. No credit card required. You can sign up with your GitHub, X, Google, or Microsoft account.
To get a devnet endpoint: in the Chainstack console, deploy a Solana devnet node and copy its HTTPS endpoint. We’ll refer to it as CHAINSTACK_DEVNET below.

Build it in Python

There is no Python SDK for this program, and the official TypeScript client is Codama-generated (and gitignored in the repo) — so the cleanest path in Python is to build the instructions ourselves. The program makes this pleasant: every instruction is a single leading discriminator byte followed by a fixed, C-packed, little-endian payload — no Borsh length prefixes, no 8-byte Anchor hashes.
1

Constants and helpers

The program IDs, PDA derivation, ATA derivation, and a small instruction-data encoder.
import time
import struct

from solana.rpc.api import Client
from solders.keypair import Keypair
from solders.pubkey import Pubkey
from solders.instruction import Instruction, AccountMeta
from solders.message import MessageV0
from solders.transaction import VersionedTransaction
from solders.system_program import transfer, TransferParams
from spl.token.client import Token
from spl.token.constants import TOKEN_PROGRAM_ID, ASSOCIATED_TOKEN_PROGRAM_ID
from solana.rpc.commitment import Confirmed

CHAINSTACK_DEVNET = "YOUR_CHAINSTACK_SOLANA_DEVNET_ENDPOINT"

PROGRAM_ID = Pubkey.from_string("De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44")
SYS_PROGRAM = Pubkey.from_string("11111111111111111111111111111111")

# The program's "anchor:event" CPI authority is a constant PDA.
EVENT_AUTHORITY = Pubkey.find_program_address([b"event_authority"], PROGRAM_ID)[0]

# Pin the client to "confirmed" so reads and confirmations don't wait on finalization.
client = Client(CHAINSTACK_DEVNET, commitment=Confirmed)


# --- PDA derivation (seeds taken verbatim from the program) ---

def subscription_authority_pda(user: Pubkey, mint: Pubkey) -> tuple[Pubkey, int]:
    return Pubkey.find_program_address(
        [b"SubscriptionAuthority", bytes(user), bytes(mint)], PROGRAM_ID
    )


def plan_pda(owner: Pubkey, plan_id: int) -> tuple[Pubkey, int]:
    return Pubkey.find_program_address(
        [b"plan", bytes(owner), plan_id.to_bytes(8, "little")], PROGRAM_ID
    )


def subscription_pda(plan: Pubkey, subscriber: Pubkey) -> tuple[Pubkey, int]:
    return Pubkey.find_program_address(
        [b"subscription", bytes(plan), bytes(subscriber)], PROGRAM_ID
    )


def ata(owner: Pubkey, mint: Pubkey) -> Pubkey:
    return Pubkey.find_program_address(
        [bytes(owner), bytes(TOKEN_PROGRAM_ID), bytes(mint)],
        ASSOCIATED_TOKEN_PROGRAM_ID,
    )[0]


def meta(pubkey: Pubkey, signer: bool, writable: bool) -> AccountMeta:
    return AccountMeta(pubkey=pubkey, is_signer=signer, is_writable=writable)


def send(ixs: list[Instruction], payer: Keypair, signers: list[Keypair]) -> str:
    blockhash = client.get_latest_blockhash().value.blockhash
    msg = MessageV0.try_compile(payer.pubkey(), ixs, [], blockhash)
    tx = VersionedTransaction(msg, signers)
    sig = client.send_raw_transaction(bytes(tx)).value
    client.confirm_transaction(sig, commitment=Confirmed)
    return str(sig)
2

Instruction builders

Each builder returns a solders.Instruction. The account order and discriminators come straight from the program’s instruction handlers.
# Instruction discriminators (single leading byte).
INIT_SUBSCRIPTION_AUTHORITY = 0
CREATE_PLAN = 7
SUBSCRIBE = 11
TRANSFER_SUBSCRIPTION = 10


def ix_init_subscription_authority(user: Pubkey, mint: Pubkey) -> Instruction:
    sa, _ = subscription_authority_pda(user, mint)
    accounts = [
        meta(user, signer=True, writable=True),
        meta(sa, signer=False, writable=True),
        meta(mint, signer=False, writable=False),
        meta(ata(user, mint), signer=False, writable=True),
        meta(SYS_PROGRAM, signer=False, writable=False),
        meta(TOKEN_PROGRAM_ID, signer=False, writable=False),
    ]
    return Instruction(PROGRAM_ID, bytes([INIT_SUBSCRIPTION_AUTHORITY]), accounts)


def ix_create_plan(
    merchant: Pubkey,
    mint: Pubkey,
    plan_id: int,
    amount: int,
    period_hours: int,
    end_ts: int = 0,
    metadata_uri: str = "",
) -> Instruction:
    plan, _ = plan_pda(merchant, plan_id)

    # PlanData (456 bytes): plan_id u64, mint, terms{amount u64, period_hours u64,
    # created_at i64}, end_ts i64, destinations[4], pullers[4], metadata_uri[128].
    # created_at is sent as 0 and overwritten on-chain with the current clock.
    data = bytearray([CREATE_PLAN])
    data += struct.pack("<Q", plan_id)
    data += bytes(mint)
    data += struct.pack("<QQq", amount, period_hours, 0)   # terms
    data += struct.pack("<q", end_ts)
    data += bytes(32 * 4)                                  # destinations: any
    data += bytes(32 * 4)                                  # pullers: owner only
    uri = metadata_uri.encode("utf-8")[:128]
    data += uri + bytes(128 - len(uri))                    # fixed 128-byte string

    accounts = [
        meta(merchant, signer=True, writable=True),
        meta(plan, signer=False, writable=True),
        meta(mint, signer=False, writable=False),
        meta(SYS_PROGRAM, signer=False, writable=False),
        meta(TOKEN_PROGRAM_ID, signer=False, writable=False),
    ]
    return Instruction(PROGRAM_ID, bytes(data), accounts)


def ix_subscribe(subscriber: Pubkey, merchant: Pubkey, mint: Pubkey, plan_id: int) -> Instruction:
    plan, plan_bump = plan_pda(merchant, plan_id)
    sub, _ = subscription_pda(plan, subscriber)
    sa, _ = subscription_authority_pda(subscriber, mint)

    # The subscriber consents to the exact live plan terms. Read them from chain
    # so a stale signed transaction can't bind the user to different terms.
    amount, period_hours, created_at = read_plan_terms(plan)
    init_id = read_sa_init_id(sa)

    # SubscribeData (73 bytes): plan_id u64, plan_bump u8, expected_mint,
    # expected_amount u64, expected_period_hours u64, expected_created_at i64,
    # expected_sa_init_id i64.
    data = bytearray([SUBSCRIBE])
    data += struct.pack("<QB", plan_id, plan_bump)
    data += bytes(mint)
    data += struct.pack("<QQqq", amount, period_hours, created_at, init_id)

    accounts = [
        meta(subscriber, signer=True, writable=True),
        meta(merchant, signer=False, writable=False),
        meta(plan, signer=False, writable=False),
        meta(sub, signer=False, writable=True),
        meta(sa, signer=False, writable=False),
        meta(SYS_PROGRAM, signer=False, writable=False),
        meta(EVENT_AUTHORITY, signer=False, writable=False),
        meta(PROGRAM_ID, signer=False, writable=False),   # self program
    ]
    return Instruction(PROGRAM_ID, bytes(data), accounts)


def ix_transfer_subscription(
    caller: Pubkey, subscriber: Pubkey, merchant: Pubkey, mint: Pubkey,
    plan_id: int, amount: int, receiver: Pubkey,
) -> Instruction:
    plan, _ = plan_pda(merchant, plan_id)
    sub, _ = subscription_pda(plan, subscriber)
    sa, _ = subscription_authority_pda(subscriber, mint)

    # TransferData (72 bytes): amount u64, delegator (the subscriber), mint.
    data = bytearray([TRANSFER_SUBSCRIPTION])
    data += struct.pack("<Q", amount)
    data += bytes(subscriber)
    data += bytes(mint)

    accounts = [
        meta(sub, signer=False, writable=True),
        meta(plan, signer=False, writable=False),
        meta(sa, signer=False, writable=False),
        meta(ata(subscriber, mint), signer=False, writable=True),
        meta(ata(receiver, mint), signer=False, writable=True),
        meta(caller, signer=True, writable=False),
        meta(mint, signer=False, writable=False),
        meta(TOKEN_PROGRAM_ID, signer=False, writable=False),
        meta(EVENT_AUTHORITY, signer=False, writable=False),
        meta(PROGRAM_ID, signer=False, writable=False),   # self program
    ]
    return Instruction(PROGRAM_ID, bytes(data), accounts)
The two read helpers decode the raw account bytes. Both accounts are #[repr(C, packed)], so the offsets are exact:
def read_sa_init_id(sa: Pubkey) -> int:
    # SubscriptionAuthority: disc(1) user(32) mint(32) payer(32) bump(1) init_id(i64).
    data = client.get_account_info(sa).value.data
    return int.from_bytes(data[98:106], "little", signed=True)


def read_plan_terms(plan: Pubkey) -> tuple[int, int, int]:
    # Plan: disc(1) owner(32) bump(1) status(1) then PlanData at offset 35.
    # PlanData: plan_id(35:43) mint(43:75) amount(75:83) period_hours(83:91)
    #           created_at(91:99) ...
    data = client.get_account_info(plan).value.data
    amount = int.from_bytes(data[75:83], "little")
    period_hours = int.from_bytes(data[83:91], "little")
    created_at = int.from_bytes(data[91:99], "little", signed=True)
    return amount, period_hours, created_at
3

Fund the wallets

We need two wallets — a merchant and a subscriber — and some devnet SOL for fees and rent. Managed devnet nodes have requestAirdrop disabled, so we fund the merchant from the Chainstack faucet and then send the subscriber a little SOL from the merchant.
merchant = Keypair()
subscriber = Keypair()

print("Fund this merchant address from the Chainstack faucet, then continue:")
print(merchant.pubkey())
Paste the merchant address into the Chainstack Solana faucet to receive 1 devnet SOL, then run the rest:
# Wait until the merchant is funded.
while client.get_balance(merchant.pubkey()).value == 0:
    time.sleep(2)

# Send the subscriber 0.2 SOL for rent and fees.
send(
    [transfer(TransferParams(
        from_pubkey=merchant.pubkey(),
        to_pubkey=subscriber.pubkey(),
        lamports=200_000_000,
    ))],
    payer=merchant,
    signers=[merchant],
)
print("subscriber funded:", client.get_balance(subscriber.pubkey()).value / 1e9, "SOL")
4

Create a mint and give the subscriber a balance

A fresh 6-decimal SPL mint, an ATA for each wallet, and 100 test tokens for the subscriber to be billed against. We use a plain SPL mint so there are no Token-2022 extensions to worry about.
# Create a 6-decimal mint with the merchant as mint authority.
token = Token.create_mint(
    conn=client,
    payer=merchant,
    mint_authority=merchant.pubkey(),
    decimals=6,
    program_id=TOKEN_PROGRAM_ID,
)
MINT = token.pubkey

# Create ATAs and mint 100 test tokens to the subscriber.
token.create_associated_token_account(subscriber.pubkey())
token.create_associated_token_account(merchant.pubkey())
token.mint_to(
    dest=ata(subscriber.pubkey(), MINT),
    mint_authority=merchant,
    amount=100_000_000,  # 100.0 tokens at 6 decimals
)
print("mint:", MINT)
5

Initialize the subscriber's Subscription Authority

This creates the SA PDA and approves it as the u64::MAX delegate on the subscriber’s token account. The subscriber must sign — a sponsor can pay rent but cannot approve on someone else’s account.
sig = send(
    [ix_init_subscription_authority(subscriber.pubkey(), MINT)],
    payer=subscriber,
    signers=[subscriber],
)
print("init SA:", sig)
6

Merchant publishes a plan

A plan billing 1 token per day. period_hours is the billing period, bounded to 1–8760 (up to one year); we pass 24.
PLAN_ID = 1
sig = send(
    [ix_create_plan(
        merchant=merchant.pubkey(),
        mint=MINT,
        plan_id=PLAN_ID,
        amount=1_000_000,     # 1.0 token per period
        period_hours=24,      # daily
        metadata_uri="https://example.com/plan.json",
    )],
    payer=merchant,
    signers=[merchant],
)
print("create plan:", sig)
7

Subscriber subscribes

ix_subscribe reads the live plan terms and the SA init_id from chain and binds them into the instruction as consent fields. The program rejects the subscribe if the live plan disagrees.
sig = send(
    [ix_subscribe(subscriber.pubkey(), merchant.pubkey(), MINT, PLAN_ID)],
    payer=subscriber,
    signers=[subscriber],
)
print("subscribe:", sig)
8

Merchant collects the first payment

The merchant pulls one period’s amount from the subscriber into the merchant’s token account. The program enforces that the caller is the plan owner (or a whitelisted puller), the receiver is allowed, and the per-period cap isn’t exceeded.
sig = send(
    [ix_transfer_subscription(
        caller=merchant.pubkey(),
        subscriber=subscriber.pubkey(),
        merchant=merchant.pubkey(),
        mint=MINT,
        plan_id=PLAN_ID,
        amount=1_000_000,           # this period's charge
        receiver=merchant.pubkey(),
    )],
    payer=merchant,
    signers=[merchant],
)
print("collect:", sig)

bal = client.get_token_account_balance(ata(merchant.pubkey(), MINT)).value
print("merchant balance:", bal.ui_amount_string)
A second collect inside the same 24-hour period fails with AmountExceedsPeriodLimit — the cap resets only when the period rolls over. Skipped periods don’t accumulate.
That’s the full lifecycle: one SA approval, one published plan, any number of subscribers, and a pull each cycle — all onchain, no billing backend.

What’s next

This guide covered the user-facing lifecycle. Two follow-ups build on it:
  • Indexing subscription events with Yellowstone gRPC — the program emits create, cancel, and pull events as Anchor-compatible inner-instruction CPIs. Streaming them through a Chainstack Geyser endpoint gives you live revenue, churn, and failed-collection dashboards without polling.
  • Capped onchain budgets for AI agents — the fixed-delegation model is the natural way to hand an autonomous agent a spending allowance, and it pairs with x402 for per-call agentic commerce.

Conclusion

Subscriptions & Allowances turns recurring revenue from custom infrastructure into a shared, audited onchain primitive. The whole design rests on one idea — route every arrangement through a single Subscription Authority delegate and enforce the real limits in per-delegation PDAs — which is what lifts the one-delegate-per-account ceiling that blocked this for years. Because there’s no Python SDK, building the instructions by hand is also the best way to understand exactly what the program checks on every pull. Point the script at a Chainstack devnet node, run it top to bottom, and you have a working subscription in a few seconds.

About the author

Ake

Ake Director of Developer Experience @ Chainstack
Talk to me all things Web3
20 years in technology | 8+ years in Web3 full time years experience
Last modified on June 3, 2026