Payments Stripe Monetization Subscriptions

Integrating Stripe and Paddle for Extension Payments

E
Extendable Team
· 15 min read

Monetizing browser extensions requires a reliable payment system that handles subscriptions, license validation, and graceful feature gating. This guide walks through implementing payments using Stripe and Paddle, the two most popular choices for extension developers.

Choosing Between Stripe and Paddle

Quick Comparison:
  • Stripe: More control, better rates, requires tax handling
  • Paddle: Handles VAT/sales tax, acts as merchant of record
  • Recommendation: Paddle for solo developers, Stripe for teams with infrastructure
FeatureStripePaddle
Processing fee2.9% + $0.305% + $0.50
VAT handlingYou managePaddle handles
Merchant of recordYouPaddle
Global payoutsYour responsibilityBuilt-in
Setup complexityMediumLow

Architecture Overview

┌─────────────────────────────────────────────────────────────┐
│                    Browser Extension                         │
│  ┌─────────────┐  ┌─────────────┐  ┌─────────────────────┐ │
│  │   Popup     │  │   Options   │  │  License Validator  │ │
│  │   (Buy UI)  │  │   (Manage)  │  │   (Background)      │ │
│  └──────┬──────┘  └──────┬──────┘  └──────────┬──────────┘ │
└─────────┼────────────────┼───────────────────┼─────────────┘
          │                │                   │
          ▼                ▼                   ▼
┌─────────────────────────────────────────────────────────────┐
│                     Your Backend API                         │
│  ┌─────────────┐  ┌─────────────┐  ┌─────────────────────┐ │
│  │  Checkout   │  │  Webhooks   │  │  License Validation │ │
│  │  Endpoint   │  │  Handler    │  │      Endpoint       │ │
│  └──────┬──────┘  └──────┬──────┘  └──────────┬──────────┘ │
└─────────┼────────────────┼───────────────────┼─────────────┘
          │                │                   │
          ▼                ▼                   │
┌─────────────────┐  ┌─────────────────┐       │
│  Stripe/Paddle  │  │    Database     │◄──────┘
│      API        │  │   (Licenses)    │
└─────────────────┘  └─────────────────┘

Stripe Integration

Backend Setup (Node.js)

// server.js
const express = require('express');
const Stripe = require('stripe');

const stripe = Stripe(process.env.STRIPE_SECRET_KEY);
const app = express();

// Webhook signature verification
app.post('/webhooks/stripe',
  express.raw({ type: 'application/json' }),
  async (req, res) => {
    const sig = req.headers['stripe-signature'];

    try {
      const event = stripe.webhooks.constructEvent(
        req.body,
        sig,
        process.env.STRIPE_WEBHOOK_SECRET
      );

      switch (event.type) {
        case 'checkout.session.completed':
          await handleCheckoutComplete(event.data.object);
          break;
        case 'customer.subscription.updated':
          await handleSubscriptionUpdate(event.data.object);
          break;
        case 'customer.subscription.deleted':
          await handleSubscriptionCanceled(event.data.object);
          break;
        case 'invoice.payment_failed':
          await handlePaymentFailed(event.data.object);
          break;
      }

      res.json({ received: true });
    } catch (err) {
      res.status(400).send(`Webhook Error: ${err.message}`);
    }
  }
);

// Create checkout session
app.post('/api/create-checkout', express.json(), async (req, res) => {
  const { priceId, email, extensionUserId } = req.body;

  try {
    const session = await stripe.checkout.sessions.create({
      mode: 'subscription',
      payment_method_types: ['card'],
      customer_email: email,
      line_items: [{ price: priceId, quantity: 1 }],
      success_url: `${process.env.SITE_URL}/success?session_id={CHECKOUT_SESSION_ID}`,
      cancel_url: `${process.env.SITE_URL}/cancel`,
      client_reference_id: extensionUserId,
      metadata: { extensionUserId },
      subscription_data: {
        metadata: { extensionUserId }
      }
    });

    res.json({ url: session.url, sessionId: session.id });
  } catch (error) {
    res.status(400).json({ error: error.message });
  }
});

