# 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.*