# Implementing a Premium Paywall with FastAPI and Razorpay
URL: https://madhudadi.in/blog/posts/premium-paywall-with-fastapi-and-razorpay-integration
Published: 2026-06-14
Tags: python, Architecture, FastAPI, Production
Read time: 16 min
Difficulty: advanced
> Razorpay integration, premium gate middleware, early access walls, plan selection modal, webhook handling, subscription state sync, and the caching strategy that prevents race conditions.# 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 (
{error.detail.post_title}
This tutorial is available to premium members.
or
);
}
`
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 (
);
}
`
---
## 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 (
Early Access
This post is in early access for registered users.
Public release in ~{hoursLeft} hours
);
}
`
---
## 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.*