DEV Community

Cover image for How to integrate Lemon Squeezy Payments into a chrome extension with webhooks
Himanshu
Himanshu

Posted on

How to integrate Lemon Squeezy Payments into a chrome extension with webhooks

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

  1. User clicks "Upgrade to Premium" in your extension popup
  2. Extension calls your serverless function to create a checkout link
  3. Serverless function communicates with Lemon Squeezy API
  4. Your background script redirect user to Lemon Squeezy checkout page (email pre-filled)
  5. After successful payment, Lemon Squeezy sends webhook to your server
  6. Webhook handler stores subscription data in your database
  7. Done.

 

Part 1: Setting Up Lemon Squeezy

Step 1: Create Your Lemon Squeezy Account

  1. Visit https://www.lemonsqueezy.com/ and sign up for a test account
  2. Complete the onboarding process

Step 2: Create Your Product

  1. Navigate to Products in your dashboard
  2. Click New Product
  3. Create a subscription product (e.g., "Premium Plan - Monthly")
  4. Set your pricing (e.g., $9.99/month)
  5. After creating the product, click on it to view details
  6. 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

Step 3: Generate API Key

  1. Go to SettingsAPI
  2. Click Create API Key
  3. Give it a name (e.g., "Chrome Extension API")
  4. Copy the API key immediately (you won't see it again)

Step 4: Get Your Store ID

  1. Go to SettingsStores
  2. You'll see your store with a number like #123456
  3. 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

Enter fullscreen mode Exit fullscreen mode

Important Notes:

  • Use the Variant ID, not the Product ID
  • NEXT_PUBLIC_CHECKOUT_SUCCESS_URL is where users land after payment (optional)
  • Keep the LEMONSQUEEZY_API_KEY and WEBHOOK_SECRET secure (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):

  1. Generate checkout links
  2. Handle webhook events

Step 1: Install Dependencies

npm install @lemonsqueezy/lemonsqueezy.js
Enter fullscreen mode Exit fullscreen mode

(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;
    },
  });
}

Enter fullscreen mode Exit fullscreen mode

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' },
      }
    );
  }
}

Enter fullscreen mode Exit fullscreen mode

What this does:

  • Accepts POST requests with productId and optional userEmail
  • 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,
  });
}

Enter fullscreen mode Exit fullscreen mode
# .env.local (your extension, not serverless function)

WXT_LEMONSQUEEZY_PRODUCT_ID=your_variant_id_here  # pro plan
Enter fullscreen mode Exit fullscreen mode

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
});

Enter fullscreen mode Exit fullscreen mode

How it works:

  1. Listens for 'checkout' messages from popup or content scripts
  2. Calls your serverless function to generate checkout URL
  3. Opens new tab with the checkout page
  4. 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>
  );
}

Enter fullscreen mode Exit fullscreen mode

User Flow:

  1. User clicks "Get Premium Now"
  2. Message sent to background script
  3. Checkout URL generated with pre-filled email
  4. User redirected to Lemon Squeezy checkout
  5. User completes payment
  6. 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);

Enter fullscreen mode Exit fullscreen mode

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;

Enter fullscreen mode Exit fullscreen mode

Add to .env.local:

NEXT_PUBLIC_SUPABASE_URL=your_supabase_url
SUPABASE_SERVICE_ROLE_KEY=your_service_role_key
Enter fullscreen mode Exit fullscreen mode

 

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

Enter fullscreen mode Exit fullscreen mode

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 }
    );
  }
}

Enter fullscreen mode Exit fullscreen mode

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"
  }
}

Enter fullscreen mode Exit fullscreen mode

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

  1. Deploy your function to Vercel first
  2. Go to Lemon Squeezy Dashboard → SettingsWebhooks
  3. Click + to add a new webhook
  4. Set URL: https://your-app.vercel.app/api/webhooks/lemon-squeezy
  5. Paste your webhook secret (the one you created in your .env file)
  6. Select events to listen for:
    • subscription_created
    • subscription_updated
    • subscription_cancelled
    • subscription_resumed
    • subscription_paused
    • subscription_expired
  7. 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 }
    );
  }
}

Enter fullscreen mode Exit fullscreen mode

 

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)