Premium Paywall Without a Third-Party CMS
This blog has premium content — posts that require an active subscription to read. No WordPress plugins, no Ghost subscription service, no third-party. Everything is handled through Razorpay, FastAPI middleware, and PostgreSQL.
Data Model
`python class Subscription(Base): tablename = "subscriptions"
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"), unique=True)
razorpay_subscription_id: Mapped[str | None] = mapped_column(unique=True)
razorpay_customer_id: Mapped[str | None]
plan: Mapped[str] # "quarterly"
status: Mapped[str] # "active" | "past_due" | "canceled" | "expired"
current_period_start: Mapped[datetime | None]
current_period_end: Mapped[datetime | None]
canceled_at: Mapped[datetime | None]
created_at: Mapped[datetime] = mapped_column(default=func.now())`
One subscription per user. The azorpay_subscription_id links to Razorpay's subscription object for webhook handling.
The Premium Gate Middleware
When a premium post is requested, the backend checks subscription status:
`python async def check_premium_access(post_id: str, user: User | None) -> bool: if not user: return False
if user.is_premium:
return True
sub = await db.execute(
select(Subscription).where(
Subscription.user_id == user.id,
Subscription.status == "active",
Subscription.current_period_end > func.now(),
)
)
return sub.scalar_one_or_none() is not None`
If the user doesn't have access, the backend returns a 403 with premium post metadata (title, excerpt, cover image, tags) so the frontend can render the premium wall:
`python @router.get("/posts/{slug}") async def get_post(slug: str, user: User = Depends(get_optional_user)): post = await posts_service.get_by_slug(slug)
if post.is_premium and not await check_premium_access(post.id, user):
raise HTTPException(
status_code=403,
detail={
"error": "premium_required",
"post_title": post.title,
"post_excerpt": post.excerpt,
"post_cover_image": post.cover_image,
"post_tags": [{"name": t.name} for t in post.tags],
"post_read_time": post.read_time,
"post_published_at": post.published_at.isoformat(),
}
)
return post`
The Premium Wall Component
The frontend catches the 403 and renders the premium wall:
sx function PremiumPostWall({ slug, error }: { slug: string; error: PremiumError }) { return ( <div className="premium-wall max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-16 text-center"> <PremiumBadge className="mx-auto mb-6" /> <h1 className="font-display font-bold text-3xl sm:text-5xl text-white mb-4"> {error.detail.post_title} </h1> <p className="text-lg text-white/60 mb-8 max-w-xl mx-auto"> This tutorial is available to premium members. </p> <div className="flex items-center justify-center gap-4"> <UpgradeToPremiumButton /> <span className="text-sm text-white/30">or</span> <Button variant="secondary" onClick={() => router.push("/register")}> Create Free Account </Button> </div> </div> ); }
The premium wall renders the post title and excerpt (from the 403 detail) so the user knows what they're missing. The full content is never sent.
Razorpay Checkout Flow
When the user clicks "Upgrade to Premium," the frontend calls the backend to create a Razorpay order:
`python @router.post("/payments/create-order") async def create_order(user: User = Depends(get_current_user)): import razorpay client = razorpay.Client(auth=(settings.RAZORPAY_KEY_ID, settings.RAZORPAY_KEY_SECRET))
order = client.order.create({
"amount": settings.PREMIUM_PRICE_INR, # ₹1,000 in paise
"currency": settings.PREMIUM_CURRENCY, # INR
"receipt": f"sub_{user.id}",
"notes": {"user_id": str(user.id)},
})
return {
"order_id": order["id"],
"amount": order["amount"],
"currency": order["currency"],
"key_id": settings.RAZORPAY_KEY_ID,
"name": settings.RESEND_FROM_NAME,
"prefill": {"email": user.email, "contact": user.phone or ""},
}`
The frontend then opens Razorpay's checkout modal:
` sx function UpgradeToPremiumButton({ plan, onSuccess }: Props) { const handleUpgrade = async () => { const { order_id, amount, currency, key_id, name, prefill } = await paymentsApi.createOrder();
const options = {
key: key_id,
amount: amount,
currency: currency,
name: name,
order_id: order_id,
prefill: prefill,
handler: function (response: any) {
paymentsApi.verifyPayment({
razorpay_order_id: response.razorpay_order_id,
razorpay_payment_id: response.razorpay_payment_id,
razorpay_signature: response.razorpay_signature,
}).then(onSuccess);
},
modal: {
ondismiss: () => console.log("Payment cancelled"),
},
};
const razorpay = new (window as any).Razorpay(options);
razorpay.open();
};
return (
<Button onClick={handleUpgrade} size="lg" className="px-8">
Upgrade to Premium — ₹999/quarter
</Button>
);} `
Webhook Handling
Razorpay sends webhook events for subscription changes. The endpoint is idempotent:
`python @router.post("/payments/webhook/razorpay") async def razorpay_webhook(request: Request): payload = await request.body() signature = request.headers.get("X-Razorpay-Signature")
import razorpay
client = razorpay.Client(auth=(settings.RAZORPAY_KEY_ID, settings.RAZORPAY_KEY_SECRET))
try:
client.utility.verify_webhook_signature(
payload.decode(), signature, settings.RAZORPAY_WEBHOOK_SECRET
)
except razorpay.errors.SignatureVerificationError:
raise HTTPException(status_code=400, detail="Invalid signature")
event = json.loads(payload)
if event["event"] == "order.paid":
await handle_order_paid(event["payload"])
elif event["event"] == "subscription.charged":
await handle_subscription_charged(event["payload"])
elif event["event"] == "subscription.cancelled":
await handle_subscription_cancelled(event["payload"])
return {"status": "ok"}`
Each handler updates the database:
`python async def handle_order_paid(payload): order = payload["order"]["entity"] user_id = order["notes"]["user_id"]
user = await db.get(User, user_id)
sub = Subscription(
user_id=user_id,
razorpay_subscription_id=order.get("subscription_id"),
razorpay_customer_id=order.get("customer_id"),
plan="quarterly",
status="active",
current_period_start=datetime.fromtimestamp(order["created_at"]),
current_period_end=datetime.fromtimestamp(order["created_at"]) + timedelta(days=90),
)
db.add(sub)
await db.commit()`
Early Access System
Posts can also be in "early access" — available to all registered users before the public release. This is separate from the premium system:
`python @router.get("/posts/{slug}") async def get_post(slug: str, user: User = Depends(get_optional_user)): post = await posts_service.get_by_slug(slug)
if post.early_access_until and post.early_access_until > datetime.utcnow():
if not user:
raise HTTPException(
status_code=403,
detail={
"error": "early_access_only",
"post_title": post.title,
"post_excerpt": post.excerpt,
"early_access_until": post.early_access_until.isoformat(),
"hours_remaining": int((post.early_access_until - datetime.utcnow()).total_seconds() / 3600),
}
)
...`
The early access wall renders differently — it encourages registration rather than payment:
` sx function EarlyAccessWall({ slug, error }: Props) { const hoursLeft = error.detail.hours_remaining;
return (
<div className="card p-8 text-center max-w-lg mx-auto">
<Clock className="mx-auto mb-4 text-amber-500" size={32} />
<h2 className="font-display font-bold text-xl mb-2">Early Access</h2>
<p className="text-sm text-white/60 mb-2">
This post is in early access for registered users.
</p>
<p className="text-xs text-amber-500 mb-6">
Public release in ~{hoursLeft} hours
</p>
<Button onClick={() => router.push("/register")}>
Create Free Account to Read
</Button>
</div>
);} `
Caching Subscription Status
Subscription status is cached in Redis with a 5-minute TTL:
`python async def get_subscription_status(user_id: str) -> str: cache_key = f"subscription:{user_id}" cached = await redis.get(cache_key) if cached: return cached.decode()
sub = await db.execute(
select(Subscription).where(
Subscription.user_id == user_id,
Subscription.status == "active",
)
)
sub = sub.scalar_one_or_none()
status = "premium" if sub and sub.current_period_end > datetime.utcnow() else "free"
await redis.setex(cache_key, 300, status)
return status`
The cache is invalidated on webhook events:
python async def invalidate_subscription_cache(user_id: str): await redis.delete(f"subscription:{user_id}")
This prevents race conditions where a user's subscription expires but the cache still shows "premium" for up to 5 minutes.
What's Next
The next post covers running Python in the browser — Pyodide WebAssembly runner, Monaco editor, xterm terminal, and the filesystem sandbox.
Built with FastAPI, Razorpay, Redis caching, and zero third-party CMS.
