I’ll show how i implement Lemon Squeezy monthly subscription and one-time payment in my browser extension. and how I handle Lemon Squeezy payment webhook events.
Quick note: this code comes from my extension boilerplate, extFast. if you wanna skip adding subscriptions, auth, feedback form, landing page & 1 billion other boring stuff, clone it here
or let's code it…
TL;DR
- User clicks "Upgrade to Premium" in your extension popup
- Extension calls your serverless function to create a checkout link
- Serverless function communicates with Lemon Squeezy API
- Your background script redirect user to Lemon Squeezy checkout page (email pre-filled)
- After successful payment, Lemon Squeezy sends webhook to your server
- Webhook handler stores subscription data in your database
- Done.
Part 1: Setting Up Lemon Squeezy
Step 1: Create Your Lemon Squeezy Account
- Visit https://www.lemonsqueezy.com/ and sign up for a test account
- Complete the onboarding process
Step 2: Create Your Product
- Navigate to Products in your dashboard
- Click New Product
- Create a subscription product (e.g., "Premium Plan - Monthly")
- Set your pricing (e.g., $9.99/month)
- After creating the product, click on it to view details
-
Important: Copy the Variant ID (not the Product
ID)- The Variant ID looks like:
123456 - You'll need this for creating checkout links in you chrome extension
- The Variant ID looks like:
Step 3: Generate API Key
- Go to Settings → API
- Click Create API Key
- Give it a name (e.g., "Chrome Extension API")
- Copy the API key immediately (you won't see it again)
Step 4: Get Your Store ID
- Go to Settings → Stores
- You'll see your store with a number like
#123456 - Copy only the numbers (without the
#symbol)
Step 5: Create Webhook Secret
Unlike some payment processors, Lemon Squeezy don't really give you the webhook secret token, so you need to type/create a random string in .env file.
Step 6: Configure Environment Variables
Create a .env.local file in your Next.js project:
LEMONSQUEEZY_API_KEY=your_api_key_here
LEMONSQUEEZY_STORE_ID=123456
LEMONSQUEEZY_WEBHOOK_SECRET=your_handmade_secret_token # just type any random string
NEXT_PUBLIC_CHECKOUT_SUCCESS_URL=https://yourapp.com/checkout-success
Important Notes:
- Use the Variant ID, not the Product ID
-
NEXT_PUBLIC_CHECKOUT_SUCCESS_URLis where users land after payment (optional) - Keep the
LEMONSQUEEZY_API_KEYandWEBHOOK_SECRETsecure (never in client code)
Part 2: Setting Up Your Backend (Serverless Functions)
We'll create two serverless functions (pretty much free, if you are hosting on vercel):
- Generate checkout links
- Handle webhook events
Step 1: Install Dependencies
npm install @lemonsqueezy/lemonsqueezy.js
(i wrote the code in typescript [bcz people use ts in production] but if you are a js human, you can easily convert it into javascript, just copy paste it in claude or something :)
Step 2: Create Lemon Squeezy Client
Create lib/lemonSqueezy/lemonSqueezyClient.ts:
import { lemonSqueezySetup } from "@lemonsqueezy/lemonsqueezy.js";
export function configureLemonSqueezy() {
lemonSqueezySetup({
apiKey: process.env.LEMONSQUEEZY_API_KEY!,
onError: (error) => {
console.error("Lemon Squeezy Error:", error);
throw error;
},
});
}
Step 3: Create Checkout API Endpoint
Create app/api/checkout/route.ts:
import { createCheckout } from "@lemonsqueezy/lemonsqueezy.js";
import { configureLemonSqueezy } from "@/lib/lemonSqueezy/lemonSqueezyClient";
async function createCheckoutLS(
productId: string,
userEmail?: string
) {
configureLemonSqueezy();
const storeId = process.env.LEMONSQUEEZY_STORE_ID;
if (!storeId) {
throw new Error("LEMONSQUEEZY_STORE_ID is not set");
}
const checkoutData = {
productOptions: {
redirectUrl: process.env.NEXT_PUBLIC_CHECKOUT_SUCCESS_URL,
},
checkoutData: {
email: userEmail || undefined,
},
};
const checkout = await createCheckout(
storeId,
productId,
checkoutData
);
if (checkout.error) {
throw new Error(checkout.error.message);
}
return checkout.data?.data.attributes.url;
}
export async function POST(request: Request) {
try {
const { productId, userEmail } = await request.json();
if (!productId) {
return new Response(
JSON.stringify({ error: 'Product variant ID is required' }),
{
status: 400,
headers: { 'Content-Type': 'application/json' },
}
);
}
const checkoutUrl = await createCheckoutLS(
productId,
userEmail || undefined
);
return new Response(
JSON.stringify({ checkout_url: checkoutUrl }),
{
status: 201,
headers: { 'Content-Type': 'application/json' },
}
);
} catch (error) {
console.error('Checkout creation error:', error);
return new Response(
JSON.stringify({
error: 'Failed to create checkout',
details: error instanceof Error ? error.message : 'Unknown error'
}),
{
status: 500,
headers: { 'Content-Type': 'application/json' },
}
);
}
}
What this does:
- Accepts POST requests with
productIdand optionaluserEmail - Creates a personalized checkout link via Lemon Squeezy API
- Pre-fills the user's email on the checkout page (better UX)
- Returns the checkout URL to your extension
Part 3: Building Your Chrome Extension
Step 1: Create Checkout Handler
Create src/handleCheckout.ts in your extension:
/**
* Creates a checkout URL by calling your serverless function
* @param email - User's email to pre-fill at checkout
* @param productId - Lemon Squeezy variant ID
* @returns Checkout URL or null if failed
*/
export async function createCheckoutURL(
email: string | null,
productId: string
): Promise<string | null> {
const CHECKOUT_ENDPOINT = 'https://your-app.vercel.app/api/checkout';
try {
const response = await fetch(CHECKOUT_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
productId,
userEmail: email,
}),
});
if (!response.ok) {
console.error('Checkout creation failed:', response.statusText);
return null;
}
const results = await response.json();
return results.checkout_url;
} catch (error) {
console.error('Error creating checkout URL:', error);
return null;
}
}
/**
* Initiates checkout flow by sending message to background script
* @param email - User's email
* @param productId - Product variant ID to purchase
*/
export async function invokeCheckout(email?: string, productId?: string) {
await chrome.runtime.sendMessage({
type: 'checkout',
userEmail: email,
productId: productId || import.meta.env.WXT_LEMONSQUEEZY_PRODUCT_ID,
});
}
# .env.local (your extension, not serverless function)
WXT_LEMONSQUEEZY_PRODUCT_ID=your_variant_id_here # pro plan
Step 2: Set Up Background Script
Create or update src/background.ts:
import { createCheckoutURL } from './lib/handleCheckout';
type CheckoutMessage = {
type: 'checkout';
userEmail?: string;
productId: string;
};
async function handleMessages(
message: CheckoutMessage,
sender: chrome.runtime.MessageSender,
sendResponse: (response?: any) => void
) {
if (message.type === 'checkout') {
console.log('🛒 Starting checkout flow...');
// Generate the checkout link
// productId is coming from invokeCheckout <- popup.tsx
const checkoutLink = await createCheckoutURL(
message.userEmail || null,
message.productId
);
if (!checkoutLink) {
console.error('❌ Failed to create checkout link');
sendResponse({ success: false, error: 'Failed to create checkout' });
return;
}
// Redirect user to checkout page (no manifest permission needed)
chrome.tabs.create({ url: checkoutLink }, (tab) => {
console.log('✅ Redirected to checkout:', checkoutLink);
sendResponse({ success: true, tabId: tab.id });
});
}
}
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
handleMessages(request, sender, sendResponse);
return true; // Required for async sendResponse
});
How it works:
- Listens for 'checkout' messages from popup or content scripts
- Calls your serverless function to generate checkout URL
- Opens new tab with the checkout page
- No manifest permissions needed for opening tabs
Step 3: Create Premium Upgrade UI
Create a component in your popup (src/components/PremiumCard.tsx):
import React from 'react';
import { invokeCheckout } from '../lib/handleCheckout';
export default function PremiumCard() {
async function handleUpgradeClick() {
try {
// Get user's email from your auth system or chrome.storage
await invokeCheckout('example@gmail.com');
} catch (error) {
console.error('Checkout error:', error);
}
}
return (
<div className="premium-card">
<div className="premium-header">
<h3>🚀 Upgrade to Premium</h3>
<p className="price">$9.99/month</p>
</div>
<ul className="features">
<li>✨ Unlimited AI generations</li>
<li>🎯 Advanced features</li>
<li>⚡ Priority support</li>
<li>🔒 Ad-free experience</li>
</ul>
<button
onClick={handleUpgradeClick}
className="upgrade-button"
>
{'Get Premium Now'}
</button>
</div>
);
}
User Flow:
- User clicks "Get Premium Now"
- Message sent to background script
- Checkout URL generated with pre-filled email
- User redirected to Lemon Squeezy checkout
- User completes payment
- User redirected to success page (optional)
Part 4: Database Setup (Supabase)
Step 1: Create Supabase Table
Run this SQL in your Supabase SQL Editor:
CREATE TABLE PremiumUsers (
user_email TEXT PRIMARY KEY,
subscription_status TEXT NOT NULL,
plan_type TEXT,
credits_used INTEGER DEFAULT 0,
subscribed_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
subscription_id TEXT,
customer_id TEXT
);
-- Add index for faster queries
CREATE INDEX idx_subscription_status ON PremiumUsers(subscription_status);
CREATE INDEX idx_expires_at ON PremiumUsers(expires_at);
Step 2: Set Up Supabase Client
Create lib/supabase/supabaseAdmin.ts:
import { createClient } from '@supabase/supabase-js';
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
// Use service role key for admin operations
const supabaseAdmin = createClient(supabaseUrl, supabaseServiceKey);
export default supabaseAdmin;
Add to .env.local:
NEXT_PUBLIC_SUPABASE_URL=your_supabase_url
SUPABASE_SERVICE_ROLE_KEY=your_service_role_key
Part 5: (now the most scary part) Webhooks
but they are important to store your premium users into your database and keep it in sync with lemon squeezy
Step 1: Create Type Definitions (optional)
Create app/api/webhooks/lemon-squeezy/types.ts:
export interface LemonSqueezyWebhookPayload {
meta: {
event_name: string;
custom_data?: Record<string, any>;
};
data: {
id: string;
attributes: {
user_email: string;
status: string;
variant_name: string;
created_at: string;
renews_at: string | null;
ends_at: string | null;
customer_id: number;
};
};
}
Step 2: Create Webhook Endpoint
Create app/api/webhooks/lemon-squeezy/route.ts:
import { NextRequest, NextResponse } from "next/server";
import crypto from "crypto";
import supabaseAdmin from "@/lib/supabase/supabaseAdmin";
import type { LemonSqueezyWebhookPayload } from "./types";
// Verify webhook signature to ensure it's from Lemon Squeezy
function verifyWebhookSignature(
payload: string,
signature: string,
secret: string
): boolean {
const hmac = crypto.createHmac("sha256", secret);
const digest = hmac.update(payload).digest("hex");
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(digest)
);
}
// Map Lemon Squeezy status to our internal status
function mapSubscriptionStatus(status: string): string {
const statusMap: Record<string, string> = {
active: "active",
on_trial: "trialing",
paused: "paused",
past_due: "past_due",
unpaid: "unpaid",
cancelled: "cancelled",
expired: "expired",
};
return statusMap[status] || status;
}
/// Determine plan type from variant name
function determinePlanType(
variantName: string,
customData?: Record<string, any>
): string {
if (customData?.plan) {
return customData.plan;
}
const lowerVariantName = variantName.toLowerCase();
if (lowerVariantName.includes("yearly") || lowerVariantName.includes("annual")) {
return "yearly";
}
return "monthly";
}
// Handle subscription creation, update, and cancellation
async function handleSubscriptionChange(payload: LemonSqueezyWebhookPayload) {
console.log("📦 Processing webhook:", {
event: payload.meta.event_name,
email: payload.data.attributes.user_email,
status: payload.data.attributes.status,
});
const attributes = payload.data.attributes;
const planType = determinePlanType(
attributes.variant_name,
payload.meta.custom_data
);
const { error } = await supabaseAdmin
.from("PremiumUsers")
.upsert(
{
user_email: attributes.user_email,
subscription_status: mapSubscriptionStatus(attributes.status),
subscribed_at: new Date(attributes.created_at).toISOString(),
expires_at: attributes.renews_at
? new Date(attributes.renews_at).toISOString()
: attributes.ends_at
? new Date(attributes.ends_at).toISOString()
: null,
subscription_id: payload.data.id,
customer_id: attributes.customer_id.toString(),
plan_type: planType,
},
{ onConflict: "user_email" }
);
if (error) {
console.error("❌ Database update failed:", error);
throw error;
}
console.log("✅ Updated subscription for:", attributes.user_email);
}
export async function POST(request: NextRequest) {
try {
const webhookSecret = process.env.LEMONSQUEEZY_WEBHOOK_SECRET;
if (!webhookSecret) {
console.error("❌ LEMONSQUEEZY_WEBHOOK_SECRET is not set");
return NextResponse.json(
{ error: "Webhook secret not configured" },
{ status: 500 }
);
}
// Get raw body for signature verification
const rawBody = await request.text();
const signature = request.headers.get("x-signature");
if (!signature) {
console.error("❌ No signature in webhook request");
return NextResponse.json(
{ error: "No signature provided" },
{ status: 401 }
);
}
// Verify the webhook is from Lemon Squeezy
const isValid = verifyWebhookSignature(rawBody, signature, webhookSecret);
if (!isValid) {
console.error("❌ Invalid webhook signature");
return NextResponse.json(
{ error: "Invalid signature" },
{ status: 401 }
);
}
// Parse and process the webhook
const payload: LemonSqueezyWebhookPayload = JSON.parse(rawBody);
const eventName = payload.meta.event_name;
console.log(`🔔 Webhook event: ${eventName}`);
// Handle different subscription events (you can remove some of them actually)
// i think all you need it "subscription_updated" and "subscription_created"
switch (eventName) {
case "subscription_created":
case "subscription_updated":
case "subscription_resumed":
case "subscription_unpaused":
await handleSubscriptionChange(payload);
break;
case "subscription_cancelled":
case "subscription_expired":
case "subscription_paused":
await handleSubscriptionChange(payload);
break;
case "order_created":
console.log("📦 One-time order:", payload.data.attributes.user_email);
// Handle one-time purchases if needed
break;
default:
console.log(`ℹ️ Unhandled event: ${eventName}`);
}
return NextResponse.json({ received: true });
} catch (error) {
console.error("❌ Webhook processing error:", error);
return NextResponse.json(
{
error: "Webhook processing failed",
details: error instanceof Error ? error.message : "Unknown error"
},
{ status: 500 }
);
}
}
this is how the webhook payload/object looks like for subscription_created event that lemon squeezy sent you:
{
"data": {
"id": "xxxxxx",
"type": "subscriptions",
"links": {
"self": "https://api.lemonsqueezy.com/v1/subscriptions/xxxxx"
},
"attributes": {
"urls": {
"customer_portal": "https://extfast.lemonsqueezy.com/billing?xxxxxx",
"update_payment_method": "https://extfast.lemonsqueezy.com/subscription/xxxxxx",
"customer_portal_update_subscription": "https://extfast.lemonsqueezy.com/billing/xxxxxxx"
},
"pause": null,
"status": "active",
"ends_at": null,
"order_id": xxxxx,
"store_id": xxxxxx,
"cancelled": false,
"renews_at": "2026-01-16T13:43:48.000000Z",
"test_mode": true,
"user_name": "banana man",
"card_brand": "visa",
"created_at": "2025-12-16T13:43:50.000000Z",
"product_id": xxxxx,
"updated_at": "2025-12-16T13:44:01.000000Z",
"user_email": "banana-man@extfast.com",
"variant_id": xxxxx,
"customer_id": xxxxxx,
"product_name": "Demo Subscription Product",
"variant_name": "Default",
"order_item_id": xxxxx,
"trial_ends_at": null,
"billing_anchor": 16,
"card_last_four": "4242",
"status_formatted": "Active",
"payment_processor": "stripe",
"first_subscription_item": {
"id": xxxxx,
"price_id": xxxxx,
"quantity": 1,
"created_at": "2025-12-16T13:44:02.000000Z",
"updated_at": "2025-12-16T13:44:02.000000Z",
"is_usage_based": false,
"subscription_id": xxxxx
}
},
"relationships": {
"order": {
"links": {
"self": "https://api.lemonsqueezy.com/v1/subscriptions/xxxxx/relationships/order",
"related": "https://api.lemonsqueezy.com/v1/subscriptions/xxxxx/order"
}
},
"store": {
"links": {
"self": "https://api.lemonsqueezy.com/v1/subscriptions/xxxxx/relationships/store",
"related": "https://api.lemonsqueezy.com/v1/subscriptions/xxxxx/store"
}
},
"product": {
"links": {
"self": "https://api.lemonsqueezy.com/v1/subscriptions/xxxxx/relationships/product",
"related": "https://api.lemonsqueezy.com/v1/subscriptions/xxxxxxx/product"
}
},
"variant": {
"links": {
"self": "https://api.lemonsqueezy.com/v1/subscriptions/xxxxx/relationships/variant",
"related": "https://api.lemonsqueezy.com/v1/subscriptions/xxxxx/variant"
}
},
"customer": {
"links": {
"self": "https://api.lemonsqueezy.com/v1/subscriptions/hahahahah/relationships/customer",
"related": "https://api.lemonsqueezy.com/v1/subscriptions/hahahahah/customer"
}
},
"order-item": {
"links": {
"self": "https://api.lemonsqueezy.com/v1/subscriptions/xxxxxx/relationships/order-item",
"related": "https://api.lemonsqueezy.com/v1/subscriptions/xxxxxx/order-item"
}
},
"subscription-items": {
"links": {
"self": "https://api.lemonsqueezy.com/v1/subscriptions/xxxxx/relationships/subscription-items",
"related": "https://api.lemonsqueezy.com/v1/subscriptions/xxxxx/subscription-items"
}
},
"subscription-invoices": {
"links": {
"self": "https://api.lemonsqueezy.com/v1/subscriptions/xxxxxx/relationships/subscription-invoices",
"related": "https://api.lemonsqueezy.com/v1/subscriptions/xxxxxx/subscription-invoices"
}
}
}
},
"meta": {
"test_mode": true,
"event_name": "subscription_created",
"webhook_id": "xxxxxxxx-xxxxx-xxxxx"
}
}
now to test this webhook function api/webhooks/lemon-squeezy, you need to install ngrok on you machine and create a tunnel (localhost:3000 url is not gonna work here) or simply deploy it on vercel and then test it (little bit painful i know)
Step 3: Configure Webhook in Lemon Squeezy
- Deploy your function to Vercel first
- Go to Lemon Squeezy Dashboard → Settings → Webhooks
- Click + to add a new webhook
- Set URL:
https://your-app.vercel.app/api/webhooks/lemon-squeezy - Paste your webhook secret (the one you created in your
.envfile) - Select events to listen for:
subscription_createdsubscription_updatedsubscription_cancelledsubscription_resumedsubscription_pausedsubscription_expired
- Click Save
Part 6: Verifying Premium User (optional)
In Your Serverless Functions
Create app/api/premium-feature/route.ts:
import supabaseAdmin from '@/lib/supabase/supabaseAdmin';
async function isEligibleUser(userEmail: string): Promise<boolean> {
try {
const { data, error } = await supabaseAdmin
.from('PremiumUsers')
.select('subscription_status, expires_at')
.eq('user_email', userEmail)
.single();
if (error) {
console.error('Premium check error:', error);
return false;
}
if (!data) {
return false;
}
const status = data.subscription_status.trim().toLowerCase();
const isPremiumActive = status === 'active' || status === 'trialing';
// Also check if subscription hasn't expired
if (data.expires_at) {
const expiresAt = new Date(data.expires_at);
const now = new Date();
if (now > expiresAt) {
return false;
}
}
return isPremiumActive;
} catch (error) {
console.error('Premium eligibility check failed:', error);
return false;
}
}
export async function POST(request: Request) {
try {
const { userEmail, action } = await request.json();
if (!userEmail) {
return Response.json(
{ error: 'User email required' },
{ status: 400 }
);
}
const isEligible = await isEligibleUser(userEmail);
if (!isEligible) {
return Response.json(
{ error: 'Premium subscription required' },
{ status: 403 }
);
}
// User is premium, proceed with premium feature
// Your premium feature logic here...
return Response.json({
success: true,
message: 'Premium feature executed'
});
} catch (error) {
console.error('API error:', error);
return Response.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
phew!
you_done_now
btw i assumed that you already implemented authentication (like Continue With Google) in your extension before adding stripe payments.
If you don’t know how to add google auth → checkout my boilerplate
thankyou so much for reading 💛
have a nice day
bye bye :)
Top comments (0)