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
| Feature | Stripe | Paddle |
|---|---|---|
| Processing fee | 2.9% + $0.30 | 5% + $0.50 |
| VAT handling | You manage | Paddle handles |
| Merchant of record | You | Paddle |
| Global payouts | Your responsibility | Built-in |
| Setup complexity | Medium | Low |
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