Premium Paywall with FastAPI and Razorpay Integration

Jun 14, 2026
16 min read

AI Insights

Powered by GPT-4o-mini

Verified Context: premium-paywall-with-fastapi-and-razorpay-integration
Quick Answer

Razorpay integration, premium gate middleware, early access walls, plan selection modal, webhook handling, subscription state sync, and the caching strategy that prevents race conditions.

Quick Summary

Learn how to create a premium paywall using FastAPI, Razorpay, and PostgreSQL without third-party CMS. Enhance your content monetization strategy.

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"

text
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

text
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)

text
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))

text
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();

text
    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")

text
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"]

text
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)

text
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;

text
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()

text
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.

Frequently Asked Questions

How does the premium paywall determine if a user has access to premium content?
The backend checks the user's subscription status by verifying if the subscription is active and the current period end is greater than the current time.
What happens if a user without a subscription tries to access premium content?
The backend returns a 403 error along with premium post metadata such as the title, excerpt, cover image, and tags, allowing the frontend to render the premium wall.
How is the subscription data structured in the database?
The subscription data is stored in a table named 'subscriptions' with fields for user ID, Razorpay subscription ID, plan type, status, and timestamps for the subscription period.
What is the role of Razorpay in this implementation?
Razorpay is used to handle payment processing, including creating orders and managing subscription objects for webhook handling.
What does the frontend do when it receives a 403 error for a premium post?
The frontend renders the premium wall using the post title and excerpt from the 403 error details, informing the user about the premium content they are missing.

Related Work

See how this thinking shows up in shipped systems.