Muhammad Atif
Command Palette

Search for a command to run...

Blog

Monetizing a Chrome Extension with Lemon Squeezy and Cloudflare Workers

Monetizing a Chrome Extension with Lemon Squeezy and Cloudflare Workers

How to add paid plans to a browser extension - licence keys, webhooks, a credit system on Cloudflare Workers and D1, and how to validate entitlements without collecting user data you do not need.

Charging for a browser extension is harder than charging for a web app. There is no server session, the client is fully inspectable, and the Chrome Web Store's own payments system was shut down years ago. You have to build the billing layer yourself.

I did this for Gemini Watermark Remover Pro, which runs four paid plans with a credit system, backed by a Hono API on Cloudflare Workers with a D1 database. This post covers the architecture, the parts that are easy to get wrong, and how to keep the amount of personal data you hold close to zero.

Choosing a payment provider

The main decision is whether you want to be a merchant of record.

Stripe gives you the most control and the lowest fees, but you are responsible for sales tax and VAT everywhere you sell. For a solo developer selling globally, that becomes a real administrative burden quickly.

Lemon Squeezy and Paddle act as merchant of record. They take a larger cut, but they handle VAT, sales tax, invoicing and refunds. For a small paid extension this is usually the right trade - the alternative is spending your time on tax registration rather than product.

I use Lemon Squeezy. It has first-class licence key support built in, which removes an entire component you would otherwise write yourself.

The architecture

The shape that works:

Extension  ──►  Worker API  ──►  D1 (licences, credits)
    │               ▲
    │               │ webhook
    └── checkout ──► Lemon Squeezy
  1. The extension opens a hosted checkout in a new tab.
  2. Lemon Squeezy processes payment and issues a licence key.
  3. A webhook tells your Worker about the purchase; the Worker writes a licence record to D1.
  4. The user pastes the key into the extension.
  5. The extension activates the key against your API and caches the result.

The important property: your API only ever holds account and entitlement data. No user content, no media, no browsing history. In Gemini Watermark Remover Pro all image and video processing happens in the browser, so the API's entire world is "does this key have credits". That makes the privacy policy short and true, and it means a breach exposes almost nothing.

Why Cloudflare Workers and D1

Workers suit this well. Licence checks are tiny, frequent and latency-sensitive; Workers run at the edge, start instantly and cost almost nothing at this volume. D1 is SQLite at the edge, which is more than enough for licences and credits.

The free tiers realistically cover a small paid extension. You are not paying for idle capacity while you find your first hundred customers.

The database

Keep the schema small:

CREATE TABLE licences (
  id            TEXT PRIMARY KEY,
  key_hash      TEXT NOT NULL UNIQUE,
  email_hash    TEXT NOT NULL,
  plan          TEXT NOT NULL,
  status        TEXT NOT NULL DEFAULT 'active',
  credits       INTEGER NOT NULL DEFAULT 0,
  device_limit  INTEGER NOT NULL DEFAULT 3,
  created_at    INTEGER NOT NULL,
  updated_at    INTEGER NOT NULL
);
 
CREATE TABLE activations (
  id           TEXT PRIMARY KEY,
  licence_id   TEXT NOT NULL REFERENCES licences(id),
  device_hash  TEXT NOT NULL,
  created_at   INTEGER NOT NULL,
  last_seen_at INTEGER NOT NULL
);
 
CREATE INDEX idx_activations_licence ON activations(licence_id);

Note what is stored: key_hash, not the key. email_hash, not the email. device_hash, not a device fingerprint.

You need to look up by these values, not read them back, so a hash is sufficient:

async function sha256(value) {
  const data = new TextEncoder().encode(value)
  const digest = await crypto.subtle.digest("SHA-256", data)
  return [...new Uint8Array(digest)]
    .map((b) => b.toString(16).padStart(2, "0"))
    .join("")
}

If you ever need to show the user which key they are on, store a masked version - XXXX-XXXX-XXXX-4821 - alongside the hash. That is enough for support, and useless to anyone who steals the database.

Handling the webhook

The webhook is the only thing that may create or upgrade a licence. Never trust the client for this.

Verify the signature before doing anything else:

import { Hono } from "hono"
 
const app = new Hono()
 
