DEV Community

Cover image for How to integrate Stripe Payments into a chrome extension (step by step)
Himanshu
Himanshu

Posted on

How to integrate Stripe Payments into a chrome extension (step by step)

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:

  1. User clicks "Upgrade to Premium" in your extension popup
  2. Extension calls your serverless function to generate a Stripe checkout URL
  3. Background script redirects user to the Stripe checkout page
  4. User completes payment on Stripe's secure checkout
  5. Stripe sends a webhook to your serverless function
  6. Your webhook handler saves the subscription data to your database
  7. 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:

  1. Visit Stripe Sandbox Documentation
  2. Create a new sandbox account

Step 2: Create Your Product

  1. Go to Products in your Stripe dashboard
  2. Click Add Product
  3. Enter product details:
    • Name: "Premium Subscription"
    • Description: "Monthly premium access"
    • Pricing: $9.99/month (or your chosen price)
  4. Copy the Price ID (starts with price_) - you'll need this later

Step 3: Get Your API Keys

  1. Navigate to DevelopersAPI Keys or visit this link
  2. Copy your Secret Key (starts with sk_test_ for sandbox)
  3. 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:

  1. Generate checkout URLs
  2. Handle Stripe webhooks

Setting Up Your Project

First, install the Stripe SDK:

npm install stripe --save
Enter fullscreen mode Exit fullscreen mode

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;

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

 

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:

  1. Install Stripe CLI: Follow the installation guide
  2. Login to Stripe CLI:

    stripe login
    
  3. Forward webhooks to your local server:

    stripe listen --forward-to localhost:3000/api/webhook/stripe
    
  4. Copy the webhook secret: The CLI will generate a webhook signing secret (starts with whsec_). Add this to your .env file, 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
);

Enter fullscreen mode Exit fullscreen mode

it will look something like this:

supabase premium users table structure

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;

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode
  • run npm run dev command
  • 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

  1. Deploy to Vercel (not sponsored btw):

    vercel
    vercel deploy --prod
    

Configure Production Webhooks

  1. Go to your Stripe DashboardWorkbench (bottom of screen) → Webhooks
  2. Click Add Destination
  3. Enter your endpoint: https://example.vercel.app/api/webhook/stripe
  4. Select events to listen to:
    • checkout.session.completed
    • customer.subscription.created
    • customer.subscription.updated
    • customer.subscription.deleted
    • invoice.paid
    • invoice.payment_failed
  5. Copy the Signing Secret and update your environment variable
  6. 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' } }
    );
  }
}

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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)