Newsletter Monetization: The Custom Approach
We chose custom over Substack and Buttondown. Here’s why and how.
The Decision Matrix
| Custom (Ours) | Substack | Buttondown | Beehiiv | Ghost | Kit (ConvertKit) | |
|---|---|---|---|---|---|---|
| Revenue cut | 0% (Stripe 2.9%) | 10% | 0% ($9/mo fee) | 0% (Stripe fees) | 0% (flat monthly) | 3.5% + $0.30/txn |
| Monthly cost | $0 (self-hosted) | $0 | $9/mo+ | $0-49/mo+ | $11/mo+ | $33/mo+ |
| Subscriber data | You own it | They own it | You own it | You own it | You own it | You own it |
| Domain | Your brand | yourname.substack.com | Custom domain | Custom domain | Custom domain | Custom domain |
| Design control | 100% | Template only | Template only | Good templates | Full (open source) | Good templates |
| Discovery | You drive traffic | Substack network | You drive traffic | Boost network | You drive traffic | Creator network |
| Built-in ads | No | No | No | Yes (ad network) | No | No |
| Referral program | Build it yourself | Substack recs | No | Built-in | No | Built-in |
| Email delivery | You manage | They handle | They handle | They handle | They handle | They handle |
| Automation | Build it yourself | Basic | Basic | Good | Decent | Best in class |
| Migration risk | None (it’s yours) | High | Medium | Medium | Low (open source) | Medium |
When Each Platform Wins
Substack: You want built-in discovery and zero setup. You’re OK giving up 10% and your domain. Good for writers who want to start in 10 minutes.
Beehiiv: You want a managed platform with a real ad network. Their Boost feature pays you $1-3 per subscriber when you recommend other newsletters. Best for growth-focused operators.
Ghost: You want full control without building from scratch. Open source, self-hostable, flat monthly pricing. Good for technical creators who want ownership without building payment infra.
Kit (ConvertKit): You’re selling digital products alongside the newsletter. Best automation builder in the game. But their 3.5% transaction fee on product sales adds up fast.
Custom (what we did): You want 100% control, zero platform risk, and you’re comfortable with Python/JS. You keep every dollar after Stripe’s 2.9%. The tradeoff is you build and maintain everything yourself.
The Math on Platform Fees
At $2,000 MRR:
- Custom: $58/mo to Stripe (2.9%). You keep $1,942.
- Substack: $200/mo to Substack + $52 to Stripe. You keep $1,748. That’s $194/mo you’re paying for… what exactly?
- Kit: $70/mo in transaction fees + $66/mo for Creator Pro plan. You keep $1,864.
- Beehiiv: $49/mo Scale plan + $58 to Stripe. You keep $1,893.
- Ghost: $25/mo Creator plan + $58 to Stripe. You keep $1,917.
The difference between custom and Substack at $2K MRR is $194/month. That’s $2,328/year. At $5K MRR, Substack costs you $500/month or $6,000/year. Real money.
Our Pricing Tiers
Free ($0)
- Top 5 stories weekly
- Headlines + sources
- Email delivery
Pro ($9/mo or $89/year)
- All 20 stories weekly
- Full “why it matters” analysis
- Source links + engagement data
- Early access (Friday PM vs Monday)
- Reply to any issue
Founding Member ($29/mo)
- Everything in Pro
- Monthly 1:1 call (15 min)
- Private Slack channel
- Shape what the newsletter covers
- Name in credits forever
- Price locked. Goes up later
Pricing Psychology: Why These Numbers Work
The Anchor Effect
People don’t evaluate prices in isolation. They compare. That’s why the pricing page shows all three tiers side by side. The Founding tier at $29/mo makes Pro at $9/mo feel cheap. This isn’t a trick. It’s how brains work. Tversky and Kahneman documented this in the 1970s, and it’s just as true now.
The Annual Discount Sweet Spot
$89/year saves 18% vs monthly. Why not 20%? Because 18% is “noticeable but not suspicious.” A 50% annual discount makes people wonder what’s wrong with the monthly price. 15-20% is the zone where people feel smart for choosing annual without questioning the monthly rate.
Annual plans also reduce churn by 51% compared to monthly. That’s not a small number. If your monthly churn is 5%, your annual churn is effectively 2.5%.
Price Anchoring in Practice
Show your pricing in this order: Founding ($29) first, then Pro ($9), then Free. The first number someone sees becomes the anchor. When $29 is the anchor, $9 feels like a steal.
If you show Free first, $9 feels expensive because the anchor is $0. Order matters.
The “50 Spots” Constraint
“50 founding spots” creates real scarcity. Not fake countdown-timer scarcity. Actual finite availability. When 30 of 50 are claimed, put that number on the page. “18 founding spots remaining” converts 2-3x better than “limited spots available.”
Don’t Discount. Add Value Instead.
Never run a 50%-off sale. It trains people to wait for discounts and devalues your work. Instead, add a bonus: “Subscribe this week and get our complete scoring algorithm doc (normally Founding-only) free.” Same economic effect, no brand damage.
The Technical Setup
What You Need
- Stripe account (free, 2.9% per transaction)
- Three Products in Stripe Dashboard:
- Pro Monthly ($9/mo, recurring)
- Pro Annual ($89/year, recurring)
- Founding Member ($29/mo, recurring)
- Four environment variables:
STRIPE_SECRET_KEY=sk_live_... STRIPE_PRICE_PRO_MONTHLY=price_... STRIPE_PRICE_PRO_ANNUAL=price_... STRIPE_PRICE_FOUNDING=price_...
The Checkout Flow
- Reader clicks “Upgrade to Pro” on your site
- Your API creates a Stripe Checkout Session
- Reader enters payment on Stripe’s hosted page (you never touch card data)
- Stripe redirects back to your site with
?upgraded=true - Webhook updates subscriber tier in your database
- Next newsletter sends full content to paid subscribers
The Paywall
Free readers see top 5 stories. A gradient fade covers stories 6-20 with an upgrade CTA. Simple CSS:
.paywall-fade {
height: 120px;
background: linear-gradient(transparent, var(--color-bg));
margin-top: -120px;
}
No JavaScript paywall hacks. No client-side checks. The full content simply isn’t in the free email. It’s server-side.
Webhook Handling for Subscription Lifecycle
This is where most newsletter devs mess up. Stripe sends you events for everything that happens to a subscription. If you miss an event or handle it wrong, people pay but don’t get access, or they cancel but keep getting premium content.
The Events That Matter
import stripe
import hmac
import hashlib
import json
from datetime import datetime, timezone
WEBHOOK_HANDLERS = {
"checkout.session.completed": "handle_new_subscription",
"invoice.payment_succeeded": "handle_renewal",
"invoice.payment_failed": "handle_payment_failure",
"customer.subscription.updated": "handle_plan_change",
"customer.subscription.deleted": "handle_cancellation",
}
Verify Every Webhook
Stripe signs every webhook with your secret. If you skip verification, anyone can POST fake events to your endpoint and grant themselves free access.
def verify_stripe_webhook(payload: bytes, sig_header: str, secret: str) -> dict:
"""
Verify webhook signature. Returns the event or raises ValueError.
"""
try:
event = stripe.Webhook.construct_event(payload, sig_header, secret)
return event
except stripe.error.SignatureVerificationError:
raise ValueError("Invalid webhook signature")
except Exception as e:
raise ValueError(f"Webhook error: {str(e)}")
Handle Each Event
def handle_new_subscription(event: dict, conn):
"""checkout.session.completed - Someone just paid."""
session = event["data"]["object"]
customer_email = session["customer_details"]["email"]
subscription_id = session.get("subscription")
# Look up which price they bought
line_items = stripe.checkout.Session.list_line_items(session["id"])
price_id = line_items["data"][0]["price"]["id"]
tier = price_to_tier(price_id)
conn.execute("""
UPDATE subscribers SET
tier = ?,
stripe_customer_id = ?,
stripe_subscription_id = ?,
subscribed_at = ?,
updated_at = ?
WHERE email = ?
""", (tier, session["customer"], subscription_id,
datetime.now(timezone.utc).isoformat(),
datetime.now(timezone.utc).isoformat(),
customer_email))
conn.commit()
# Send welcome email for their tier
send_welcome_email(customer_email, tier)
def handle_renewal(event: dict, conn):
"""invoice.payment_succeeded - Renewal went through."""
invoice = event["data"]["object"]
customer_id = invoice["customer"]
# Update last payment date
conn.execute("""
UPDATE subscribers SET
last_payment_at = ?,
payment_failures = 0,
updated_at = ?
WHERE stripe_customer_id = ?
""", (datetime.now(timezone.utc).isoformat(),
datetime.now(timezone.utc).isoformat(),
customer_id))
conn.commit()
def handle_payment_failure(event: dict, conn):
"""invoice.payment_failed - Card declined or expired."""
invoice = event["data"]["object"]
customer_id = invoice["customer"]
attempt = invoice.get("attempt_count", 1)
conn.execute("""
UPDATE subscribers SET
payment_failures = ?,
updated_at = ?
WHERE stripe_customer_id = ?
""", (attempt, datetime.now(timezone.utc).isoformat(), customer_id))
conn.commit()
# Stripe retries 3 times over ~3 weeks by default.
# Don't downgrade immediately. Send a friendly nudge instead.
if attempt == 1:
send_email_template("payment_failed_gentle", customer_id)
elif attempt == 2:
send_email_template("payment_failed_urgent", customer_id)
# After attempt 3, Stripe fires customer.subscription.deleted
def handle_cancellation(event: dict, conn):
"""customer.subscription.deleted - Subscription ended."""
subscription = event["data"]["object"]
customer_id = subscription["customer"]
conn.execute("""
UPDATE subscribers SET
tier = 'free',
stripe_subscription_id = NULL,
cancelled_at = ?,
updated_at = ?
WHERE stripe_customer_id = ?
""", (datetime.now(timezone.utc).isoformat(),
datetime.now(timezone.utc).isoformat(),
customer_id))
conn.commit()
send_email_template("cancellation_confirmation", customer_id)
def handle_plan_change(event: dict, conn):
"""customer.subscription.updated - Upgrade or downgrade."""
subscription = event["data"]["object"]
customer_id = subscription["customer"]
new_price_id = subscription["items"]["data"][0]["price"]["id"]
new_tier = price_to_tier(new_price_id)
conn.execute("""
UPDATE subscribers SET
tier = ?,
updated_at = ?
WHERE stripe_customer_id = ?
""", (new_tier, datetime.now(timezone.utc).isoformat(), customer_id))
conn.commit()
def price_to_tier(price_id: str) -> str:
"""Map Stripe price ID to tier name."""
import os
mapping = {
os.environ["STRIPE_PRICE_PRO_MONTHLY"]: "pro",
os.environ["STRIPE_PRICE_PRO_ANNUAL"]: "pro",
os.environ["STRIPE_PRICE_FOUNDING"]: "founding",
}
return mapping.get(price_id, "free")
Critical Rule: Respond Fast
Stripe expects a 2xx response within a few seconds. If your webhook handler does slow work (sending emails, calling external APIs), acknowledge the webhook first and process async.
import threading
def webhook_endpoint(request):
"""Acknowledge immediately, process in background."""
try:
event = verify_stripe_webhook(
request.body,
request.headers["Stripe-Signature"],
os.environ["STRIPE_WEBHOOK_SECRET"]
)
except ValueError:
return Response(status=400)
# Acknowledge immediately
handler_name = WEBHOOK_HANDLERS.get(event["type"])
if handler_name:
thread = threading.Thread(
target=globals()[handler_name],
args=(event, sqlite3.connect("data/digest.db"))
)
thread.start()
return Response(status=200) # tell Stripe we got it
Testing Webhooks Locally
# Install Stripe CLI
brew install stripe/stripe-cli/stripe
# Forward events to your local server
stripe listen --forward-to localhost:8000/api/webhook
# In another terminal, trigger test events
stripe trigger checkout.session.completed
stripe trigger invoice.payment_failed
stripe trigger customer.subscription.deleted
The CLI gives you a temporary webhook secret (whsec_…). Use it as your STRIPE_WEBHOOK_SECRET in development.
Revenue Projections
Updated with realistic 2026 benchmarks. The average paid newsletter charges $9.40/mo. Typical free-to-paid conversion is 5-10%. Average monthly churn is 4-5% (top performers keep it below 3%).
| Subscribers | Free | Pro ($9) | Founding ($29) | MRR | Annual (w/ 4% monthly churn) |
|---|---|---|---|---|---|
| 100 | 92 | 6 | 2 | $112 | ~$1,050 |
| 500 | 460 | 30 | 10 | $560 | ~$5,400 |
| 1,000 | 920 | 60 | 20 | $1,120 | ~$10,800 |
| 5,000 | 4,600 | 300 | 50* | $4,150 | ~$41,000 |
| 10,000 | 9,200 | 600 | 50* | $6,850 | ~$68,000 |
*Founding caps at 50 spots
These assume 8% paid conversion (conservative for a niche AI newsletter). The “Annual” column accounts for churn. You won’t keep every subscriber you get. At 4% monthly churn, you lose ~40% of monthly subscribers per year. That’s why pushing annual plans matters so much.
The Honest Math
Most newsletter revenue projections are fantasy. Here’s the reality check:
- Month 1-3: You’ll have 50-200 subscribers. Almost all free. MRR: $0-50.
- Month 4-6: If you’re consistent, you hit 500. Maybe 20-30 paid. MRR: $200-400.
- Month 7-12: At 1,000 subs with good content, expect 60-80 paid. MRR: $600-1,000.
- Year 2: If you haven’t quit, 2,000-5,000 subs. MRR: $1,500-4,000.
The people making $10K+/month from newsletters either (a) had an existing audience, (b) have been at it for 2+ years, or (c) are in the top 1% of growth execution. Plan for the median, not the dream.
Churn Reduction Strategies
Acquiring a subscriber costs 5-10x more than keeping one. Here’s how to keep them.
1. Push Annual Plans Hard
Annual subscribers churn 51% less than monthly. At signup, make annual the default selection. Show the savings prominently: “Save $19/year” is better than “Save 18%.” People respond to dollar amounts more than percentages.
After 2 months on a monthly plan, send a targeted email: “You’ve been a Pro member for 2 months. Lock in your rate and save $19 by switching to annual.” Time this before they’ve had a chance to think about canceling.
2. Win the First 30 Days
Members who don’t engage in the first 90 days are 73% more likely to churn. But you don’t have 90 days. You have 30.
Week 1: Welcome email with “here’s what you just got access to” and links to the 3 best past issues. Week 2: Personal check-in. “How are you liking Pro so far? Hit reply.” Actually read the replies. Week 3: Deliver unexpected value. Share something exclusive that wasn’t advertised. A bonus analysis, a tool recommendation, anything that makes them think “this was worth it.” Week 4: Social proof. “300 other Pro members read this week’s issue. Here’s what they clicked most.”
3. Cancellation Flow with a Save Offer
When someone clicks “Cancel,” don’t just let them go. Show a one-question survey (“Why are you leaving?”) and a save offer based on their answer:
| Reason | Save Offer |
|---|---|
| ”Too expensive” | Offer 2 months at 50% off |
| ”Not enough time to read” | Switch to a monthly summary format |
| ”Content isn’t relevant” | Offer to customize their topic preferences |
| ”Just taking a break” | Pause subscription for 1-2 months |
A good cancellation flow saves 15-25% of would-be churners.
4. Dunning Emails (Failed Payments)
40-50% of churn is involuntary. Expired cards, insufficient funds, bank blocks. Stripe retries automatically, but you should also email the subscriber:
- Attempt 1: Friendly. “Heads up, your payment didn’t go through. Want to update your card?”
- Attempt 2 (1 week later): Urgent but kind. “We don’t want you to lose access. Update here.”
- Attempt 3 (2 weeks later): Last chance. “Your Pro access ends in 3 days.”
Link directly to Stripe’s hosted customer portal for card updates. Don’t make them log in to your site first.
5. Build Community Hooks
Newsletters with community features reduce churn by 23%. This doesn’t mean you need a Discord server. A simple “reply to this email” culture works. Forward interesting reader replies (with permission) in the next issue. Make people feel like they’re part of something, not just consuming content.
Referral Programs
Word of mouth is the cheapest and highest-quality growth channel. But it doesn’t happen by itself. You need a system.
Option 1: SparkLoop
SparkLoop is the market leader for newsletter referral programs. It integrates with 20+ email platforms and handles all the tracking and reward automation.
How it works:
- Each subscriber gets a unique referral link
- They share it. When friends sign up, SparkLoop tracks it
- At milestone thresholds, rewards unlock automatically
- You set the milestones and rewards
Example milestone structure:
| Referrals | Reward |
|---|---|
| 1 | Early access to next issue |
| 3 | Free month of Pro |
| 5 | Exclusive “Top 10 AI Tools” PDF |
| 10 | Free annual Pro subscription |
| 25 | 1:1 call with you |
SparkLoop also runs a partner network. Other newsletters pay you $1-3 per subscriber when you recommend them at signup. This can offset your entire email platform cost.
Option 2: Beehiiv’s Built-In Referrals
If you’re on Beehiiv, their referral program is built in. No extra tool needed. Same milestone/reward structure, same tracking, but tighter integration with their platform.
Option 3: Build It Yourself (for custom setups)
If you’re fully custom like us, build a simple referral system:
import hashlib
import secrets
def generate_referral_code(email: str) -> str:
"""Generate a short, memorable referral code."""
hash_val = hashlib.sha256(email.encode()).hexdigest()[:8]
return f"ae-{hash_val}"
def track_referral(conn, referrer_code: str, new_subscriber_email: str):
"""Record a referral and check milestones."""
# Find referrer
referrer = conn.execute(
"SELECT email FROM subscribers WHERE referral_code = ?",
(referrer_code,)
).fetchone()
if not referrer:
return
referrer_email = referrer[0]
# Record the referral
conn.execute("""
INSERT INTO referrals (referrer_email, referred_email, created_at)
VALUES (?, ?, ?)
""", (referrer_email, new_subscriber_email,
datetime.now(timezone.utc).isoformat()))
conn.commit()
# Check milestones
count = conn.execute(
"SELECT COUNT(*) FROM referrals WHERE referrer_email = ?",
(referrer_email,)
).fetchone()[0]
milestones = {1: "early_access", 3: "free_month", 5: "bonus_pdf", 10: "free_annual"}
if count in milestones:
reward = milestones[count]
send_reward_email(referrer_email, reward, count)
conn.execute("""
INSERT INTO rewards (email, reward_type, referral_count, created_at)
VALUES (?, ?, ?, ?)
""", (referrer_email, reward, count,
datetime.now(timezone.utc).isoformat()))
conn.commit()
Referral Program Tips
- Put the referral link in every single email. Footer is fine. “Share this newsletter” with their unique link.
- The best reward isn’t a t-shirt. It’s access. Premium content, 1:1 time, exclusive access. These cost you nothing and feel valuable.
- Track your referral rate. A healthy newsletter sees 5-15% of subscribers sharing at least once. Below 5%, your content isn’t share-worthy. Fix the content first.
- Double-sided rewards work better. Give the referrer AND the new subscriber something. “You both get a free month of Pro.”
The Real Moat
It’s not the content. Anyone can summarize AI news.
It’s the training loop. Your system gets better every week because you’re rating articles. After 3 months, your curation is trained on your specific taste. No competitor can replicate that without starting from scratch.
That’s worth $9/month.