// Validate license
app.post('/api/validate-license', express.json(), async (req, res) => {
  const { licenseKey, extensionUserId } = req.body;

  try {
    const license = await db.licenses.findOne({ key: licenseKey });

    if (!license) {
      return res.json({ valid: false, reason: 'not_found' });
    }

    if (license.userId !== extensionUserId) {
      return res.json({ valid: false, reason: 'user_mismatch' });
    }

    if (license.status !== 'active') {
      return res.json({ valid: false, reason: license.status });
    }

    if (license.expiresAt && new Date(license.expiresAt) < new Date()) {
      return res.json({ valid: false, reason: 'expired' });
    }

    res.json({
      valid: true,
      tier: license.tier,
      expiresAt: license.expiresAt,
      features: getFeaturesByTier(license.tier)
    });
  } catch (error) {
    res.status(500).json({ error: error.message });
  }
});

// Webhook handlers
async function handleCheckoutComplete(session) {
  const { extensionUserId } = session.metadata;
  const subscription = await stripe.subscriptions.retrieve(session.subscription);

  const license = {
    key: generateLicenseKey(),
    userId: extensionUserId,
    stripeCustomerId: session.customer,
    stripeSubscriptionId: session.subscription,
    tier: getPlanTier(subscription.items.data[0].price.id),
    status: 'active',
    createdAt: new Date(),
    expiresAt: new Date(subscription.current_period_end * 1000)
  };

  await db.licenses.insert(license);

  // Notify extension via email or push
  await sendLicenseEmail(session.customer_email, license.key);
}

async function handleSubscriptionUpdate(subscription) {
  await db.licenses.update(
    { stripeSubscriptionId: subscription.id },
    {
      status: subscription.status === 'active' ? 'active' : 'inactive',
      tier: getPlanTier(subscription.items.data[0].price.id),
      expiresAt: new Date(subscription.current_period_end * 1000)
    }
  );
}

async function handleSubscriptionCanceled(subscription) {
  await db.licenses.update(
    { stripeSubscriptionId: subscription.id },
    { status: 'canceled' }
  );
}

function generateLicenseKey() {
  const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
  const segments = [];
  for (let i = 0; i < 4; i++) {
    let segment = '';
    for (let j = 0; j < 4; j++) {
      segment += chars[Math.floor(Math.random() * chars.length)];
    }
    segments.push(segment);
  }
  return segments.join('-'); // XXXX-XXXX-XXXX-XXXX
}

Extension Integration

// background.js - License management
class LicenseManager {
  constructor() {
    this.apiBase = 'https://api.yourextension.com';
    this.cacheKey = 'license_cache';
    this.cacheDuration = 24 * 60 * 60 * 1000; // 24 hours
  }

  async validateLicense(forceRefresh = false) {
    // Check cache first
    if (!forceRefresh) {
      const cached = await this.getCachedLicense();
      if (cached) return cached;
    }

    const { licenseKey } = await chrome.storage.sync.get('licenseKey');
    if (!licenseKey) {
      return { valid: false, tier: 'free' };
    }

    try {
      const response = await fetch(`${this.apiBase}/api/validate-license`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          licenseKey,
          extensionUserId: await this.getUserId()
        })
      });

      const result = await response.json();

      // Cache the result
      await this.cacheLicense(result);

      return result;
    } catch (error) {
      // Offline: use cached data or allow graceful degradation
      const cached = await this.getCachedLicense(true); // ignore expiry
      return cached || { valid: false, tier: 'free', offline: true };
    }
  }

  async getCachedLicense(ignoreExpiry = false) {
    const { [this.cacheKey]: cache } = await chrome.storage.local.get(this.cacheKey);

    if (!cache) return null;

    const isExpired = Date.now() - cache.cachedAt > this.cacheDuration;
    if (isExpired && !ignoreExpiry) return null;

    return cache.license;
  }

  async cacheLicense(license) {
    await chrome.storage.local.set({
      [this.cacheKey]: {
        license,
        cachedAt: Date.now()
      }
    });
  }

  async getUserId() {
    let { extensionUserId } = await chrome.storage.sync.get('extensionUserId');

    if (!extensionUserId) {
      extensionUserId = crypto.randomUUID();
      await chrome.storage.sync.set({ extensionUserId });
    }

    return extensionUserId;
  }

  async setLicenseKey(key) {
    await chrome.storage.sync.set({ licenseKey: key });
    return this.validateLicense(true);
  }

  async openCheckout(priceId) {
    const response = await fetch(`${this.apiBase}/api/create-checkout`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        priceId,
        extensionUserId: await this.getUserId()
      })
    });

    const { url } = await response.json();
    chrome.tabs.create({ url });
  }
}

