Overview
aSaaSin uses Lemon Squeezy for paid subscriptions. Plans are defined in your database, pricing is public and read-only, and a webhook keeps user subscription status in sync after checkout.
Setup
- Add env vars:
LEMON_SQUEEZY_API_KEY
LEMON_SQUEEZY_STORE_ID
LEMON_SQUEEZY_WEBHOOK_SECRET
- Create products and variants in Lemon Squeezy.
- Put each variant id into your plan mapping (prefer
subscription_plans.lemon_variant_id
). - Deploy the webhook at
/api/webhooks/lemon-squeezy
.
Keep secrets on the server only. Never expose the API key or webhook secret to the client.
Plans mapping
Plans live in subscription_plans
with (name, billing_cycle)
unique. Store lemon_variant_id
per plan and capability flags in features
JSONB (e.g., maxProjects
, maxApiTokens
). The webhook uses lemon_variant_id
to resolve the internal plan.
Free plan is handled in-app (no Lemon Squeezy checkout).
Subscribe flow
- User clicks Subscribe on Pricing.
- If logged out, send them to Sign up with:
variant_id=<lemon-variant>
redirect_to=/checkout
- After auth, continue to checkout for that
variant_id
. - If they switch between Sign in/Sign up, keep both params in the URL.
Create checkout
A server route receives variant_id
, creates a Lemon Squeezy checkout, and returns a redirect URL. Call it from your Pricing button or from /checkout
after auth.
// app/api/lemonsqueezy/checkout/route.ts (simplified)
export async function POST(req: Request) {
const { variant_id } = await req.json()
// call Lemon Squeezy API with API key + store id
// create checkout and return its URL
return Response.json({ url: 'https://checkout.lemonsqueezy.com/...' })
}
Webhook sync
- Verify signature with
LEMON_SQUEEZY_WEBHOOK_SECRET
. - Identify the user (by email or customer id).
- Resolve plan by
lemon_variant_id
→subscription_plans.id
. - Upsert/update
subscriptions
:subscription_plan_id
lemon_customer_id
,lemon_subscription_id
status
(active | canceled | past_due | expired
)expires_at
(if present in payload)
- Return 200 and ignore duplicate deliveries. Database constraints help ensure idempotency.
Webhook uses the Supabase service role on the server. Do not expose it to the client.
// app/api/webhooks/lemon-squeezy/route.ts (shape only)
export async function POST(req: Request) {
// 1) verify signature
// 2) parse event
// 3) find user + map variant -> plan
// 4) upsert subscriptions row
return new Response(null, { status: 200 })
}
Data model
subscription_plans
: public read-only;features
JSONB for capabilities; optionallemon_variant_id
.subscriptions
: one per user; links to a plan; stores Lemon Squeezy ids and status; written by the webhook.- See the Database page for concise schemas and RLS notes.
Security
- Plans are public read-only (safe to show pricing).
- Subscriptions are protected by RLS; users can read/update only their own row.
- Webhook and checkout creation run server-side using secrets.
- Auth and dashboard routes are disallowed in
robots.ts
.
Never log raw webhook payloads with secrets enabled in production.
Local development
- Use Lemon Squeezy test mode keys.
- Expose your local webhook (e.g., ngrok) to hit
/api/webhooks/lemon-squeezy
. - Seed or set
lemon_variant_id
for your plans insubscription_plans
. - Test the full subscribe flow with
variant_id
+redirect_to
.
Confirm redirect URLs in Lemon Squeezy and in Supabase Auth for localhost and production.
Notes & extensions
- Trials, coupons, and proration can be added later in the checkout request.
- A customer billing portal is not included by default.
- Consider surfacing current plan and renewal info in Dashboard → Billing.