← All articles
Jun 12, 20264 min read

prkit: Selling a Chrome Extension with Paddle, Upstash, and a 90-Day JWT

  • Chrome Extensions
  • Paddle
  • Upstash Redis
  • Next.js
  • licensing
  • prkit
prkit: Selling a Chrome Extension with Paddle, Upstash, and a 90-Day JWT

prkit is a small Chrome extension that does one thing: it puts a PR-template dropdown directly inside GitHub's pull-request form, with automatic variable substitution — ticket IDs from the branch name, author, branches, dates. Free tier gets one template; a one-time $9 unlocks unlimited templates synced across devices.

The extension is the easy 30%. The interesting 70% is everything around charging money for it: checkout, license issuance, activation, revocation, refunds. Here's the full architecture — Manifest V3 vanilla JS on the client, Next.js + Upstash Redis + Paddle on the backend, all hosted free-tier.

Why Paddle

Selling software globally means VAT, sales tax, and invoices. Paddle is a merchant of record — they're legally the seller, so tax is their problem. Checkout is an overlay via @paddle/paddle-js on the landing page; the source of truth is the webhook.

The webhook handler runs on Vercel's Node runtime (the Paddle SDK needs Node's crypto) and verifies every payload signature with paddle.webhooks.unmarshal(body, secret, signature) before trusting a byte. Two events drive the entire license lifecycle:

  • transaction.completed → generate a license key, store it, email it to the buyer via Resend.
  • adjustment.created with action refund → resolve the original purchase and flip the license status to revoked.

Wiring refunds into revocation from day one matters: without it, a refund is just a free Pro license.

Triple-indexed licenses in Redis

A license store needs to answer three different lookups fast: by key (activation), by email (support, "I lost my key"), and by order ID (webhooks, refunds). On a key-value store, you don't get secondary indexes — so you write the record three times:

license:key:{key}      → record
license:email:{email}  → record
license:order:{order}  → record

All three written in a Promise.all on purchase, and updated together on revocation. Duplicating a sub-kilobyte record is free; what it buys is O(1) lookups from every code path with zero query infrastructure. For a single-product license server, Upstash's per-request pricing rounds to $0.

Offline-friendly licensing with a revocation backstop

The activation flow: the extension sends license key + email + a locally generated device ID to /api/license/activate, and gets back a JWT signed with HS256 (jose), valid for 90 days.

The 90-day expiry is the heart of the design — a deliberate compromise between two bad extremes:

  • Phone-home-per-use punishes users (latency, offline failures) for the crime of having paid.
  • Pure self-verifying JWT means a refunded buyer keeps Pro until expiry, and you can never revoke a leaked key.

So validation is layered. Locally, the JWT verifies offline — the extension works on a plane. Daily, a chrome.alarms job in the service worker calls /api/license/verify, which checks the database status, not just the signature. A revoked license dies within 24 hours; a network outage costs the user nothing for up to 90 days. (MV3 note: service workers are ephemeral, so setInterval doesn't survive — chrome.alarms is the only reliable scheduler.)

chrome.storage.sync and its 8KB trap

Templates sync across devices via chrome.storage.sync, which enforces 8KB per item and 100KB total. A single array of all templates hits the per-item wall at exactly one long PR template. So prkit stores a lightweight template_index of IDs, with each template under its own tpl_<id> key — per-item quota applies per template, and updating one never rewrites the rest.

Injecting UI into GitHub's React DOM

GitHub is an SPA with Turbo navigation: scripts don't re-run on route changes, so the content script watches document.body with a MutationObserver and re-attempts injection (debounced 300ms) when the PR form appears.

The sharpest edge: GitHub's textarea is a React-controlled input. Set .value directly and the text appears — then vanishes on submit, because React's state never knew. The fix is dispatching a synthetic event so React's own listeners pick up the change:

textarea.value = renderedTemplate;
textarea.dispatchEvent(new Event("input", { bubbles: true }));

Variable substitution stays deliberately dumb: ticket IDs parse from the branch with /[A-Z][A-Z0-9_]+-\d+/, the username comes from GitHub's own <meta name="user-login">, and any placeholder that can't be resolved is left intact in the output — an unfilled {{TICKET}} is a visible prompt to the author, not silently deleted content.

The shape of the whole thing

One vanilla-JS extension, one Next.js app, one Redis database, two webhook events. No accounts, no sessions, no user table — the license key is the identity. For a single-product micro-SaaS, the boring architecture is the feature: every part is replaceable, debuggable with curl, and cheap enough to run forever at zero revenue.

prkit lives at prkit.vercel.app.

WRITTEN BY

Shahzaib Muhammad Akram

Senior Frontend EngineerCyberjaya, Malaysia