const licenseManager = new LicenseManager();

// Periodic validation
chrome.alarms.create('license-check', { periodInMinutes: 60 });
chrome.alarms.onAlarm.addListener(async (alarm) => {
  if (alarm.name === 'license-check') {
    await licenseManager.validateLicense(true);
  }
});
Offline Handling: Always cache license status locally. Users expect your extension to work even without internet. Be generous with grace periods.

Paddle Integration

Paddle simplifies tax handling by acting as the merchant of record:

// paddle-integration.js (Extension)
class PaddlePayments {
  constructor(vendorId) {
    this.vendorId = vendorId;
    this.initialized = false;
  }

  async loadPaddle() {
    if (this.initialized) return;

    // Load Paddle.js
    await new Promise((resolve, reject) => {
      const script = document.createElement('script');
      script.src = 'https://cdn.paddle.com/paddle/paddle.js';
      script.onload = resolve;
      script.onerror = reject;
      document.head.appendChild(script);
    });

    Paddle.Setup({ vendor: this.vendorId });
    this.initialized = true;
  }

  async openCheckout(productId, options = {}) {
    await this.loadPaddle();

    return new Promise((resolve, reject) => {
      Paddle.Checkout.open({
        product: productId,
        email: options.email,
        passthrough: JSON.stringify({
          extensionUserId: options.userId
        }),
        successCallback: (data) => {
          resolve({
            success: true,
            checkoutId: data.checkout.id,
            orderId: data.order?.order_id
          });
        },
        closeCallback: () => {
          resolve({ success: false, reason: 'closed' });
        }
      });
    });
  }
}

// Backend webhook handler for Paddle
app.post('/webhooks/paddle', express.json(), async (req, res) => {
  const { alert_name, passthrough, subscription_id, status } = req.body;

  // Verify webhook signature
  if (!verifyPaddleSignature(req.body, process.env.PADDLE_PUBLIC_KEY)) {
    return res.status(400).send('Invalid signature');
  }

  const { extensionUserId } = JSON.parse(passthrough || '{}');

  switch (alert_name) {
    case 'subscription_created':
      await createLicense(extensionUserId, subscription_id);
      break;
    case 'subscription_updated':
      await updateLicense(subscription_id, status);
      break;
    case 'subscription_cancelled':
      await cancelLicense(subscription_id);
      break;
    case 'subscription_payment_succeeded':
      await extendLicense(subscription_id);
      break;
    case 'subscription_payment_failed':
      await handlePaymentFailed(subscription_id);
      break;
  }

  res.send('OK');
});

Feature Gating

Control access to premium features based on license:

// features.js
const featureMatrix = {
  free: {
    basicFeature: true,
    advancedFeature: false,
    apiAccess: false,
    maxItems: 10,
    exportFormats: ['txt']
  },
  pro: {
    basicFeature: true,
    advancedFeature: true,
    apiAccess: false,
    maxItems: 100,
    exportFormats: ['txt', 'csv', 'json']
  },
  team: {
    basicFeature: true,
    advancedFeature: true,
    apiAccess: true,
    maxItems: Infinity,
    exportFormats: ['txt', 'csv', 'json', 'xlsx']
  }
};

class FeatureGate {
  constructor(licenseManager) {
    this.licenseManager = licenseManager;
    this.license = null;
  }

  async initialize() {
    this.license = await this.licenseManager.validateLicense();
  }

  getTier() {
    return this.license?.tier || 'free';
  }

  hasFeature(featureName) {
    const tier = this.getTier();
    return featureMatrix[tier]?.[featureName] ?? false;
  }

  getLimit(limitName) {
    const tier = this.getTier();
    return featureMatrix[tier]?.[limitName] ?? featureMatrix.free[limitName];
  }