app.post("/webhooks/lemonsqueezy", async (c) => {
  const raw = await c.req.text()
  const signature = c.req.header("X-Signature")
 
  const valid = await verifySignature(raw, signature, c.env.LS_WEBHOOK_SECRET)
  if (!valid) return c.text("Invalid signature", 401)
 
  const event = JSON.parse(raw)
 
  switch (event.meta.event_name) {
    case "order_created":
      await createLicence(c.env.DB, event)
      break
    case "subscription_updated":
      await updateSubscription(c.env.DB, event)
      break
    case "subscription_cancelled":
      await cancelLicence(c.env.DB, event)
      break
  }
 
  return c.json({ received: true })
})

Signature verification uses HMAC-SHA256 with constant-time comparison:

async function verifySignature(body, signature, secret) {
  const key = await crypto.subtle.importKey(
    "raw",
    new TextEncoder().encode(secret),
    { name: "HMAC", hash: "SHA-256" },
    false,
    ["sign"]
  )
  const mac = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(body))
  const expected = [...new Uint8Array(mac)]
    .map((b) => b.toString(16).padStart(2, "0"))
    .join("")
 
  if (expected.length !== signature?.length) return false
 
  // Constant-time comparison: never early-return on first mismatch.
  let diff = 0
  for (let i = 0; i < expected.length; i++) {
    diff |= expected.charCodeAt(i) ^ signature.charCodeAt(i)
  }
  return diff === 0
}

A naive expected === signature leaks timing information. It is a small thing to get right and a genuine hole if you skip it.

Make webhooks idempotent

Providers retry. You will receive the same event more than once, and a duplicated order_created that grants credits twice is a real bug. Key on the provider's event or order id and ignore repeats:

const existing = await db
  .prepare("SELECT id FROM licences WHERE id = ?")
  .bind(orderId)
  .first()
 
if (existing) return // already processed

Validating a licence in the extension

Activation exchanges a key for a cached entitlement:

app.post("/licence/activate", async (c) => {
  const { key, deviceId } = await c.req.json()
  const keyHash = await sha256(key)
 
  const licence = await c.env.DB.prepare(
    "SELECT * FROM licences WHERE key_hash = ? AND status = 'active'"
  )
    .bind(keyHash)
    .first()
 
  if (!licence) return c.json({ ok: false, error: "invalid_key" }, 404)
 
  const deviceHash = await sha256(deviceId)
  const count = await c.env.DB.prepare(
    "SELECT COUNT(*) AS n FROM activations WHERE licence_id = ?"
  )
    .bind(licence.id)
    .first()
 
  const known = await c.env.DB.prepare(
    "SELECT id FROM activations WHERE licence_id = ? AND device_hash = ?"
  )
    .bind(licence.id, deviceHash)
    .first()
 
  if (!known && count.n >= licence.device_limit) {
    return c.json({ ok: false, error: "device_limit_reached" }, 403)
  }
 
  // upsert activation, then return the entitlement
  return c.json({
    ok: true,
    plan: licence.plan,
    credits: licence.credits,
    expiresAt: Date.now() + 7 * 24 * 60 * 60 * 1000,
  })
})

The extension stores that result and re-validates periodically rather than on every action:

chrome.alarms.create("revalidate-licence", { periodInMinutes: 60 * 24 })

Use chrome.storage.session for the cached entitlement if you would rather it never touch disk - it survives the service worker restarting but is cleared when the browser closes. As covered in the Manifest V3 guide, the worker will be terminated constantly, so nothing can live in a module variable.

Fail open, within limits

If your API is unreachable, do not lock out a paying customer. Honour the cached entitlement until it expires, then degrade to the free tier. A user who paid and cannot work because your Worker had a bad minute will refund and leave a one-star review.

Credits versus subscriptions

For usage-based products, credits are easier to reason about than seats. Deduct server-side, always:

app.post("/credits/consume", async (c) => {
  const { key, amount = 1 } = await c.req.json()
  const keyHash = await sha256(key)
 
  const result = await c.env.DB.prepare(
    `UPDATE licences
        SET credits = credits - ?, updated_at = ?
      WHERE key_hash = ? AND credits >= ?
      RETURNING credits`
  )
    .bind(amount, Date.now(), keyHash, amount)
    .first()
 
  if (!result) return c.json({ ok: false, error: "insufficient_credits" }, 402)
  return c.json({ ok: true, remaining: result.credits })
})

