Here’s how i implement stripe monthly subscription and one-time payment in my browser extension. I will also show you how i handle stripe webhook events.
Quick note: this code comes from my extension boilerplate, extFast. if you wanna skip implementing subscriptions, auth, feedback form, landing page and 1 billion other boring stuff, clone it here
or let's code it….
Overview
Here's how the payment flow works:
- User clicks "Upgrade to Premium" in your extension popup
- Extension calls your serverless function to generate a Stripe checkout URL
- Background script redirects user to the Stripe checkout page
- User completes payment on Stripe's secure checkout
- Stripe sends a webhook to your serverless function
- Your webhook handler saves the subscription data to your database
- Extension verifies premium status when user tries to access premium features
Part 1: Setting Up Stripe
Step 1: Create a Stripe Sandbox Account
For development and testing, start with a Stripe sandbox:
- Visit Stripe Sandbox Documentation
- Create a new sandbox account
Step 2: Create Your Product
- Go to Products in your Stripe dashboard
- Click Add Product
- Enter product details:
- Name: "Premium Subscription"
- Description: "Monthly premium access"
- Pricing: $9.99/month (or your chosen price)
- Copy the Price ID (starts with
price_) - you'll need this later
Step 3: Get Your API Keys
- Navigate to Developers → API Keys or visit this link
- Copy your Secret Key (starts with
sk_test_for sandbox) - Store this securely - never commit it to your repository
Reference: Stripe API Keys Documentation
Part 2: Creating Serverless Functions
We'll create two serverless functions:
- Generate checkout URLs
- Handle Stripe webhooks
Setting Up Your Project
First, install the Stripe SDK:
npm install stripe --save
Reference: Stripe SDK Docs
i wrote the code in typescript but you can easily convert it into javascript, just use claude or something.
Initialize Stripe Client
Create a file to initialize your Stripe client:
// lib/stripe/stripeClient.ts
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2025-11-17.clover',
typescript: true,
});
export default stripe;
STRIPE_SECRET_KEY is coming from .env i am very sure you already know how to put keys in env file
Function 1: Create Checkout Links
This serverless function generates personalized checkout URLs with the user's email pre-filled:
// app/api/checkout/route.ts
import stripe from '@/lib/stripe/stripeClient';
/// typescript stuff (ignore it)
interface CreateCheckoutParams {
user?: {
customerId?: string;
email?: string;
};
mode: 'payment' | 'subscription';
clientReferenceId?: string;
priceId: string;
couponId?: string | null;
}
export const createCheckoutStripe = async ({
user,
mode,
clientReferenceId,
priceId,
couponId,
}: CreateCheckoutParams): Promise<string | null> => {
try {
const extraParams: {
customer?: string;
customer_creation?: 'always';
customer_email?: string;
invoice_creation?: { enabled: boolean };
payment_intent_data?: { setup_future_usage: 'on_session' };
tax_id_collection?: { enabled: boolean };
} = {};
// Handle existing customers vs new customers
if (user?.customerId) {
extraParams.customer = user.customerId;
} else {
if (mode === 'payment') {
extraParams.customer_creation = 'always';
extraParams.invoice_creation = { enabled: true };
extraParams.payment_intent_data = { setup_future_usage: 'on_session' };
}
if (user?.email) {
extraParams.customer_email = user.email;
}
extraParams.tax_id_collection = { enabled: true };
}
const stripeSession = await stripe.checkout.sessions.create({
mode,
allow_promotion_codes: true,
client_reference_id: clientReferenceId,
line_items: [
{
price: priceId,
quantity: 1,
},
],
discounts: couponId
? [
{
coupon: couponId,
},
]
: [],
success_url: 'https://example.com/checkout-success',
cancel_url: 'https://example.com/home',
locale: 'auto',
...extraParams,
});
return stripeSession.url;
} catch (e) {
console.error('Error creating checkout session:', e);
return null;
}
};
export async function POST(request: Request) {
try {
const { id, userEmail, mode } = await request.json();
// Validation
if (!id) {
return new Response(JSON.stringify({ error: 'Price ID is missing' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
}
if (!mode) {
return new Response(
JSON.stringify({
error: "Mode is missing (choose 'payment' or 'subscription')",
}),
{
status: 400,
headers: { 'Content-Type': 'application/json' },
}
);
}
const checkoutUrl = await createCheckoutStripe({
user: {
email: userEmail,
},
mode,
priceId: id,
});
if (!checkoutUrl) {
return new Response(JSON.stringify({ error: 'Failed to create url' }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
}
return new Response(JSON.stringify({ checkout_url: checkoutUrl }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
} catch (error) {
console.error('Checkout error:', error);
return new Response(JSON.stringify({ error: 'Internal server error' }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
}
}
Alternative Approach: If you don't need to pre-fill the user's email, you can generate a fixed checkout link directly from the Stripe dashboard (Products → Checkout Links) and skip this function entirely.
Part 3: Building the Chrome Extension
Extension File Structure
your-extension/
├── manifest.json
├── popup/
│ ├── popup.html
│ └── popup.tsx
├── background/
│ └── background.ts
├── utils/
│ └── handleCheckout.ts
└── components/
└── PremiumCard.tsx
Step 1: Checkout Handler
Create a utility file to handle checkout operations:
// utils/handleCheckout.ts
/**
* Generates a Stripe checkout URL by calling your serverless function
* This pre-fills the user's email on the checkout page
*/
export async function createCheckoutURL(
email: string | null,
priceId: string,
mode: 'payment' | 'subscription' = 'subscription'
): Promise<string | null> {
// Replace with your actual Vercel endpoint
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({
id: priceId,
userEmail: email || null,
mode: mode,
}),
});
if (!response.ok) {
console.error('Failed to create checkout URL:', 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 the checkout flow by sending a message to the background script
* Call this function when the user clicks "Upgrade to Premium"
*/
export async function invokeCheckout(
email: string | undefined,
priceId: string
): Promise<void> {
try {
await chrome.runtime.sendMessage({
type: 'checkout',
userEmail: email,
priceId: priceId,
});
} catch (error) {
console.error('Error invoking checkout:', error);
}
}
Step 2: Background Script
The background script handles messages and redirects users to checkout:
// background/background.ts
import { createCheckoutURL } from '../utils/handleCheckout';
/// some typescript stuff (ignore it)
type CheckoutMessage = {
type: 'checkout';
userEmail?: string;
priceId: string;
};
async function handleMessages(
message: CheckoutMessage,
sender: chrome.runtime.MessageSender,
sendResponse: (response?: any) => void
) {
if (message.type === 'checkout') {
try {
console.log('🚀 Starting checkout process...');
// Generate the checkout link
// passing the string 'subscription' or 'payment' is important
const checkoutLink = await createCheckoutURL(
message.userEmail || null,
message.priceId,
'subscription'
);
if (!checkoutLink) {
console.error('❌ Failed to create checkout link');
return;
}
console.log('✅ Checkout link created successfully');
// Redirect user to Stripe checkout (no manifest permission needed)
chrome.tabs.create({ url: checkoutLink }, function (tab) {
console.log('📄 Opened checkout page:', checkoutLink);
});
} catch (error) {
console.error('❌ Error in checkout flow:', error);
}
}
}
// Listen for messages from popup or content scripts
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
handleMessages(request, sender, sendResponse);
return true; // Keep the message channel open for async responses
});
Step 3: “Upgrade Card” Component
Let’s create premium upgrade card widget for your popup:
// components/PremiumCard.tsx
import React from 'react';
import { invokeCheckout } from '../utils/handleCheckout';
interface PremiumCardProps {
userEmail?: string;
}
export default function PremiumCard({ userEmail }: PremiumCardProps) {
// Replace with your actual Stripe Price ID
const PRICE_ID = 'price_1234567890abcdef';
async function handleUpgradeClick() {
try {
await invokeCheckout(userEmail, PRICE_ID);
} catch (error) {
console.error('Error starting checkout:', error);
alert('Failed to start checkout. Please try again.');
}
}
return (
<div className="premium-card">
<div className="premium-badge">⭐ Premium</div>
<h3>Upgrade to Premium</h3>
<p className="subtitle">Unlock all features...</p>
<ul className="features-list">
<li>✨ Unlimited AI generations</li>
<li>🚀 Priority processing speed</li>
<li>💎 Advanced customization options</li>
<li>🎯 Priority customer support</li>
<li>🔄 Cancel anytime</li>
</ul>
<div className="pricing">
<span className="price">$9.99</span>
<span className="period">/month</span>
</div>
<button
className="upgrade-button"
onClick={handleUpgradeClick}
>
Get Premium Now
</button>
</div>
);
}
Part 4: (now the most scary part) Webhooks
Webhooks are important for keeping your database in sync with Stripe.
Setting Up for Local Development (see docs)
To test webhooks locally, you need the Stripe CLI:
- Install Stripe CLI: Follow the installation guide
-
Login to Stripe CLI:
stripe login -
Forward webhooks to your local server:
stripe listen --forward-to localhost:3000/api/webhook/stripe -
Copy the webhook secret: The CLI will generate a webhook signing secret (starts with
whsec_). Add this to your.envfile, like this:
STRIPE_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxx
Reference: Stripe Webhooks Documentation
Database Setup (Supabase)
First, create a table to store premium users. 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
);
it will look something like this:
Initialize Supabase Admin Client
Create a Supabase client with admin privileges, do not use this in your chrome extension, only put it in server side, for chrome extension create another supabase client with anon key or publishable key, this is the server-side:
// 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!;
const supabaseAdmin = createClient(supabaseUrl, supabaseServiceKey, {
auth: {
autoRefreshToken: false,
persistSession: false
}
});
export default supabaseAdmin;
Function 2: Webhook Handler
This function processes all Stripe events and updates your database, it handles both one-time payment webhooks and subscription webhooks from stripe:
// app/api/webhook/stripe/route.ts
import { NextRequest, NextResponse } from 'next/server';
import Stripe from 'stripe';
import supabaseAdmin from '@/lib/supabase/supabaseAdmin';
import stripe from '@/lib/stripe/stripeClient';
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;
/**
* Handles subscription creation and updates
*/
async function handleSubscriptionChange(subscription: Stripe.Subscription) {
const customerId = subscription.customer as string;
const customer = await stripe.customers.retrieve(customerId) as Stripe.Customer;
if (!customer.email) {
console.error('❌ Customer email is missing');
throw new Error('Customer email is required');
}
// Convert Stripe timestamps (seconds) to ISO strings
const subscribedAt = subscription.current_period_start
? new Date(subscription.current_period_start * 1000).toISOString()
: new Date().toISOString();
const expiresAt = subscription.current_period_end
? new Date(subscription.current_period_end * 1000).toISOString()
: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString();
const dataToInsert = {
user_email: customer.email,
subscription_status: subscription.status,
subscribed_at: subscribedAt,
expires_at: expiresAt,
subscription_id: subscription.id,
customer_id: customerId,
plan_type: subscription.items.data[0]?.price?.recurring?.interval || 'monthly',
};
// Upsert: insert new or update existing record
const { data, error } = await supabaseAdmin
.from('PremiumUsers')
.upsert(dataToInsert, { onConflict: 'user_email' })
.select();
if (error) {
console.error('❌ Database error:', error);
throw error;
}
console.log('✅ Subscription updated:', data);
}
/**
* Handles one-time payment completion
*/
async function handleOneTimePayment(session: Stripe.Checkout.Session) {
const email = session.customer_email || session.customer_details?.email;
if (!email) {
console.error('❌ Customer email is missing from checkout session');
throw new Error('Customer email is required');
}
// Set fixed duration for one-time payments (e.g., 30 days)
const subscriptionDurationDays = 30;
const subscribedAt = new Date(session.created * 1000);
const expiresAt = new Date(subscribedAt);
expiresAt.setDate(expiresAt.getDate() + subscriptionDurationDays);
const dataToInsert = {
user_email: email,
subscription_status: 'active',
subscribed_at: subscribedAt.toISOString(),
expires_at: expiresAt.toISOString(),
subscription_id: session.id,
customer_id: session.customer as string,
plan_type: 'one_time',
};
const { data, error } = await supabaseAdmin
.from('PremiumUsers')
.upsert(dataToInsert, { onConflict: 'user_email' })
.select();
if (error) {
console.error('❌ Database error:', error);
throw error;
}
console.log('✅ One-time payment recorded:', data);
}
/**
* Main webhook handler
*/
export async function POST(req: NextRequest) {
const body = await req.text();
const signature = req.headers.get('stripe-signature');
if (!signature) {
return NextResponse.json(
{ error: 'No signature provided' },
{ status: 400 }
);
}
let event: Stripe.Event;
// Verify webhook signature
try {
event = stripe.webhooks.constructEvent(body, signature, webhookSecret);
} catch (err: any) {
console.error('❌ Webhook signature verification failed:', err.message);
return NextResponse.json(
{ error: `Webhook Error: ${err.message}` },
{ status: 400 }
);
}
// Process the event
try {
switch (event.type) {
// Subscription lifecycle events
case 'customer.subscription.created':
case 'customer.subscription.updated':
await handleSubscriptionChange(event.data.object as Stripe.Subscription);
break;
case 'customer.subscription.deleted':
const deletedSubscription = event.data.object as Stripe.Subscription;
const deletedCustomer = await stripe.customers.retrieve(
deletedSubscription.customer as string
) as Stripe.Customer;
if (deletedCustomer.email) {
await supabaseAdmin
.from('PremiumUsers')
.upsert(
{
user_email: deletedCustomer.email,
subscription_status: 'canceled',
subscription_id: deletedSubscription.id,
customer_id: deletedSubscription.customer as string,
},
{ onConflict: 'user_email' }
);
console.log('✅ Subscription canceled');
}
break;
// Checkout completion
case 'checkout.session.completed':
const session = event.data.object as Stripe.Checkout.Session;
if (session.mode === 'payment' && session.payment_status === 'paid') {
// One-time payment
await handleOneTimePayment(session);
} else if (session.mode === 'subscription' && session.subscription) {
// Subscription payment
const subscription = await stripe.subscriptions.retrieve(
session.subscription as string
);
await handleSubscriptionChange(subscription);
}
break;
// Invoice events (for recurring subscriptions)
case 'invoice.paid':
const invoice = event.data.object as Stripe.Invoice;
if (invoice.subscription) {
const subscription = await stripe.subscriptions.retrieve(
invoice.subscription as string
);
await handleSubscriptionChange(subscription);
}
break;
case 'invoice.payment_failed':
const failedInvoice = event.data.object as Stripe.Invoice;
if (failedInvoice.subscription) {
const subscription = await stripe.subscriptions.retrieve(
failedInvoice.subscription as string
);
const customer = await stripe.customers.retrieve(
subscription.customer as string
) as Stripe.Customer;
if (customer.email) {
await supabaseAdmin
.from('PremiumUsers')
.upsert(
{
user_email: customer.email,
subscription_status: 'past_due',
subscription_id: subscription.id,
customer_id: subscription.customer as string,
},
{ onConflict: 'user_email' }
);
console.log('✅ Subscription marked as past_due');
}
}
break;
default:
console.log(`⚠️ Unhandled event type: ${event.type}`);
}
return NextResponse.json({ received: true }, { status: 200 });
} catch (err: any) {
console.error('❌ Error processing webhook:', err);
return NextResponse.json(
{ error: 'Webhook processing failed' },
{ status: 500 }
);
}
}
- run
npm run devcommand - then run the stripe CLI
- generate a stripe checkout link,
- open the link and
- test a payment with dummy credit card
42424242... - see if you received the webhook events
- finally:
Part 5: Deploying to Production (for free)
Deploy Your Serverless Functions
-
Deploy to Vercel (not sponsored btw):
vercel vercel deploy --prod
Configure Production Webhooks
- Go to your Stripe Dashboard → Workbench (bottom of screen) → Webhooks
- Click Add Destination
- Enter your endpoint:
https://example.vercel.app/api/webhook/stripe - Select events to listen to:
checkout.session.completedcustomer.subscription.createdcustomer.subscription.updatedcustomer.subscription.deletedinvoice.paidinvoice.payment_failed
- Copy the Signing Secret and update your environment variable
- deploy to vercel again with new
webhook secret token
Part 6: Verifying Premium Status (demo)
When somebody use a premium feature in your extension, first verify that user’s subscription_status, like this, it’s server-side, not your extension-side:
// app/api/verify-premium/route.ts
import supabaseAdmin from '@/lib/supabase/supabaseAdmin';
/**
* Checks if a user has an active premium subscription
*/
export async function isEligibleUser(userEmail: string): Promise<boolean> {
try {
const { data, error } = await supabaseAdmin
.from('PremiumUsers')
.select('*')
.eq('user_email', userEmail)
.single();
if (error) {
console.error('Database error:', error);
return false;
}
if (!data) {
return false;
}
const status = data.subscription_status.trim().toLowerCase();
// Consider 'active' and 'trialing' as eligible
// 'trialing' is for users in free trial period
// paid is for one-time payment i guess
return status === 'active' || status === 'trialing' || status === 'paid'
} catch (error) {
console.error('Error checking premium status:', error);
return false;
}
}
/**
* API endpoint to verify premium status
*/
export async function POST(request: Request) {
try {
const { userEmail } = await request.json();
if (!userEmail) {
return new Response(
JSON.stringify({ error: 'Email is required' }),
{ status: 400, headers: { 'Content-Type': 'application/json' } }
);
}
const isPremium = await isEligibleUser(userEmail);
/// it will return false if the user is not in database or don't have any active subscription
return new Response(
JSON.stringify({ isPremium }),
{ status: 200, headers: { 'Content-Type': 'application/json' } }
);
} catch (error) {
console.error('Verification error:', error);
return new Response(
JSON.stringify({ error: 'Internal server error' }),
{ status: 500, headers: { 'Content-Type': 'application/json' } }
);
}
}
Using Premium Verification in Your Extension
// utils/premiumCheck.ts
export async function checkPremiumStatus(userEmail: string): Promise<boolean> {
try {
const response = await fetch('https://your-app.vercel.app/api/verify-premium', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userEmail }),
});
if (!response.ok) {
return false;
}
const { isPremium } = await response.json();
return isPremium;
} catch (error) {
console.error('Error checking premium status:', error);
return false;
}
}
// Example usage in your extension
export async function usePremiumFeature(userEmail: string) {
const isPremium = await checkPremiumStatus(userEmail);
if (!isPremium) {
console.log('this guy is not a premium guy');
return;
}
// User is premium,
console.log('✅ this guy is premium')
}
hey man!
congrats, you are pretty much done right 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” then checkout my boilerplate
thankyou so much for reading 💛
have a nice night...
bye bye :)

Top comments (0)