  async requireFeature(featureName, action) {
    if (this.hasFeature(featureName)) {
      return action();
    }

    return this.showUpgradePrompt(featureName);
  }

  showUpgradePrompt(feature) {
    // Show contextual upgrade UI
    chrome.runtime.sendMessage({
      type: 'SHOW_UPGRADE_PROMPT',
      feature,
      currentTier: this.getTier()
    });

    return { blocked: true, reason: 'upgrade_required' };
  }
}

// Usage
const gate = new FeatureGate(licenseManager);
await gate.initialize();

async function exportData(format) {
  if (!gate.hasFeature(`export_${format}`)) {
    gate.showUpgradePrompt('export_formats');
    return;
  }

  // Perform export
}

Handling Payment Edge Cases

// Handle grace periods and payment failures
async function handlePaymentFailed(subscriptionId) {
  const license = await db.licenses.findOne({
    stripeSubscriptionId: subscriptionId
  });

  // Give 7-day grace period
  const graceEndDate = new Date();
  graceEndDate.setDate(graceEndDate.getDate() + 7);

  await db.licenses.update(
    { stripeSubscriptionId: subscriptionId },
    {
      status: 'grace_period',
      graceEndsAt: graceEndDate
    }
  );

  // Send email notification
  await sendPaymentFailedEmail(license.email, graceEndDate);
}

// Extension-side grace period handling
async function checkGracePeriod() {
  const license = await licenseManager.validateLicense();

  if (license.status === 'grace_period') {
    const daysLeft = Math.ceil(
      (new Date(license.graceEndsAt) - new Date()) / (1000 * 60 * 60 * 24)
    );

    showWarning(`Payment failed. ${daysLeft} days to update payment method.`);

    if (daysLeft <= 0) {
      // Grace period ended
      return { ...license, tier: 'free', gracePeriodEnded: true };
    }
  }

  return license;
}

Subscription Management UI

<!-- options.html - Subscription management -->
<div id="subscription-section" class="settings-section">
  <h2>Subscription</h2>

  <div id="free-tier" class="tier-info hidden">
    <p>You're on the <strong>Free</strong> plan</p>
    <button id="upgrade-btn" class="primary">Upgrade to Pro</button>
  </div>

  <div id="pro-tier" class="tier-info hidden">
    <p>You're on the <strong>Pro</strong> plan</p>
    <p class="expiry">Renews: <span id="renewal-date"></span></p>
    <button id="manage-btn">Manage Subscription</button>
    <button id="cancel-btn" class="danger">Cancel</button>
  </div>

  <div id="license-input" class="hidden">
    <label for="license-key">License Key</label>
    <input type="text" id="license-key" placeholder="XXXX-XXXX-XXXX-XXXX">
    <button id="activate-btn">Activate</button>
  </div>
</div>

<script>
  async function initSubscriptionUI() {
    const license = await chrome.runtime.sendMessage({ type: 'GET_LICENSE' });

    if (license.valid && license.tier !== 'free') {
      document.getElementById('pro-tier').classList.remove('hidden');
      document.getElementById('renewal-date').textContent =
        new Date(license.expiresAt).toLocaleDateString();
    } else {
      document.getElementById('free-tier').classList.remove('hidden');
    }
  }

  document.getElementById('upgrade-btn').addEventListener('click', async () => {
    await chrome.runtime.sendMessage({
      type: 'OPEN_CHECKOUT',
      priceId: 'price_pro_monthly'
    });
  });

  document.getElementById('manage-btn').addEventListener('click', async () => {
    // Open Stripe Customer Portal
    const { url } = await chrome.runtime.sendMessage({ type: 'GET_PORTAL_URL' });
    chrome.tabs.create({ url });
  });

  initSubscriptionUI();
</script>

Summary

Implementing payments in browser extensions requires careful attention to offline scenarios, license caching, and graceful degradation. Use webhooks for reliable subscription lifecycle management and always provide clear upgrade paths for free users.

Key implementation points:

  • Cache licenses locally for offline operation
  • Use webhooks (not polling) for subscription updates
  • Implement grace periods for payment failures
  • Provide clear subscription management UI
  • Consider Paddle for simplified tax handling
  • Gate features gracefully with upgrade prompts