The credits >= ? condition in the WHERE clause makes the check and the deduction a single atomic statement. Reading the balance, then deciding, then writing is a race that lets a user spend the same credit twice with two parallel requests.

Subscription renewals top credits back up through the same webhook that created the licence.

Accepting that it can be bypassed

Anyone can open your extension's source. A determined user will find the entitlement check and patch it. This is true of every client-side licensing system and it is not worth losing sleep over.

What licensing is actually for is making paying easier than not paying for the ordinary user. Aim for:

  • Honest free tier so people can evaluate without friction.
  • One-click checkout and instant activation.
  • Server-side enforcement for anything that costs you money - API calls, processing.

That last point is the real defence. If the expensive operation happens on your server and requires a valid licence, patching the client gains nothing. Anything running purely in the browser is, ultimately, on the honour system. Price accordingly.

Pricing and free tiers

The pricing decision matters more than any code in this post.

A free tier is not optional. Nobody pays for a browser extension they have not used. The question is where the boundary sits. Two shapes work:

  • Usage-limited. The full feature set, capped by volume - a number of credits per month. Good when your cost scales with use.
  • Feature-limited. Core features free, advanced ones paid. Good when the expensive part is a specific capability rather than volume.

Gemini Watermark Remover Pro is usage-limited with a credit system, because processing is the cost. A user can try the real thing on real files and hit the ceiling only once they are getting value.

One-off versus subscription. Subscriptions produce better revenue but meet real resistance for small tools. A lifetime licence converts better and removes churn work, at the cost of no recurring income. A reasonable middle path is a subscription for anything with ongoing server cost, and a one-off price for anything that is purely client-side - you are not funding anything recurring, so charging recurringly is hard to defend.

Trials. A time-limited trial of the paid tier, started without a card, converts better than a permanent crippled tier for products where value takes a few sessions to become obvious. Store the trial start server-side against the licence record, not in chrome.storage, or it resets with every reinstall.

Refunds and support

Merchant-of-record providers handle refund mechanics, but you still need a position.

Refund generously for the first week. The revenue from arguing over a small purchase is far less than the cost of a public one-star review, and disputes with card networks are worse than refunds.

When a licence is refunded, the webhook fires and you should mark it inactive. But be careful about the timing of enforcement:

case "order_refunded":
  await db
    .prepare("UPDATE licences SET status = 'refunded', updated_at = ? WHERE id = ?")
    .bind(Date.now(), orderId)
    .run()
  break

Because the extension caches its entitlement, a refunded user keeps access until the cache expires. That is usually acceptable - shortening the cache to punish refunds means more network calls for everyone else. Pick a cache window you are comfortable with and let it apply uniformly.

For support, the single most useful thing is a masked licence identifier the user can copy from the settings page. "My key does not work" is unresolvable; "key ending 4821 says device limit reached" is a thirty-second fix.

Privacy and the store listing

Chrome Web Store requires a privacy policy and a data-use disclosure, and reviewers do check that they match behaviour. Licensing means you are collecting something, so declare it accurately.

For a hashed-licence design the disclosure is genuinely short: you collect an email address (via the payment provider) and store hashed identifiers to validate entitlements. You do not collect browsing history, page content or media.

Say exactly that. Do not copy a generic template that claims more than you do - over-declaring will get you asked to justify data you never touch, and under-declaring is a policy violation. I have written more about this in publishing on the Chrome Web Store.

What I would tell myself starting out

  • Use a merchant of record. Tax compliance across dozens of jurisdictions is not a good use of a solo developer's time.
  • Hash everything you can. If you never need to read a value back, store the hash. It shrinks your obligations and your risk.
  • Make webhooks idempotent from day one. Retries are normal, not exceptional.
  • Do the money-costing work server-side. Everything else is convenience.
  • Cache entitlements and fail open. Availability problems should never look like billing problems.

The whole licensing layer for Gemini Watermark Remover Pro is a few hundred lines of Hono on a Worker, plus two D1 tables. It is not a big system. Getting the boundaries right - what is verified where, what you store, what happens when the network fails - is what takes the thought.

You can see the extensions this runs behind at orbitexaio.com, or find more of my work on GitHub.

Command Palette

Search for a command to run...