Bringing Subscription Billing On-Chain: A Solana Experiment

I’ve always found subscription billing fascinating. It’s one of those quiet systems that powers almost everything we pay for monthly — Netflix, Spotify, Notion, Patreon, SaaS tools, even some creator platforms. Behind the “Subscribe” button sits a surprisingly complex backend: plans, recurring charges, trial periods, failed payments with retries, cancellations (immediate or at the end of the cycle), pauses, and sometimes reactivation flows.
In Web2, this usually lives on centralized servers with Stripe or similar handling the money, and the merchant’s own database tracking everything else. It’s fast and convenient, but it comes with trade-offs: the platform can freeze your funds or change rules overnight, fees add up quickly, and neither users nor merchants can truly verify how the system behaves under the hood.
When the SuperTeam bounty asked us to rebuild a real-world backend pattern as a Solana program in Rust, subscription billing felt like the right challenge. It’s practical enough to matter, deep enough to require thoughtful design, and it forces you to confront Solana’s constraints head-on. So I built sub_model — a small but usable Anchor program that handles recurring SPL-token subscriptions with trials, pauses that actually freeze time, failure retries, grace periods, cancel-at-period-end logic, reactivation, and basic merchant stats like active subscribers and lifetime revenue.
This article isn’t just a code walk-through. It’s the story of how I moved a familiar Web2 pattern onto Solana, the choices I had to make, and what it taught me about what on-chain backends can (and can’t) do yet.
Check website demo
How Subscription Billing Works in Web2 (and Why It’s Centralized)
At its core, subscription billing is a state machine with a timer attached. A merchant defines a plan: price, billing interval, maybe a trial. A user subscribes, often with a card that becomes a token. A server periodically checks which subscriptions are due, attempts payment, handles failures (usually with retries and emails), and applies changes like cancellations or pauses.
The whole thing relies on a trusted middleman: the payment processor holds the token and executes charges, while the merchant’s database keeps track of status, billing history, and access rights. It works smoothly because someone is always watching — cron jobs run reliably, emails go out automatically, and support can manually fix things.
But that trust comes at a cost. Fees are high, platforms can deplatform accounts, and the logic is opaque. If Stripe decides to hold funds or change retry rules, merchants and users have little recourse. Everything is fast and convenient — until it isn’t.
What Solana Changes (and What It Forces You to Rethink)
Solana brings a few things that feel magical at first: transactions are cheap, tokens are native, and code runs trustlessly once deployed. But it also removes things we take for granted:
No background jobs — nothing happens automatically.
State is public — everyone can see subscription status and payments.
Time moves in ~400 ms slots — no millisecond precision, no frequent polling.
Code is immutable — no quick fixes or A/B tests after launch.
These constraints shaped every major decision.
I chose direct token transfers to the merchant’s ATA instead of an escrow vault. It removes custody risk and eliminates the need for a withdrawal step, which feels closer to how real money should move. The downside is no easy refunds or proration — but for v1, simplicity won.
Renewals are user-signed rather than fully automatic. A keeper or bot can trigger them, but the user still signs (or delegates later). This avoids complex delegation logic early, though it means full auto-renew needs off-chain help.
State transitions like “trial ended” or “cancel at period end” happen through a public instruction called process_expired. Anyone can call it when the time is right — keepers, bots, or even users themselves. It’s not as elegant as a cron job, but it keeps the program decentralized.
Pausing and resuming turned out to be one of the more interesting parts. In Web2, pause usually just stops billing and access. On Solana, I wanted fairness — if someone pauses, they shouldn’t lose paid time. So I added a previous_status field and a paused_at timestamp. When resuming, the program restores the old status and extends the period by however long the pause lasted. It’s a small detail, but it makes the system feel more humane.
How the Pieces Fit Together
Plans and subscriptions live as PDAs — deterministic addresses derived from seeds. A plan is scoped to a merchant and a unique ID, so multiple merchants can create overlapping plans without collision. Subscriptions are scoped to a user and a plan, which keeps data isolated and queries cheap.
Payments go straight from the user’s token account to the merchant’s. No middleman, no withdrawal step — just a simple token::transfer. To prevent abuse, the program checks that the merchant token account is actually owned by the plan owner and uses the same mint.
Status changes are explicit. There’s no hidden server logic — everything is in the instructions. process_expired is the closest thing to a cron job, and it’s deliberately permissionless so the system doesn’t rely on any single actor to keep state fresh.
The Trade-offs That Shaped This Version
Direct transfers are fast and trustless, but they make refunds and proration hard — future versions might add a small vault or proration math if needed.
User-signed renewals add friction compared to fully automatic billing, but they avoid delegation complexity for now. A keeper can prompt the user or use delegated authority later.
Public state means merchants can see aggregate stats (active subscribers, lifetime revenue), but individual user details are visible to anyone who derives the PDA. That’s a feature for transparency, but a drawback for privacy-sensitive use cases.
Time handling is coarse. Pauses and expiries are accurate to the second, but not millisecond-perfect. For most subscriptions that’s fine — nobody cares about a few seconds on a monthly plan.
What Still Needs Work
This is v1 — it’s usable, but not perfect. Renewals still require user signatures (no full auto-renew without off-chain help). There’s no upgrade/downgrade path between plans, no proration or refunds, and no rich metadata on plans (name, description, features).
Off-chain integration is needed for real-world use: a simple bot watching events and calling renew or process_expired when due. Helius webhooks or a cron job could do it cheaply.
Testing showed the logic holds up under time jumps, failure retries, pause/resume cycles, and invalid states — but more coverage (especially fuzzing overflows and edge timestamps) would make it even stronger.
Closing Thoughts
Building this made me appreciate Solana’s strengths and limits in a new way. It’s not trying to be a faster Ethereum or a cheaper AWS. It’s a different kind of backend: open, cheap, and immutable, but it asks you to rethink automation, privacy, and UX.
For many use-cases — creator platforms in emerging markets, DeFi protocols, DAOs, community memberships — the trade-offs feel worth it. For high-frequency consumer SaaS, Web2 still has the edge in convenience.
But the experiment worked. Subscription billing doesn’t have to live on someone else’s server. It can live on-chain, with real ownership and transparency.
The program is deployed on Devnet, the repo is public, and the code is open. If you’re curious, derive a PDA, call subscribe, and see what happens.
Thanks to Superteam for the prompt — this was a fun (and humbling) way to push the boundaries.



