TL;DR
- We A/B tested a yearly subscription against a one-time lifetime license on three React Native templates for a full quarter.
- Lifetime converted at 2.4x the yearly plan, cut the 7-day refund rate from 6.8% to 3.1%, and dropped support tickets per 100 sales from 41 to 17.
- Repeat purchase went up under lifetime (~2% to ~11%). Every advisor we asked predicted the opposite.
- Root cause is product shape, not pricing fashion. Our marginal cost per buyer after delivery is ~0, so recurring billing had no cost curve to match.
- The actual code change was two lines in the Stripe session and one column in Postgres. The comms rewrite took a week.
The setup
We sell downloadable React Native templates. Full Expo + Supabase codebases you clone, rebrand, and ship. There is no server we keep alive for you, no per-seat metering, no proxy in front of your OpenAI key.
We still launched with a yearly plan, because that is what every pricing-page template on the internet says to do.
Two variants, three products (a $49, a $79, and an $89 control), traffic split ~50/50 by product page visit, swapped weekly to kill weekday effects:
- Variant A: yearly subscription at roughly one-third the lifetime price. Source access revoked on cancel.
- Variant B: one payment, lifetime updates, keep the code forever.
Identical code, identical Supabase migrations, identical Expo config. The only difference was what happened to your access when you stopped paying.
The numbers
| Metric | Yearly | Lifetime | Delta |
|---|---|---|---|
| Product-page to paid conversion | 1.0x | 2.4x | +140% |
| Refund rate (7-day window) | 6.8% | 3.1% | -54% |
| Support tickets per 100 sales | 41 | 17 | -59% |
| Cart abandonment at pricing | 68% | 44% | -24 pts |
| Repeat customer rate (60 days) | ~2% | ~11% | +9 pts |
Three things stood out.
Repeat purchase went up. The "if they own it they'll never come back" prediction was just wrong. A happy lifetime buyer came back for a second template. A subscription buyer treated the whole category as expensive rented software and never returned.
Refunds halved. A big share of "refund me" tickets were not dissatisfaction. They were anxiety about the next bill. Remove the next bill, remove the reason to hedge.
Support load collapsed. Subscription tickets were dominated by four questions that literally cannot exist under lifetime: how do I cancel, will you charge me if I don't ship, what happens to my code if I stop paying, can I export before renewal.
Why it broke: the economics didn't line up
The subscription playbook assumes the vendor keeps paying an ongoing cost to keep your use of the product alive. Check what we actually pay for after checkout:
// Our per-buyer ongoing cost, honestly enumerated
const ongoingCostPerBuyer = {
hosting: 0, // you deploy it
database: 0, // you connect your own Supabase project
llmInference: 0, // you drop in your own API key
appleDeveloper: 0, // your account, your $99/yr
bandwidth: 0, // one tarball at purchase time
};
// => sum is 0. There is no recurring cost to fund with recurring revenue.
You connect your own Supabase instance with the migrations we ship. You drop your own key into Expo's env config:
# .env.local: your keys, your quota, your bill
EXPO_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
EXPO_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
OPENAI_API_KEY=sk-your-own-key
When the buyer's cost is recurring and the vendor's cost is not, buyers hedge. They cancel early, they refund, and they open tickets about the next bill instead of the current build.
The actual code change
This is the part people assume is hard. It isn't. The Stripe side is a mode swap:
// Before: subscription
const session = await stripe.checkout.sessions.create({
mode: 'subscription',
line_items: [{ price: 'price_yearly_fitness', quantity: 1 }],
subscription_data: {
metadata: { template: 'fitness-app', revoke_on_cancel: 'true' },
},
success_url: `${SITE}/download?session_id={CHECKOUT_SESSION_ID}`,
});
// After: one-time payment
const session = await stripe.checkout.sessions.create({
mode: 'payment',
line_items: [{ price: 'price_lifetime_fitness', quantity: 1 }],
payment_intent_data: {
metadata: { template: 'fitness-app', license_type: 'single' },
},
success_url: `${SITE}/download?session_id={CHECKOUT_SESSION_ID}`,
});
Read the Stripe billing modes doc before you pick one. payment versus subscription is not a small config toggle, it reshapes the entire buyer relationship.
The schema side is one column:
-- supabase/migrations/20260114_product_licenses.sql
create type license_type as enum ('single', 'multiple', 'team', 'enterprise');
-- note: no 'monthly_recurring'. it does not exist anymore.
alter table public.product_licenses
add column license_type license_type not null default 'single',
add column purchased_at timestamptz not null default now();
-- entitlement check, post-switch
create or replace function public.has_template_access(
p_user_id uuid,
p_template text
) returns boolean language sql stable as $$
select exists (
select 1 from public.product_licenses
where user_id = p_user_id
and template_slug = p_template
);
-- no expiry comparison. that clause is what we deleted.
$$;
That deleted and expires_at > now() clause is the whole post in one line of SQL.
What buyers actually asked for
Tagged every sales conversation, support ticket, and abandoned-cart survey for the quarter. Three themes dominated:
- "Can I just buy it?" By far the most frequent. Developers wanted a purchase order, not a payment relationship.
- "What happens to the code if I cancel?" Our subscription answer was "access revoked." Correct, and hostile-sounding to anyone shipping a real product.
- "I need this for a client project, can I keep the fork after it ships?" Agency and freelancer use, a much bigger slice than we expected. A yearly plan does not serve "I need this for six weeks, then I'm done."
The rule I'd give you
Not "lifetime always wins." Match pricing to product shape:
| Product shape | Pricing | Why |
|---|---|---|
| Hosted SaaS with per-user server cost | Subscription | Recurring cost matches recurring value |
| API-metered service | Usage-based | Cost scales with call volume |
| Downloadable tool / plugin / template | Lifetime | Marginal cost after delivery is zero |
| Continuously-curated feed or library | Hybrid | Ongoing editorial work justifies ongoing revenue |
| Hardware + optional cloud | Perpetual + optional sub | Two cost curves, two prices |
The honest test: does my cost of serving this customer continue after they sign up? If yes, subscription. If no, you are selling a lifetime asset dressed up as a rental.
We would go back to subscription tomorrow for a hosted managed backend, a continuously-curated prompt library, an agency retainer, or an enterprise plan with a real SLA. For static, self-hosted, source-available code, subscription is a category error.
Where it landed
Every template in the Applighter catalog is now a one-time purchase between $49 and $99, including full TypeScript source, Supabase migrations, Expo config, and every future update to that template. Agencies get a multiple license upgrade instead of a seat subscription. Refund window is 7 days, no questions asked.
The FAQ line that took a week to write:
One payment, all future updates to that template, including Expo upgrades, new screens, and security patches. No subscription, no seat tax.
Every word in it was argued over. "All future updates" kills rug-pull fear. "Expo upgrades" makes the promise specific. "No seat tax" preempts the next question every dev asks.
Your turn
If you sell something downloadable and you're still on a yearly plan, I'd genuinely like to know what your refund rate looks like. And if you switched and it went badly, that's the more useful comment. Drop it below.
Top comments (0)