I Was Scared of Free Plan Abuse, So I Wrote the Defense Code First
I Was Scared of Free Plan Abuse, So I Wrote the Defense Code First
April 2026 · Lazy Developer EP.07
Building a SaaS comes with two fears. Nobody uses it. Or everybody uses it for free. The first is a marketing problem. The second is a design problem. While building FeedMission, I decided to tackle the second one first. If free users flood in and the only thing growing is the AI API bill, there’s no way to sustain the service.
I integrated a payment system, designed the plans, and wrote the code to manage limits. Then I changed the pricing 3 times over 4 hours. This post is a record of that process.
– FREE (50/month, no AI) / STARTER $9 (200, AI included) / PRO $19 (unlimited)
– LemonSqueezy payment integration — HMAC-SHA256 webhook verification, 4 subscription events handled
– Widget auto-hides when monthly feedback limit is exceeded (API pre-check)
– FREE to paid: redirect to checkout URL / paid to paid: variant change via LemonSqueezy API
– Pivoted pricing from KRW to USD (USD was the right call for a global service)
– On cancellation/expiry: auto-downgrade to FREE — existing data preserved, only new analysis blocked
How to Structure the Plans
I compared plans from Canny, Nolt, and Fider to decide. The key principle was “the free tier has to be usable enough, but the paid tier needs to offer clearly different value.” If the free plan is too restrictive, nobody signs up. If it’s too generous, nobody upgrades.
Voting, roadmap, and changelog are available on the free plan. Those are core features. The AI-powered features are the paid conversion point. As feedback piles up and the moment comes when “manually sorting all this is a pain,” that’s when users upgrade to STARTER.
FREE: { maxProjects: 1, maxFeedbackPerMonth: 50,
aiClustering: false, emailNotifications: false }
STARTER: { maxProjects: 3, maxFeedbackPerMonth: 200,
aiClustering: true, emailNotifications: true }
PRO: { maxProjects: Infinity, maxFeedbackPerMonth: Infinity,
aiClustering: true, insights: true, dailyDigest: true }
I Changed the Price 3 Times in 4 Hours
I started with Korean Won. STARTER 9,000 KRW, PRO 19,000 KRW. But FeedMission is a global service. The widget supports 6 platforms and has multi-language support (ko/en). KRW pricing didn’t fit.
I pivoted to USD within 4 minutes. STARTER $9, PRO $19. Then 30 minutes later, I restructured the feature matrix for each plan. Which features go in which tier. This decision directly maps to code, so once you commit, you’re editing multiple files. That’s why I put all plan definitions in a single plan-limits.ts file and made everything else reference just that file.
Why LemonSqueezy Instead of Stripe
When it comes to payments, Stripe is the first thing that comes to mind. But Korean businesses can’t use Stripe directly. As of April 2026, Stripe supports business registration in 46 countries, and South Korea isn’t on the list. Under Korean financial regulations, operating a payment gateway (PG) requires registration with the financial authorities, and Stripe has never established a Korean subsidiary or passed that regulatory process. In October 2025, news spread that “Stripe now supports Korean payments,” but that meant overseas businesses could accept KRW payments from Korean customers — not that Korean businesses could create Stripe accounts.
There are workarounds. You can set up a US entity through Stripe Atlas or open an account through a Singapore entity. But I didn’t want to go as far as incorporating a foreign company as an indie developer.
LemonSqueezy operates on a MoR (Merchant of Record) model. In simple terms, LemonSqueezy acts as the “legal seller” on your behalf. When a customer pays, LemonSqueezy collects the money, handles VAT and sales tax for 100+ countries, deducts the fee, and sends the rest to you. Korean businesses can sign up, and you don’t have to file sales tax separately (sales tax only — you still have to file income tax yourself). Stripe acquired LemonSqueezy in 2024, but the service continues to operate as before.
The fee is 5% + $0.50 per transaction. More expensive than Stripe (2.9% + $0.30). But when you factor in the cost of setting up a foreign entity or the overhead of handling taxes yourself, the math shows MoR is more cost-effective when annual revenue is under approximately $100K. FeedMission isn’t at that scale yet, so LemonSqueezy was the right call.
Stripe — Korean businesses cannot sign up directly. Requires a foreign entity (Atlas, etc.).
LemonSqueezy — MoR model. Korean businesses can sign up. Tax handling included. 5% + $0.50.
Paddle — MoR model. Similar to LemonSqueezy. Simpler fee structure.
Under ~$100K annual revenue, MoR is more practical. Above that, foreign entity + Stripe becomes the tipping point.
LemonSqueezy Webhooks — How to Receive Payment Events
When a user completes payment, LemonSqueezy sends a webhook to my server. An event saying “this user subscribed to the STARTER plan.” I receive it and reflect it in the database.
subscription_created → Subscription created (upsert)
subscription_updated → Plan change reflected
subscription_cancelled → Cancelled → downgrade to FREE
subscription_expired → Expired → downgrade to FREE
Subscription creation and updates are handled with Prisma’s upsert. If it doesn’t exist, create it. If it does, update it. On cancellation and expiry, the user’s plan is switched to FREE. Existing data stays intact. Feedback, clusters, roadmap — all preserved. Only new AI analysis is blocked.
Idempotency is critical for webhooks. The same event can arrive multiple times. If LemonSqueezy doesn’t get a response from my server, it resends. We compare the existing lsSubscriptionId against the event ID to ignore already-processed events.
Which plan was subscribed is determined by the variant ID. When you create a product in LemonSqueezy, each plan becomes a variant. If it matches the PRO variant ID, it’s PRO. Otherwise, STARTER. Simple logic, but managed through environment variables to avoid hardcoding.
When the Limit Is Exceeded, the Widget Disappears
The FREE plan allows 50 feedback submissions per month. What happens when it exceeds 50? Two things need to be blocked simultaneously. The API returns 429, and the widget itself is hidden.
If the widget is visible on screen but submission returns an error, the user experience is bad. The right approach is to check the limit before the widget renders. If it’s already exceeded, don’t show it at all.
const monthStart = new Date()
monthStart.setDate(1) // based on the 1st of each month
monthStart.setHours(0, 0, 0, 0)
const count = await prisma.feedback.count({
where: { projectId, createdAt: { gte: monthStart } }
})
return { available: count < limit }
When the widget loads, it calls /api/feedback/status. If available: false comes back, the widget hides itself. The end user doesn’t know why the widget isn’t showing, but the project owner sees a “Monthly limit exceeded” notice on the dashboard. The same check was added to the iOS SDK. When the limit is exceeded, the widget is hidden in SwiftUI.
Plan Upgrades and Downgrades
There are two paths. Going from FREE to paid, and switching between paid plans.
return { action: ‘checkout’, url: checkoutUrl }
// paid to paid: change variant via LemonSqueezy API
await fetch(`https://api.lemonsqueezy.com/v1/subscriptions/${id}`, {
method: ‘PATCH’,
body: { data: { attributes: { variant_id: newVariant } } }
})
When going from FREE to paid, the user is redirected to the LemonSqueezy checkout page. I used the server-side redirect built in EP.06. When switching between paid plans, the LemonSqueezy API is called directly to change the variant. Upgrading from STARTER to PRO automatically applies proration.
When the API call succeeds, the database is updated immediately. So the user can start using the new plan’s features before the webhook even arrives. The webhook comes later to sync the state one more time.
CORS Headers Are Needed on Error Responses Too
The widget calls my API from a different domain. CORS is required. Here’s what’s easy to miss: not just 200 responses, but error responses (400, 429, 500) also need CORS headers. Without them, the browser blocks the error message itself, so the widget can’t even tell “why it failed.”
const corsHeaders = {
‘Access-Control-Allow-Origin’: ‘*’,
‘Access-Control-Allow-Methods’: ‘POST, OPTIONS’,
‘Access-Control-Allow-Headers’: ‘Content-Type’,
}
// CORS headers required even on 429 limit exceeded
return NextResponse.json(
{ error: ‘Monthly limit exceeded’ },
{ status: 429, headers: corsHeaders }
)
Does Data Disappear on Downgrade?
No. Even if you go from PRO down to FREE, feedback, clusters, roadmap, and changelog all remain. Read-only access. Only new AI analysis (clustering, insights) is blocked. The checkPlanFeature() function checks the plan at the API level for each feature. If the feature is inactive, it returns 403.
This matters for an important reason. If data gets wiped on downgrade, users have no reason to upgrade again. When data stays, the motivation to “use AI analysis again” remains.