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

  1. Add env vars:
    • LEMON_SQUEEZY_API_KEY
    • LEMON_SQUEEZY_STORE_ID
    • LEMON_SQUEEZY_WEBHOOK_SECRET
  2. Create products and variants in Lemon Squeezy.
  3. Put each variant id into your plan mapping (prefer subscription_plans.lemon_variant_id).
  4. 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

  1. User clicks Subscribe on Pricing.
  2. If logged out, send them to Sign up with:
    • variant_id=<lemon-variant>
    • redirect_to=/checkout
  3. After auth, continue to checkout for that variant_id.
  4. 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

  1. Verify signature with LEMON_SQUEEZY_WEBHOOK_SECRET.
  2. Identify the user (by email or customer id).
  3. Resolve plan by lemon_variant_idsubscription_plans.id.
  4. Upsert/update subscriptions:
    • subscription_plan_id
    • lemon_customer_id, lemon_subscription_id
    • status (active | canceled | past_due | expired)
    • expires_at (if present in payload)
  5. 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; optional lemon_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

  1. Use Lemon Squeezy test mode keys.
  2. Expose your local webhook (e.g., ngrok) to hit /api/webhooks/lemon-squeezy.
  3. Seed or set lemon_variant_id for your plans in subscription_plans.
  4. 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.