DEV Community

seller-mind
seller-mind

Posted on Edited on

Why I Finally Stopped Building Chrome Extension SaaS Plumbing From Scratch

When I first started shipping indie SaaS products, I'd lose my first week just doing plumbing. I'd have a great idea for a micro-SaaS, sit down to code, and immediately get bogged down in the unglamorous reality of wiring up auth, payment webhooks, and the extension lifecycle. By the time I actually started building the core features of my app, my motivation was half gone. I realized I was paying a massive context-switching tax every single time I started a new project. So I decided to build something to eliminate that tax.

The SaaS development landscape has shifted dramatically over the last few years. A while ago, building a SaaS meant setting up a monolithic backend and a simple frontend. Today, the industry expects you to be an expert in edge computing, subscription billing logic, OAuth flows, and global deployment pipelines—all before you write a single line of your actual business logic. The cost of ignoring this reality is high. When developers spend the majority of their initial sprint on boilerplate, they burn out. We abandon perfectly good ideas because the friction to get to the fun part is simply too high. Furthermore, stitching together disparate tools often leads to fragmented architectures. Your auth lives in one place, your billing in another, and your edge functions in a third, all held together by duct tape and environment variables. The cognitive load of jumping between the payment dashboard, the extension developer console, and Cloudflare's logs is where most indie devs lose their momentum.

I wanted to create a unified foundation to solve this, and I went a different direction than most boilerplates. Instead of yet another web app template, I built SaaSLaunch for a specific and underserved shape of product: a Chrome extension with a paid subscription.

That means three moving parts that all have to agree with each other:

  • A Manifest V3 extension — service worker, content scripts, message passing, and the storage APIs. MV3's service worker gets suspended when idle, so anything stateful has to survive a cold wake-up.
  • A Cloudflare Worker backend — the extension can't hold a secret key, so every AI call and every entitlement check goes through a Worker that does.
  • Dodo Payments for billing — checkout plus webhook handling, with entitlements written back to a store the extension can read.

Here is the part that actually took me the longest to get right: the entitlement check. The extension needs to know "is this user paid?" on every single invocation, but you cannot put a payment provider secret in an extension bundle. So the Worker becomes the single source of truth. The extension asks the Worker; the Worker asks the payment provider's API (or reads a cached entitlement it maintains from webhooks); the answer comes back as a short-lived signed token.

The webhook side is where the real bugs live. Every payment provider signs its webhook payloads, and you must verify that signature before you trust a single byte of it — otherwise anyone can POST {"status":"paid"} to your endpoint and hand themselves a subscription. Here is the shape of the verification step in the Worker:

// Cloudflare Worker: verify a payment webhook before trusting it
export default {
  async fetch(request, env) {
    if (new URL(request.url).pathname !== '/webhook/dodo') {
      return new Response('Not found', { status: 404 });
    }

    const raw = await request.text();
    const signature = request.headers.get('webhook-signature') || '';
    const timestamp = request.headers.get('webhook-timestamp') || '';

    // Reject stale deliveries so a captured payload cannot be replayed later.
    const skew = Math.abs(Date.now() / 1000 - Number(timestamp));
    if (!timestamp || skew > 300) {
      return new Response('Stale webhook', { status: 400 });
    }

    const expected = await hmacHex(env.DODO_WEBHOOK_SECRET, `${timestamp}.${raw}`);
    if (!timingSafeEqual(expected, signature)) {
      return new Response('Bad signature', { status: 401 });
    }

    // Only now is the payload trustworthy.
    const event = JSON.parse(raw);
    await env.KV.put(`entitlement:${event.data.customer_id}`, event.data.status);
    return new Response('ok');
  },
};
Enter fullscreen mode Exit fullscreen mode

Two things I'd do differently if I started over. First, I originally tried to make one giant webhook handler cover every event type, and debugging subscription lifecycle issues in that thing was miserable — splitting it into small per-event handlers fixed that. Second, I initially wrote entitlements only on the "payment succeeded" event and forgot that cancellations and refunds also need to revoke access. A user who cancels should lose access at the end of the period, not never and not immediately.

On billing specifically: I use Dodo Payments as the merchant of record. For a solo developer selling to a global audience that is the whole point — you are not filing VAT returns in thirty jurisdictions, the provider is. That is worth real money in saved accounting time, and it is the reason I did not reach for a raw card processor.

If you're tired of rebuilding the same billing and auth flows, or if you just want to see how the edge SEO analysis works in practice, I've extracted that specific logic into a free tool. You can try the SEO Checker at https://saaslaunch.dev/free-tools/seo-checker to analyze your own landing pages and see the edge performance firsthand.


Full disclosure: I'm the developer of SaaSLaunch, a Chrome extension SaaS starter kit (Manifest V3 + Cloudflare Workers + Dodo Payments). I built this because I needed it myself.

Top comments (0)