<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Keyturn Kit</title>
    <description>The latest articles on DEV Community by Keyturn Kit (@keyturnkit).</description>
    <link>https://dev.to/keyturnkit</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4206755%2Fddbec2b6-d944-4936-95e3-1f0f992b4b62.png</url>
      <title>DEV Community: Keyturn Kit</title>
      <link>https://dev.to/keyturnkit</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/keyturnkit"/>
    <language>en</language>
    <item>
      <title>Seven real blog exports into Framer CMS, and the four bugs they found</title>
      <dc:creator>Keyturn Kit</dc:creator>
      <pubDate>Fri, 06 Nov 2026 11:02:01 +0000</pubDate>
      <link>https://dev.to/keyturnkit/seven-real-blog-exports-into-framer-cms-and-the-four-bugs-they-found-5eb7</link>
      <guid>https://dev.to/keyturnkit/seven-real-blog-exports-into-framer-cms-and-the-four-bugs-they-found-5eb7</guid>
      <description>&lt;p&gt;We make Blog Import, a Framer plugin that moves a blog into Framer CMS from an export file. Its test suite had 267 passing tests on the morning of 27 October. Then we imported real exports, from public repos where people had committed their own blog backups, into a Framer project, and found four bugs in one afternoon. None of them would have shown up with the hand-written fixtures.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. &lt;code&gt;&amp;amp;amp;&lt;/code&gt; in image addresses
&lt;/h2&gt;

&lt;p&gt;A Hashnode backup with 18 posts imported fine, but 27 of 67 images failed to copy. Most were &lt;code&gt;pbs.twimg.com/media/...?format=jpg&amp;amp;name=medium&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The post bodies are HTML, so an &lt;code&gt;&amp;amp;&lt;/code&gt; inside a &lt;code&gt;src&lt;/code&gt; attribute is written as &lt;code&gt;&amp;amp;amp;&lt;/code&gt;. We read the attribute with a regular expression and handed &lt;code&gt;...?format=jpg&amp;amp;amp;name=medium&lt;/code&gt; to Framer's image upload as is. Twitter's CDN doesn't know a parameter called &lt;code&gt;amp;name&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The fix is one line: decode &lt;code&gt;&amp;amp;amp;&lt;/code&gt; (and &lt;code&gt;&amp;amp;#38;&lt;/code&gt;, &lt;code&gt;&amp;amp;#x26;&lt;/code&gt;) before uploading, and look the decoded address up when swapping the new one back in. It affects every source, not just Hashnode. A WordPress image URL with &lt;code&gt;?w=1024&amp;amp;h=768&lt;/code&gt; would have failed the same way; our fixtures never had one. After the fix, 2 images failed, and both were dead at the source (a 404 and a 403).&lt;/p&gt;

&lt;h2&gt;
  
  
  2. The old site says no, sometimes
&lt;/h2&gt;

&lt;p&gt;Importing the newest 25 posts from wordpress.org/news means copying 147 images. Two runs in a row lost 2 and then 4 images, different ones each time, and the posts whose cover was among them came in without a cover. Every one of those images loaded fine with curl a minute later.&lt;/p&gt;

&lt;p&gt;We already paced uploads in batches of 10 with a pause between them, and retried one by one when a batch failed. The one-by-one retry came right after the rate limit, so it got limited too. Now the images that still fail get one more slow pass at the end: wait 10 seconds, then one image per second. The third run lost none.&lt;/p&gt;

&lt;p&gt;That pass can take a minute on a big blog, and the progress line used to stay on "Copying images, 45 of 45" the whole time. It now says "Trying 38 images again, 12 of 38".&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Importing again forgot the layout
&lt;/h2&gt;

&lt;p&gt;At the time, Blog Import could put everything in one collection (Framer's Basic plan allows only two) or in four linked ones, chosen with a checkbox. The checkbox always started unticked, so importing a one-collection blog a second time quietly added three new collections. The plugin now saves the layout with the collection and reads it back; for collections imported before that, it looks at which fields exist. It has since gained a third layout, Blog plus categories, which also fits on Basic.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. A message that wasn't true
&lt;/h2&gt;

&lt;p&gt;A real Ghost export from 2025 had 9 images written as &lt;code&gt;__GHOST_URL__/content/images/...&lt;/code&gt;. Ghost exports leave out the domain, so the plugin asks for the old site's address. We gave it, and 10 images still failed: the blog has since moved to a static site, and the old image addresses now return an HTML page. The results screen said those images "still load from the old site". They didn't. It now says they still point to the old site and show only while it is online.&lt;/p&gt;

&lt;h2&gt;
  
  
  Re-importing got fast as a side effect
&lt;/h2&gt;

&lt;p&gt;Re-importing the same posts used to copy every image again, about 30 seconds for 25 Squarespace posts. Framer's plugin API can read back the items a plugin wrote (&lt;code&gt;getCollections()&lt;/code&gt; then &lt;code&gt;getItems()&lt;/code&gt;), so the plugin now pairs each image in the new body with the one already in Framer, by position and the text around it, and only uploads what is new. The same re-import now takes under 2 seconds, and a cover that can't be fetched no longer replaces the one already there.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we'd tell anyone writing an importer
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Test with real exports early. GitHub has plenty: search for &lt;code&gt;Squarespace-Wordpress-Export&lt;/code&gt;, &lt;code&gt;kind#post&lt;/code&gt; (Blogger), &lt;code&gt;__GHOST_URL__&lt;/code&gt; (Ghost), or a Hashnode backup repo. Treat them as data, read only, and don't publish other people's posts.&lt;/li&gt;
&lt;li&gt;Old sites rate-limit and die. Design the image step for partial failure, and make the message say exactly what the user's site will look like.&lt;/li&gt;
&lt;li&gt;Our seven sources, from those runs: WordPress (live site), Substack, Medium, Ghost and Hashnode from real exports; Squarespace and Blogger from real exports too, with failures only on third-party images (a dead host and Wikimedia refusing hotlinks).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://keyturnkit.github.io/blog-import/" rel="noopener noreferrer"&gt;Blog Import&lt;/a&gt; is on the Framer Marketplace. The newest 25 posts import free; a license for the rest is $29 for one project or $59 for any number, paid once.&lt;/p&gt;

</description>
      <category>framer</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>testing</category>
    </item>
    <item>
      <title>Moving a client's blog into Framer: a checklist for agencies</title>
      <dc:creator>Keyturn Kit</dc:creator>
      <pubDate>Thu, 05 Nov 2026 11:06:46 +0000</pubDate>
      <link>https://dev.to/keyturnkit/moving-a-clients-blog-into-framer-a-checklist-for-agencies-ig</link>
      <guid>https://dev.to/keyturnkit/moving-a-clients-blog-into-framer-a-checklist-for-agencies-ig</guid>
      <description>&lt;p&gt;The design is the part of a migration you quote for. The blog is the part that comes back to you a month later, when an old link from a newsletter lands on a 404 or the photos in a 2019 post stop loading. This is a checklist for that part. We make &lt;a href="https://keyturnkit.github.io/blog-import/" rel="noopener noreferrer"&gt;Blog Import&lt;/a&gt;, a Framer plugin for this job, so weigh that.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Get the export before anything else
&lt;/h2&gt;

&lt;p&gt;Ask the client for the export file in the first week, not the last. Each platform keeps it somewhere different:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Platform&lt;/th&gt;
&lt;th&gt;Where the export is&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;WordPress&lt;/td&gt;
&lt;td&gt;Tools, Export, All content (an &lt;code&gt;.xml&lt;/code&gt; file)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Squarespace&lt;/td&gt;
&lt;td&gt;Import &amp;amp; export panel, Export, WordPress (an &lt;code&gt;.xml&lt;/code&gt; file)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Blogger&lt;/td&gt;
&lt;td&gt;Settings, Back up content, Download (an &lt;code&gt;.xml&lt;/code&gt; file)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;td&gt;Settings, Security and apps, Download your information (a &lt;code&gt;.zip&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ghost&lt;/td&gt;
&lt;td&gt;Settings, Migration tools, Export (a &lt;code&gt;.json&lt;/code&gt; file)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Substack&lt;/td&gt;
&lt;td&gt;Settings, Exports, Create new export (a &lt;code&gt;.zip&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Webflow&lt;/td&gt;
&lt;td&gt;CMS, the blog collection, Collection settings, Export (a &lt;code&gt;.csv&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Keep a copy in the project folder. Once the client cancels, it is also the only copy of the drafts.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. The images are not in the file
&lt;/h2&gt;

&lt;p&gt;None of these exports carry the images themselves. They carry addresses on the old host: &lt;code&gt;/wp-content/uploads/&lt;/code&gt; on WordPress, Squarespace's image servers, Webflow's asset domain. Those addresses stop working when the client cancels, often weeks after launch. Copy the images into Framer during the import, then open a few old posts on the new site and check where each image loads from.&lt;/p&gt;

&lt;p&gt;Our &lt;a href="https://keyturnkit.github.io/tools/blog-export-image-list/" rel="noopener noreferrer"&gt;image list tool&lt;/a&gt; reads an export in the browser and lists which images sit on the old host.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Redirects need Framer Pro
&lt;/h2&gt;

&lt;p&gt;Every old post address needs a redirect to its new path, or links from other sites and search results end on a 404. Framer has redirects on Pro and higher, under Site Settings, Hosting, Redirects; Basic has none. If the client is on Basic, say so in the quote. Old addresses look different on each platform, for example &lt;code&gt;/2024/03/09/my-post/&lt;/code&gt; on WordPress and &lt;code&gt;/blog/2014/6/10/my-post&lt;/code&gt; on Squarespace. &lt;a href="https://keyturnkit.github.io/guides/framer-blog-redirects/" rel="noopener noreferrer"&gt;Redirects for a moved blog&lt;/a&gt; has the patterns. Addresses that were never on the client's domain can't be redirected from Framer: &lt;code&gt;name.blogspot.com&lt;/code&gt;, &lt;code&gt;name.ghost.io&lt;/code&gt;, &lt;code&gt;name.substack.com&lt;/code&gt; and stories on &lt;code&gt;medium.com&lt;/code&gt;. Leave those posts up with a link to the new site.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Check the CMS limits against the post count
&lt;/h2&gt;

&lt;p&gt;Basic allows 1,000 CMS items and 2 collections, Pro 2,500 items and 10 collections. Authors, categories and tags count as items when they are collections of their own. On Basic, keep authors, categories and tags as fields on each post instead. &lt;a href="https://keyturnkit.github.io/guides/framer-cms-limits-blog/" rel="noopener noreferrer"&gt;Framer CMS limits for a blog&lt;/a&gt; works through the numbers.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Before the client cancels
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Images load from Framer, not the old host.&lt;/li&gt;
&lt;li&gt;Redirects are added and the site is published.&lt;/li&gt;
&lt;li&gt;The domain points at Framer.&lt;/li&gt;
&lt;li&gt;Five old addresses, one from the oldest year, land on the right post.&lt;/li&gt;
&lt;li&gt;Only then cancel the old platform. Budget a month of overlap.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Doing the import
&lt;/h2&gt;

&lt;p&gt;By hand is fine for ten posts. For more, Framer's CSV import brings the text in, but images inside posts stay linked to the old host and there are no redirects.&lt;/p&gt;

&lt;p&gt;Blog Import reads every export in the table above, copies the images into Framer (for Ghost images the export writes as &lt;code&gt;__GHOST_URL__&lt;/code&gt;, it asks for the old blog address or a zip of the images, see &lt;a href="https://keyturnkit.github.io/guides/ghost-blog-to-framer/" rel="noopener noreferrer"&gt;the Ghost guide&lt;/a&gt;) and, on Pro, adds one redirect per post from the old address on the client's own domain. The newest 25 posts import free, so you can run it on a real client blog before paying. A license is $29 for one Framer project or $59 for any number of projects, paid once. Other plugins do parts of this; &lt;a href="https://keyturnkit.github.io/guides/framer-blog-import-plugins-compared/" rel="noopener noreferrer"&gt;the comparison&lt;/a&gt; lists them.&lt;/p&gt;

</description>
      <category>framer</category>
      <category>webdev</category>
      <category>cms</category>
      <category>migration</category>
    </item>
    <item>
      <title>What six payment providers keep from a $9 Chrome extension</title>
      <dc:creator>Keyturn Kit</dc:creator>
      <pubDate>Wed, 04 Nov 2026 13:03:25 +0000</pubDate>
      <link>https://dev.to/keyturnkit/what-six-payment-providers-keep-from-a-9-chrome-extension-6hf</link>
      <guid>https://dev.to/keyturnkit/what-six-payment-providers-keep-from-a-9-chrome-extension-6hf</guid>
      <description>&lt;p&gt;Since the Chrome Web Store stopped taking payments, every paid extension needs a provider of its own, and the fee pages are hard to compare: some charge a flat cut, some add Stripe on top, some add a surcharge for cards outside the US. So we put the published rates side by side for one ordinary case: a $9 one-time purchase, 50 sales a month, 60% of buyers outside the US.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Provider&lt;/th&gt;
&lt;th&gt;Fees a month&lt;/th&gt;
&lt;th&gt;You keep&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Dodo Payments&lt;/td&gt;
&lt;td&gt;$42.05&lt;/td&gt;
&lt;td&gt;$407.95 (90.7%)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lemon Squeezy&lt;/td&gt;
&lt;td&gt;$47.50&lt;/td&gt;
&lt;td&gt;$402.50 (89.4%)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Polar, Starter plan&lt;/td&gt;
&lt;td&gt;$51.55&lt;/td&gt;
&lt;td&gt;$398.45 (88.5%)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Payhip free plan, with Stripe&lt;/td&gt;
&lt;td&gt;$54.60&lt;/td&gt;
&lt;td&gt;$395.40 (87.9%)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ExtensionPay, with Stripe&lt;/td&gt;
&lt;td&gt;$54.60&lt;/td&gt;
&lt;td&gt;$395.40 (87.9%)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gumroad, direct sales&lt;/td&gt;
&lt;td&gt;$70.00&lt;/td&gt;
&lt;td&gt;$380.00 (84.4%)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The rates behind it, from each pricing page on 28 October 2026:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://www.lemonsqueezy.com/pricing" rel="noopener noreferrer"&gt;Lemon Squeezy&lt;/a&gt;: 5% + 50¢. It mentions "small additional fees" outside the US without a number, so its row has none and is a little too high.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://polar.sh/resources/pricing" rel="noopener noreferrer"&gt;Polar&lt;/a&gt; Starter: 5% + 50¢, +1.5% for cards outside the US. Its paid plans go down to 3.4% + 30¢.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://gumroad.com/pricing" rel="noopener noreferrer"&gt;Gumroad&lt;/a&gt;: 10% + 50¢ on direct sales, 30% on sales that come from Gumroad Discover.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://dodopayments.com/pricing" rel="noopener noreferrer"&gt;Dodo Payments&lt;/a&gt;: 4% + 40¢, +1.5% outside the US, +0.5% for subscriptions.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://payhip.com/pricing" rel="noopener noreferrer"&gt;Payhip&lt;/a&gt; free plan: 5%, plus Stripe or PayPal. Paid plans are $29 a month with a 2% fee, or $99 a month with none.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://extensionpay.com/" rel="noopener noreferrer"&gt;ExtensionPay&lt;/a&gt;: 5%, plus Stripe. Sales tax is yours.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://stripe.com/pricing" rel="noopener noreferrer"&gt;Stripe&lt;/a&gt;: 2.9% + 30¢, +1.5% for international cards.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Three things the table leaves out:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Sales tax.&lt;/strong&gt; Lemon Squeezy, Polar and Dodo Payments are merchants of record: they collect and pay the tax. With ExtensionPay, and anything else where Stripe pays you directly, filing it is your job.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Payouts and currency.&lt;/strong&gt; Some providers pass on payout or conversion fees (Polar passes on Stripe's; Dodo's page lists a $5 fee for payouts under $1,000 in one place and "Free" in another). The real amount is a little lower for all of them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Price changes the order.&lt;/strong&gt; The fixed 30 to 50 cents weighs heavily on a $3 sale and barely matters on a $30 one. Try your own numbers in the &lt;a href="https://keyturnkit.github.io/tools/chrome-extension-fee-calculator/" rel="noopener noreferrer"&gt;calculator&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Whichever provider you pick, the extension still has to know who paid. ExtensionPay does that on its servers. With the others you check a license key from the extension. That is what we build: &lt;a href="https://keyturnkit.github.io/" rel="noopener noreferrer"&gt;Keyturn Kit&lt;/a&gt; is a WXT + React starter that does it with no server of yours, free (MIT) for Lemon Squeezy, and $59 once for Pro, which adds Polar, Dodo Payments, Gumroad and Payhip.&lt;/p&gt;

</description>
      <category>chromeextension</category>
      <category>webdev</category>
      <category>saas</category>
      <category>startup</category>
    </item>
    <item>
      <title>Gumroad license keys in a Chrome extension, and what the API leaves to you</title>
      <dc:creator>Keyturn Kit</dc:creator>
      <pubDate>Tue, 03 Nov 2026 12:28:35 +0000</pubDate>
      <link>https://dev.to/keyturnkit/gumroad-license-keys-in-a-chrome-extension-and-what-the-api-leaves-to-you-2nkb</link>
      <guid>https://dev.to/keyturnkit/gumroad-license-keys-in-a-chrome-extension-and-what-the-api-leaves-to-you-2nkb</guid>
      <description>&lt;p&gt;We make Keyturn Kit, a starter for selling Chrome extensions with license keys, and just added Gumroad to it. Gumroad's license API is the simplest of the providers we support and also the one that leaves the most to you, so here are the details, whether you use our kit or not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Turn license keys on
&lt;/h2&gt;

&lt;p&gt;In Gumroad, open the product's content page, choose Insert and add a License key block. While you edit, the block shows the product's ID. Copy it, because every verify call needs it. Gumroad requires &lt;code&gt;product_id&lt;/code&gt; for products created on or after 9 January 2023; the older &lt;code&gt;product_permalink&lt;/code&gt; only works for older products.&lt;/p&gt;

&lt;p&gt;Add &lt;code&gt;https://api.gumroad.com/*&lt;/code&gt; to &lt;code&gt;host_permissions&lt;/code&gt; in your manifest.&lt;/p&gt;

&lt;h2&gt;
  
  
  One endpoint, called two ways
&lt;/h2&gt;

&lt;p&gt;There's a single public call, &lt;code&gt;POST https://api.gumroad.com/v2/licenses/verify&lt;/code&gt;, with form fields:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://api.gumroad.com/v2/licenses/verify&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/x-www-form-urlencoded&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URLSearchParams&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;product_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;PRODUCT_ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;license_key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;increment_uses_count&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;true&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// 'false' for the daily re-check&lt;/span&gt;
  &lt;span class="p"&gt;}),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A good key answers 200 with &lt;code&gt;success: true&lt;/code&gt;, a &lt;code&gt;uses&lt;/code&gt; count and a &lt;code&gt;purchase&lt;/code&gt; object. A key Gumroad doesn't know for this product, a disabled key, or a purchase whose access was revoked answers 404 with &lt;code&gt;success: false&lt;/code&gt; and a &lt;code&gt;message&lt;/code&gt; you can show the user.&lt;/p&gt;

&lt;p&gt;Because the key is looked up inside your &lt;code&gt;product_id&lt;/code&gt;, a key bought for someone else's product is rejected by Gumroad itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  No activations, only a counter
&lt;/h2&gt;

&lt;p&gt;Lemon Squeezy, Polar and Dodo let a key be activated on a set number of devices. Gumroad doesn't. Every verify call adds 1 to &lt;code&gt;uses&lt;/code&gt; unless you send &lt;code&gt;increment_uses_count=false&lt;/code&gt;, and lowering it takes your access token, so in practice only you do it, from the sale in your Gumroad dashboard.&lt;/p&gt;

&lt;p&gt;So count once, when the key is pasted, and compare &lt;code&gt;uses&lt;/code&gt; with the number of browsers you allow. Re-check later with &lt;code&gt;increment_uses_count=false&lt;/code&gt;, or a daily check would use up the key within a week. A reinstall counts as a new use, so leave some room.&lt;/p&gt;

&lt;h2&gt;
  
  
  Refunds and ended memberships don't disable the key
&lt;/h2&gt;

&lt;p&gt;Gumroad keeps answering 200 for a refunded purchase. Its help article says the key stays enabled until you disable it. What changes is the purchase.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;refunded&lt;/code&gt; or &lt;code&gt;chargebacked&lt;/code&gt; is true after a refund or chargeback&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;disputed&lt;/code&gt; is true while a payment is disputed, and &lt;code&gt;dispute_won&lt;/code&gt; tells you if you won it&lt;/li&gt;
&lt;li&gt;for memberships, &lt;code&gt;subscription_ended_at&lt;/code&gt;, &lt;code&gt;subscription_cancelled_at&lt;/code&gt; and &lt;code&gt;subscription_failed_at&lt;/code&gt; hold the date access ends, and go back to null if the membership renews&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Check these on every verify and lock Pro yourself. Keyturn Kit treats refunded, charged back and lost disputes as disabled, and any of the three dates in the past as expired.&lt;/p&gt;

&lt;h2&gt;
  
  
  Nothing secret in the extension
&lt;/h2&gt;

&lt;p&gt;Disabling a key or lowering its count goes through Gumroad's OAuth API with your access token. That token must never ship in an extension; anyone can unzip one. Keep those actions in your dashboard. In the extension, "remove key" can only forget the key in that browser.&lt;/p&gt;

&lt;h2&gt;
  
  
  When Gumroad can't be reached
&lt;/h2&gt;

&lt;p&gt;Network errors, 429 and 5xx responses aren't an answer about the key. Keep the last good result for a while. Keyturn Kit allows 7 days after the last good check before locking Pro.&lt;/p&gt;

&lt;h2&gt;
  
  
  Doing it with Keyturn Kit
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://keyturnkit.github.io/" rel="noopener noreferrer"&gt;Keyturn Kit&lt;/a&gt; is a WXT + React starter that does all of the above. The free edition (&lt;a href="https://github.com/keyturnkit/keyturn-kit" rel="noopener noreferrer"&gt;MIT, on GitHub&lt;/a&gt;) uses Lemon Squeezy. Gumroad, Payhip, Polar and Dodo Payments come with Pro ($59 once), where switching provider is one line in &lt;code&gt;lib/config.ts&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;gumroad&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="nx"&gt;gumroad&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;productId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;YOUR-GUMROAD-PRODUCT-ID&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;maxUses&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pro also adds a free trial counted from install and a number of free uses per day before the paywall. The step-by-step guide for the free edition is &lt;a href="https://keyturnkit.github.io/guides/license-keys-chrome-extension/" rel="noopener noreferrer"&gt;Selling a Chrome extension with license keys, no backend&lt;/a&gt;. Since this post is about Gumroad, &lt;a href="https://mamstudiohq.gumroad.com/l/hhecqu" rel="noopener noreferrer"&gt;Pro is on Gumroad too&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>chromeextension</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>gumroad</category>
    </item>
    <item>
      <title>Taking payment before the user installs your Chrome extension</title>
      <dc:creator>Keyturn Kit</dc:creator>
      <pubDate>Mon, 02 Nov 2026 12:51:33 +0000</pubDate>
      <link>https://dev.to/keyturnkit/taking-payment-before-the-user-installs-your-chrome-extension-599</link>
      <guid>https://dev.to/keyturnkit/taking-payment-before-the-user-installs-your-chrome-extension-599</guid>
      <description>&lt;p&gt;Someone asked on ExtensionPay's discussions how to charge on their own landing page before anyone installs. Setups that track paying users from inside the extension want the extension first. With license keys you can flip the order, because the key is the proof of purchase and it can arrive before the extension does. We make Keyturn Kit, a starter that works this way, so take this with that in mind.&lt;/p&gt;

&lt;h2&gt;
  
  
  The order of things
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Buy button on your page, linking to a checkout (Lemon Squeezy here; Polar, Dodo Payments, Gumroad and Payhip work the same way).&lt;/li&gt;
&lt;li&gt;The confirmation screen and the receipt email both point to your Chrome Web Store listing. The receipt carries the license key.&lt;/li&gt;
&lt;li&gt;The buyer installs, and the extension opens a page that asks for the key.&lt;/li&gt;
&lt;li&gt;The extension activates the key with the provider's license API. No accounts, no server of yours.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Point the receipt at the store
&lt;/h2&gt;

&lt;p&gt;In Lemon Squeezy each product has a confirmation modal (title, message, button text, button link) and receipt settings (thank you note, button text, button link). Set both links to the store listing and say what comes next: "Install the extension, then paste the key from this email."&lt;/p&gt;

&lt;h2&gt;
  
  
  Ask for the key on first install
&lt;/h2&gt;

&lt;p&gt;Without this, a buyer installs, sees a locked popup and has to guess where the key goes.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// entrypoints/background.ts (WXT)&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="nf"&gt;defineBackground&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Registered at the top level: the service worker only gets onInstalled if the listener exists on first run.&lt;/span&gt;
  &lt;span class="nx"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;runtime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onInstalled&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addListener&lt;/span&gt;&lt;span class="p"&gt;(({&lt;/span&gt; &lt;span class="nx"&gt;reason&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;reason&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;install&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="nx"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;runtime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;openOptionsPage&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The options page needs one field and an Activate button:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;license&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;activate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Chrome&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;showError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Say on that page that people who haven't paid can use the free part and buy later, so one page serves both.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three decisions
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Set an activation limit in the provider, for example 3, so a key covers one person's browsers without spreading.&lt;/li&gt;
&lt;li&gt;When you refund, disable the key; the extension notices on its next daily check.&lt;/li&gt;
&lt;li&gt;For Firefox and Edge the key flow is the same, but the receipt needs a link per store, so a small "get the extension" page beats one store link.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The &lt;a href="https://keyturnkit.github.io/guides/pay-before-install-chrome-extension/" rel="noopener noreferrer"&gt;full guide&lt;/a&gt; has the same steps with links to Lemon Squeezy's docs, and the free MIT edition of &lt;a href="https://github.com/keyturnkit/keyturn-kit" rel="noopener noreferrer"&gt;Keyturn Kit&lt;/a&gt; ships the options page, the activation and the first-install snippet.&lt;/p&gt;

</description>
      <category>chromeextension</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>typescript</category>
    </item>
    <item>
      <title>Moving Chrome extension license keys off Lemon Squeezy</title>
      <dc:creator>Keyturn Kit</dc:creator>
      <pubDate>Sun, 01 Nov 2026 12:57:06 +0000</pubDate>
      <link>https://dev.to/keyturnkit/moving-chrome-extension-license-keys-off-lemon-squeezy-2kki</link>
      <guid>https://dev.to/keyturnkit/moving-chrome-extension-license-keys-off-lemon-squeezy-2kki</guid>
      <description>&lt;p&gt;If your Chrome extension unlocks Pro with a Lemon Squeezy license key, you've probably seen the Stripe news. We make Keyturn Kit, a starter for selling extensions with license keys, and its free edition is built on Lemon Squeezy, so we looked at what a move would take. Short answer: nothing forces one today, but Stripe Managed Payments has no license keys, so it's worth knowing where you'd go.&lt;/p&gt;

&lt;p&gt;Lemon Squeezy still sells and still answers its license API. Its site now carries a "2026 Update: Lemon Squeezy + Stripe Managed Payments" banner, and write-ups of that update say the goal is to make moving to Stripe Managed Payments easy. Stripe has no license keys of its own, so an extension that unlocks Pro with a Lemon Squeezy key has no direct equivalent to move to. Nothing forces a move today; this post is for planning one. Read on 27 October 2026.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the extension needs from a provider
&lt;/h2&gt;

&lt;p&gt;A paid extension without a server needs three things from a license API:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Calls the extension can make with nothing secret in its code.&lt;/li&gt;
&lt;li&gt;A way to limit how many browsers use one key.&lt;/li&gt;
&lt;li&gt;A key that stops working after a refund.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Four providers, side by side
&lt;/h2&gt;

&lt;p&gt;Each row comes from our guide for that provider, checked against its docs on 27 October 2026.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Provider&lt;/th&gt;
&lt;th&gt;Secret in the extension&lt;/th&gt;
&lt;th&gt;Browser limit&lt;/th&gt;
&lt;th&gt;After a refund&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://keyturnkit.github.io/guides/polar-license-keys-chrome-extension/" rel="noopener noreferrer"&gt;Polar&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;None, customer-portal endpoints need no token&lt;/td&gt;
&lt;td&gt;Activations, if turned on in the license key benefit&lt;/td&gt;
&lt;td&gt;Revoke or disable the key; the next check fails&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://keyturnkit.github.io/guides/dodo-license-keys-chrome-extension/" rel="noopener noreferrer"&gt;Dodo Payments&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;None, license endpoints are public&lt;/td&gt;
&lt;td&gt;Activations per key&lt;/td&gt;
&lt;td&gt;Disable the key; validate then returns &lt;code&gt;valid: false&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://keyturnkit.github.io/guides/gumroad-license-keys-chrome-extension/" rel="noopener noreferrer"&gt;Gumroad&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;None, verify needs no token&lt;/td&gt;
&lt;td&gt;No activations, only a &lt;code&gt;uses&lt;/code&gt; counter you check yourself&lt;/td&gt;
&lt;td&gt;The key stays enabled; check the &lt;code&gt;refunded&lt;/code&gt; and &lt;code&gt;chargebacked&lt;/code&gt; fields yourself&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://keyturnkit.github.io/guides/payhip-license-keys-chrome-extension/" rel="noopener noreferrer"&gt;Payhip&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;A per-product secret Payhip says is meant for public apps&lt;/td&gt;
&lt;td&gt;A &lt;code&gt;uses&lt;/code&gt; count you raise and lower with that secret (a soft limit)&lt;/td&gt;
&lt;td&gt;Payhip disables the key by itself&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two catches worth knowing before you pick. On Dodo, a key bought from any other Dodo seller activates too, so compare &lt;code&gt;business_id&lt;/code&gt; with yours. On Gumroad, a refunded purchase still verifies, so the extension has to look at the purchase fields.&lt;/p&gt;

&lt;h2&gt;
  
  
  Moving existing customers
&lt;/h2&gt;

&lt;p&gt;Keys from one provider don't work at another, so plan for the people who already paid:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Send new buyers to the new store, and keep checking old keys with Lemon Squeezy: when the new provider doesn't recognise a pasted key, try it against Lemon Squeezy before saying no. Both checks can live side by side while Lemon Squeezy's license API answers.&lt;/li&gt;
&lt;li&gt;Or give existing buyers a key from the new provider, if your new provider lets you issue one without a new purchase. Check that before you announce the move.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  In Keyturn Kit
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://keyturnkit.github.io/" rel="noopener noreferrer"&gt;Keyturn Kit&lt;/a&gt; is a WXT + React starter for paid extensions. The free edition (&lt;a href="https://github.com/keyturnkit/keyturn-kit" rel="noopener noreferrer"&gt;MIT, on GitHub&lt;/a&gt;) uses Lemon Squeezy. Pro ($59 once) adds Polar, Dodo Payments, Gumroad and Payhip, and switching is one line in &lt;code&gt;lib/config.ts&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;polar&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// or 'dodo', 'gumroad', 'payhip', 'lemonsqueezy'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The free edition's walkthrough is &lt;a href="https://keyturnkit.github.io/guides/license-keys-chrome-extension/" rel="noopener noreferrer"&gt;Selling a Chrome extension with license keys, no backend&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>chromeextension</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>lemonsqueezy</category>
    </item>
    <item>
      <title>Payhip license keys in a Chrome extension, with a secret that's allowed to ship</title>
      <dc:creator>Keyturn Kit</dc:creator>
      <pubDate>Sat, 31 Oct 2026 12:58:18 +0000</pubDate>
      <link>https://dev.to/keyturnkit/payhip-license-keys-in-a-chrome-extension-with-a-secret-thats-allowed-to-ship-206k</link>
      <guid>https://dev.to/keyturnkit/payhip-license-keys-in-a-chrome-extension-with-a-secret-thats-allowed-to-ship-206k</guid>
      <description>&lt;p&gt;Most license APIs give you one account key that must never leave a server, which is awkward when your product is a Chrome extension with no server at all. Payhip's v2 license API is different. Each product has its own secret key, and Payhip's help center says that one is meant for public apps. We just added Payhip to Keyturn Kit, our starter for selling extensions, so here is how the endpoints behave and where the limits are. The details come from Payhip's help article on software license keys, read on 27 October 2026.&lt;/p&gt;

&lt;h2&gt;
  
  
  Turn license keys on
&lt;/h2&gt;

&lt;p&gt;In Payhip, turn on license keys for the product. Its license settings show the product secret key, and that's the value the extension sends. Your account API key stays out of it.&lt;/p&gt;

&lt;p&gt;Add &lt;code&gt;https://payhip.com/*&lt;/code&gt; to &lt;code&gt;host_permissions&lt;/code&gt; in your manifest.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checking a key
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;GET https://payhip.com/api/v2/license/verify?license_key=...&lt;/code&gt;, with the secret in a &lt;code&gt;product-secret-key&lt;/code&gt; header:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="s2"&gt;`https://payhip.com/api/v2/license/verify?license_key=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;encodeURIComponent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;product-secret-key&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;PRODUCT_SECRET&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;license&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// empty response = no such key&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A good key comes back as &lt;code&gt;data&lt;/code&gt; with &lt;code&gt;enabled&lt;/code&gt;, &lt;code&gt;license_key&lt;/code&gt;, &lt;code&gt;buyer_email&lt;/code&gt;, &lt;code&gt;uses&lt;/code&gt; and &lt;code&gt;date&lt;/code&gt;. An unknown key gives an empty response rather than an error, so the check has to treat "nothing came back" as "not a key" instead of trying to parse it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Counting browsers with uses
&lt;/h2&gt;

&lt;p&gt;There are no activations as such, but every key has a &lt;code&gt;uses&lt;/code&gt; number you can move up and down with the same secret.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;PUT /api/v2/license/usage&lt;/code&gt; adds one. Call it once when the key is pasted, after checking that &lt;code&gt;uses&lt;/code&gt; is below the number of browsers you allow.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;PUT /api/v2/license/decrease&lt;/code&gt; takes one off. Call it when someone removes the key, so moving to a new laptop frees a slot.&lt;/li&gt;
&lt;li&gt;The daily re-check only calls verify, which leaves the count alone.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Refunds
&lt;/h2&gt;

&lt;p&gt;Payhip disables the key by itself when an order is refunded. The next verify returns &lt;code&gt;enabled: false&lt;/code&gt; and the extension can lock the paid features. Nothing to do in the dashboard.&lt;/p&gt;

&lt;h2&gt;
  
  
  How far the secret reaches
&lt;/h2&gt;

&lt;p&gt;The secret ships inside the extension, and anyone can unzip an extension. With it, someone who already holds a key can call &lt;code&gt;decrease&lt;/code&gt; on that key and reset their own count, so the browser limit is soft. They can't create keys or unlock keys they don't have. That's the trade Payhip made by letting public apps call these endpoints. If a hard limit matters to you, the check belongs on a server.&lt;/p&gt;

&lt;h2&gt;
  
  
  In Keyturn Kit
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://keyturnkit.github.io/" rel="noopener noreferrer"&gt;Keyturn Kit&lt;/a&gt; is a WXT + React starter that does the above. The free edition (&lt;a href="https://github.com/keyturnkit/keyturn-kit" rel="noopener noreferrer"&gt;MIT, on GitHub&lt;/a&gt;) uses Lemon Squeezy. Payhip, Gumroad, Polar and Dodo Payments come with Pro ($59 once), where switching provider is one line in &lt;code&gt;lib/config.ts&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;payhip&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="nx"&gt;payhip&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;productSecretKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;YOUR-PAYHIP-PRODUCT-SECRET-KEY&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;maxUses&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The full guide, with the same code, is on &lt;a href="https://keyturnkit.github.io/guides/payhip-license-keys-chrome-extension/" rel="noopener noreferrer"&gt;our site&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>chromeextension</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>payhip</category>
    </item>
    <item>
      <title>Moving a photo-heavy WordPress blog into Framer CMS: what happened to 50 images and their captions</title>
      <dc:creator>Keyturn Kit</dc:creator>
      <pubDate>Fri, 30 Oct 2026 12:56:47 +0000</pubDate>
      <link>https://dev.to/keyturnkit/moving-a-photo-heavy-wordpress-blog-into-framer-cms-what-happened-to-50-images-and-their-captions-2990</link>
      <guid>https://dev.to/keyturnkit/moving-a-photo-heavy-wordpress-blog-into-framer-cms-what-happened-to-50-images-and-their-captions-2990</guid>
      <description>&lt;p&gt;We make Blog Import, a Framer plugin that moves old blogs into Framer CMS. Photographers' blogs are the hard case: one post per wedding or place, 30 to 80 photos each, usually with a caption under every photo. So we built a test post with 50 captioned photos and pushed it through a real Framer project. Three things came out of it that apply whether you use our plugin or write your own import.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Where the images live decides whether they copy
&lt;/h2&gt;

&lt;p&gt;Framer's CMS can take an image by URL and store its own copy. Our first run pointed at public domain photos on Wikimedia's thumbnail server. From a laptop every one of those URLs answered 200. From Framer's side, all 64 failed, retries included.&lt;/p&gt;

&lt;p&gt;We then hosted resized copies (1600 px, under 400 KB each, about 20 MB in total) on a plain static site and ran the same import. All 64 copied, in under 20 seconds.&lt;/p&gt;

&lt;p&gt;A real blog hosts its own images, so this mostly matters for tests and demos. But if your old site sits behind hotlink protection or a bot filter, expect the same failure, and keep the old site online until the copy is done.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Static hosting caches longer than you think
&lt;/h2&gt;

&lt;p&gt;Between the two runs we changed the sample JSON and ran the import again a few minutes later. It still read the old URLs. GitHub Pages serves files with &lt;code&gt;cache-control: max-age=600&lt;/code&gt;, and the browser running the plugin kept the copy from the first run. After ten minutes the new file came through. If you iterate on fixture data served from a static host, change the path or wait out the cache.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Framer's rich text drops &lt;code&gt;&amp;lt;figure&amp;gt;&lt;/code&gt; and &lt;code&gt;&amp;lt;figcaption&amp;gt;&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;WordPress writes a captioned photo like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;figure&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"wp-block-image"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;img&lt;/span&gt; &lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"/uploads/2025/06/harbour.jpg"&lt;/span&gt; &lt;span class="na"&gt;alt=&lt;/span&gt;&lt;span class="s"&gt;"Harbour at dusk"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;figcaption&amp;gt;&lt;/span&gt;The harbour an hour after the ceremony&lt;span class="nt"&gt;&amp;lt;/figcaption&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/figure&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Sent to a Framer CMS formatted text field as is, the image arrived, but the caption text was gone and the image lost its alt. What survived was a bare image.&lt;/p&gt;

&lt;p&gt;What works is turning each captioned figure into plain tags the field keeps: the image, then a paragraph with the caption in italics, and the caption as alt when the image has none.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;img&lt;/span&gt; &lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"/uploads/2025/06/harbour.jpg"&lt;/span&gt; &lt;span class="na"&gt;alt=&lt;/span&gt;&lt;span class="s"&gt;"Harbour at dusk"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;p&amp;gt;&amp;lt;em&amp;gt;&lt;/span&gt;The harbour an hour after the ceremony&lt;span class="nt"&gt;&amp;lt;/em&amp;gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With that change the same post came through with 50 images, 50 captions in order and 50 alts. The same shape covers Ghost's image cards and Medium's figures, which use the same tags.&lt;/p&gt;

&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Copy images from where the old blog hosts them, while it is still online.&lt;/li&gt;
&lt;li&gt;Don't trust a fixture you changed less than ten minutes ago on a static host.&lt;/li&gt;
&lt;li&gt;Flatten &lt;code&gt;&amp;lt;figure&amp;gt;&lt;/code&gt; and &lt;code&gt;&amp;lt;figcaption&amp;gt;&lt;/code&gt; into &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; plus &lt;code&gt;&amp;lt;p&amp;gt;&amp;lt;em&amp;gt;&lt;/code&gt; before writing to Framer's formatted text.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The caption change is in the current version of Blog Import on the Framer Marketplace. The &lt;a href="https://keyturnkit.github.io/guides/showit-blog-to-framer/" rel="noopener noreferrer"&gt;Showit guide&lt;/a&gt; has the steps for photographers whose blog runs on Showit's WordPress, and &lt;a href="https://keyturnkit.github.io/blog-import/" rel="noopener noreferrer"&gt;Blog Import&lt;/a&gt; is free. We also made &lt;a href="https://keyturnkit.github.io/second-shooter/" rel="noopener noreferrer"&gt;Second Shooter&lt;/a&gt;, a Framer template for wedding and portrait photographers built around posts like this one, with every photo large in the text column. If you have moved a big photo blog another way, I'd like to hear what broke.&lt;/p&gt;

</description>
      <category>framer</category>
      <category>wordpress</category>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>What's inside a Ghost export, and what it takes to move it into Framer CMS</title>
      <dc:creator>Keyturn Kit</dc:creator>
      <pubDate>Thu, 29 Oct 2026 12:57:54 +0000</pubDate>
      <link>https://dev.to/keyturnkit/whats-inside-a-ghost-export-and-what-it-takes-to-move-it-into-framer-cms-3p92</link>
      <guid>https://dev.to/keyturnkit/whats-inside-a-ghost-export-and-what-it-takes-to-move-it-into-framer-cms-3p92</guid>
      <description>&lt;p&gt;We make Blog Import, a Framer plugin that reads blog exports, and Ghost was the last format we added. Ghost's export looks simple at first, a single JSON file. A few details in it decide what you can rebuild somewhere else, so here they are, whether you use our plugin or write your own script.&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting the file
&lt;/h2&gt;

&lt;p&gt;In Ghost admin, open Settings, find Migration tools and choose Export. You get a &lt;code&gt;.json&lt;/code&gt; file with posts, pages, tags, users and settings. Images and the theme are not in it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two shapes of the same file
&lt;/h2&gt;

&lt;p&gt;Ghost's exporter writes &lt;code&gt;{ meta, data }&lt;/code&gt;. The admin download wraps that once more, as &lt;code&gt;{ db: [ { meta, data } ] }&lt;/code&gt;. Files from older tools and Ghost's own import docs use the bare shape, so a reader should accept both.&lt;/p&gt;

&lt;p&gt;Everything useful is in &lt;code&gt;data&lt;/code&gt;. &lt;code&gt;data.posts&lt;/code&gt; holds posts and pages together; &lt;code&gt;type&lt;/code&gt; tells them apart (&lt;code&gt;post&lt;/code&gt; or &lt;code&gt;page&lt;/code&gt;), and &lt;code&gt;status&lt;/code&gt; is &lt;code&gt;published&lt;/code&gt;, &lt;code&gt;draft&lt;/code&gt; or &lt;code&gt;scheduled&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Authors and tags live in link tables
&lt;/h2&gt;

&lt;p&gt;A post row has no author name or tag list. Ghost keeps them the way a database would.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;data.users&lt;/code&gt; and &lt;code&gt;data.tags&lt;/code&gt; hold the people and the tags&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;data.posts_authors&lt;/code&gt; and &lt;code&gt;data.posts_tags&lt;/code&gt; connect them to posts by id, each row with a &lt;code&gt;sort_order&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The first author by &lt;code&gt;sort_order&lt;/code&gt; is the primary one. Tags whose name starts with &lt;code&gt;#&lt;/code&gt; are internal tags, used for routing and theme logic rather than as topics, so they usually shouldn't become categories on the new site.&lt;/p&gt;

&lt;h2&gt;
  
  
  Dates come in three formats
&lt;/h2&gt;

&lt;p&gt;Depending on the Ghost version that wrote the file, &lt;code&gt;published_at&lt;/code&gt; is an ISO string, &lt;code&gt;"2024-01-10 09:30:00"&lt;/code&gt; (UTC, with a space instead of the &lt;code&gt;T&lt;/code&gt;), or epoch milliseconds. &lt;code&gt;new Date()&lt;/code&gt; handles the first and the last; the middle one needs the space replaced and a &lt;code&gt;Z&lt;/code&gt; added, or one browser reads it as local time and another rejects it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The image problem
&lt;/h2&gt;

&lt;p&gt;This is the part that matters most before you cancel Ghost. Recent exports write the site's own address as a placeholder.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;img&lt;/span&gt; &lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"__GHOST_URL__/content/images/2024/01/photo.jpg"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Older exports write &lt;code&gt;/content/images/...&lt;/code&gt; with no domain at all. The settings in the export don't hold the site URL either, so the file alone can't tell you where those images were. Images with a full address, on a CDN or another host, are fine.&lt;/p&gt;

&lt;p&gt;In practice, if your Ghost site is still online, you can rebuild the addresses by putting your domain in front, and copy the images while the site is up. Once the site is gone, the only copies are the ones you downloaded yourself.&lt;/p&gt;

&lt;p&gt;Links between posts use the same placeholder (&lt;code&gt;&amp;lt;a href="__GHOST_URL__/second-post/"&amp;gt;&lt;/code&gt;), and those are easier. Dropping the placeholder leaves a path that works on the new site if the slugs stay the same.&lt;/p&gt;

&lt;h2&gt;
  
  
  Old addresses
&lt;/h2&gt;

&lt;p&gt;Ghost's default post address is &lt;code&gt;/your-post-slug/&lt;/code&gt;. Sites that changed &lt;code&gt;routes.yaml&lt;/code&gt; (dated paths, tag-based collections) use other addresses, and the export doesn't say which routes were active. Redirects built from the export assume the default, so check a few after moving.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we do with it in Framer
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://www.framer.com/marketplace/plugins/blog-import-squarespace-wordpress/" rel="noopener noreferrer"&gt;Blog Import&lt;/a&gt; reads that file inside Framer and creates CMS collections for posts, authors and tags, linked to each other, with drafts kept as drafts and a redirect per post on Framer Pro or higher. Pages, co-authors beyond the first, members and newsletter settings don't come across. For images it does what's described above. Full addresses are copied into Framer. For &lt;code&gt;__GHOST_URL__&lt;/code&gt; ones you type the old blog address and they are copied too while the site is up; if it's already gone, you choose a zip of the &lt;code&gt;content/images&lt;/code&gt; folder and the plugin matches the files (including Ghost's resized copies under &lt;code&gt;size/w600/&lt;/code&gt;) and uploads them from there. Self-hosted sites have that folder on the server; on Ghost(Pro) you have to ask Ghost's support for it.&lt;/p&gt;

&lt;p&gt;The newest 25 posts import free, and there's a &lt;a href="https://keyturnkit.github.io/guides/ghost-blog-to-framer/" rel="noopener noreferrer"&gt;step-by-step guide&lt;/a&gt; with the same details for non-developers. If your export has something odd in it that this doesn't cover, I'd like to see it in the comments.&lt;/p&gt;

</description>
      <category>ghost</category>
      <category>framer</category>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Polar 403 "This license key does not support activations" in a Chrome extension</title>
      <dc:creator>Keyturn Kit</dc:creator>
      <pubDate>Wed, 28 Oct 2026 15:34:16 +0000</pubDate>
      <link>https://dev.to/keyturnkit/polar-403-this-license-key-does-not-support-activations-in-a-chrome-extension-230c</link>
      <guid>https://dev.to/keyturnkit/polar-403-this-license-key-does-not-support-activations-in-a-chrome-extension-230c</guid>
      <description>&lt;p&gt;If your Chrome extension checks Polar license keys and every real key fails, look at the response of the &lt;code&gt;activate&lt;/code&gt; call. When the license key benefit in your Polar dashboard has no activation limit, Polar answers with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="mi"&gt;403&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"detail"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"This license key does not support activations. Use the /validate endpoint instead to check license validity."&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing is wrong with the key. The benefit simply has nothing to count, so Polar refuses to create an activation. Most examples call &lt;code&gt;activate&lt;/code&gt; first and treat any non-200 as "invalid key", so the buyer sees an error with a key they just paid for.&lt;/p&gt;

&lt;p&gt;There are two ways out.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Turn activations on.&lt;/strong&gt; In Polar, open the product, edit the License Keys benefit and set an activation limit (for example 3 browsers). Now &lt;code&gt;activate&lt;/code&gt; returns an activation id, and you validate later with that id. This is the only mode where Polar enforces a browser limit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Fall back to validate.&lt;/strong&gt; If you want unlimited use, keep the benefit as it is and handle exactly that 403:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;API&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://api.polar.sh/v1/customer-portal/license-keys&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;API&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;Accept&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;organization_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ORG_ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;json&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;204&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;activateKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;label&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;activate&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;label&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;noActivations&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
    &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;403&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
    &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;json&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;detail&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
    &lt;span class="sr"&gt;/does not support activations/i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;detail&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;noActivations&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;validate&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;granted&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;activationId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;license_key&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;granted&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;activationId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three details that matter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Match that one 403 only. Any other 403 means something else and should still fail.&lt;/li&gt;
&lt;li&gt;Store an empty activation id, and on later checks call &lt;code&gt;validate&lt;/code&gt; with no &lt;code&gt;activation_id&lt;/code&gt; field at all.&lt;/li&gt;
&lt;li&gt;Skip &lt;code&gt;deactivate&lt;/code&gt; when there is no activation id, since there is nothing to release.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These endpoints are the customer-portal ones, so they need only your organization id, no access token. Nothing secret ships in the extension.&lt;/p&gt;

&lt;p&gt;We hit this while building &lt;a href="https://keyturnkit.github.io/" rel="noopener noreferrer"&gt;Keyturn Kit&lt;/a&gt;, a WXT + React starter for paid Chrome extensions. Version 1.2.3 of its Polar provider (in Pro) handles both modes, with tests for the fallback. The free MIT edition on &lt;a href="https://github.com/keyturnkit/keyturn-kit" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; uses Lemon Squeezy.&lt;/p&gt;

</description>
      <category>chromeextension</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>saas</category>
    </item>
    <item>
      <title>Selling a Chrome extension with license keys, no backend</title>
      <dc:creator>Keyturn Kit</dc:creator>
      <pubDate>Sun, 25 Oct 2026 14:32:13 +0000</pubDate>
      <link>https://dev.to/keyturnkit/selling-a-chrome-extension-with-license-keys-no-backend-4n0f</link>
      <guid>https://dev.to/keyturnkit/selling-a-chrome-extension-with-license-keys-no-backend-4n0f</guid>
      <description>&lt;p&gt;Google wound down Chrome Web Store payments between 2020 and early 2021. To charge for an extension now, you need a checkout and a way to know who paid, and you want to make sharing one key with everyone at least inconvenient.&lt;/p&gt;

&lt;p&gt;This post walks through a small setup that covers that without running a server: Lemon Squeezy for the checkout, its License API for the "who paid" part, and about 80 lines of TypeScript in the extension.&lt;/p&gt;

&lt;p&gt;We make Keyturn Kit, a starter built on this approach (more at the end). Everything below works on its own without it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why license keys and not accounts
&lt;/h2&gt;

&lt;p&gt;The usual SaaS answer is user accounts: the user logs in and your server knows their plan. That means a backend, a database and auth to look after. For an extension that sells for $5 to $30 it is a lot to run.&lt;/p&gt;

&lt;p&gt;A license key turns this around. The payment provider generates the key and emails it to the buyer, the buyer pastes it into the extension once, and the extension asks the provider whether the key is valid and caches the answer. You don't keep a user table anywhere.&lt;/p&gt;

&lt;p&gt;The trade-off is that anything checked on the client can be patched out by someone determined. An activation limit stops casual sharing, and you can disable a key yourself in the dashboard, for example after a refund. Neither stops a person who edits your minified JS. For many small extensions that's an acceptable line.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: the product
&lt;/h2&gt;

&lt;p&gt;In Lemon Squeezy, create a product and open its settings:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;turn on &lt;strong&gt;Generate license keys&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;set an &lt;strong&gt;activation limit&lt;/strong&gt;, for example 3. Each browser that activates the key uses one slot.&lt;/li&gt;
&lt;li&gt;for a one-time purchase, set the license length to unlimited&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Note your store id from the store settings. You'll need it in a minute.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: the manifest
&lt;/h2&gt;

&lt;p&gt;The License API is called straight from the extension, so it needs host permission:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"permissions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"storage"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"alarms"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"host_permissions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"https://api.lemonsqueezy.com/*"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;a href="https://docs.lemonsqueezy.com/api/license-api" rel="noopener noreferrer"&gt;License API docs&lt;/a&gt; examples send no API key: the license key itself is the credential. Never put your Lemon Squeezy API key in an extension.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: activate
&lt;/h2&gt;

&lt;p&gt;When the user pastes a key, first check that it belongs to your store, then activate it with a name for this install. Lemon Squeezy returns an instance id; keep it, because you need it to validate and deactivate later.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;API&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://api.lemonsqueezy.com/v1/licenses&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;STORE_ID&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;12345&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// yours&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;API&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;Accept&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URLSearchParams&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="c1"&gt;// Rate limits and server errors are temporary. Don't treat them as "key rejected".&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;429&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;temporary&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;activate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="c1"&gt;// Any Lemon Squeezy store's key is a real key. Check it's yours before using an activation slot.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;check&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;validate&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;license_key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;check&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;valid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;check&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Key not accepted&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;check&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;store_id&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;STORE_ID&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;This key is from a different store&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;activate&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;license_key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;instance_name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Chrome &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;randomUUID&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;activated&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Key not accepted&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;store_id&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;STORE_ID&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;deactivate&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;license_key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;instance_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;instance&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;This key is from a different store&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;chrome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;storage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;license&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;instanceId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;instance&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;lastCheckedAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;lastOkAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;valid&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;store_id&lt;/code&gt; check is easy to leave out. Without it, a key bought for any product in any Lemon Squeezy store turns on your Pro features, because the License API tells you the key is real, not that it is yours.&lt;/p&gt;

&lt;p&gt;The random suffix on &lt;code&gt;instance_name&lt;/code&gt; gives each install its own entry in your dashboard. Without it, a customer with three laptops shows up as three identical "Chrome" rows.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: re-check once a day
&lt;/h2&gt;

&lt;p&gt;Validate once a day rather than on every popup open, so a user on a plane doesn't lose what they paid for and you aren't calling the API on every click.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;HOUR&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="nx"&gt;_600_000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;DAY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;24&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;HOUR&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;GRACE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;DAY&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// Write only if the same license is still stored. The network call takes time, and the&lt;/span&gt;
&lt;span class="c1"&gt;// user may have removed or replaced their key meanwhile.&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;saveIfSame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;l&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;any&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;changes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;object&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;license&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;chrome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;storage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;license&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;instanceId&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;instanceId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;chrome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;storage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;license&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;changes&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;isPro&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;license&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;l&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;chrome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;storage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;license&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;l&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lastCheckedAt&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;DAY&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;validate&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;license_key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;instance_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;instanceId&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;valid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;valid&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;store_id&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;STORE_ID&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;saveIfSame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;l&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;valid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;lastCheckedAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="na"&gt;lastOkAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;valid&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lastOkAt&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="c1"&gt;// No answer: keep the last known state and try again in an hour.&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;saveIfSame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;l&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;lastCheckedAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;DAY&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;HOUR&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;license&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;chrome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;storage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;license&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;!!&lt;/span&gt;&lt;span class="nx"&gt;now&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;valid&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lastOkAt&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;GRACE&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;"The server said no" and "the server didn't answer" need different handling. A key you've disabled gets &lt;code&gt;valid: false&lt;/code&gt; and locks at the next check, so within about a day. A network error or a rate limit keeps the previous answer, for up to a week since the last good check. If you treat the two the same, either users get locked out on bad Wi-Fi or disabled keys keep working.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;valid&lt;/code&gt; is false for disabled and expired keys, so there's no need to inspect &lt;code&gt;license_key.status&lt;/code&gt; as well.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;saveIfSame&lt;/code&gt; matters more than it looks. Without it, a check that started before the user clicked "move my key" can finish afterwards and write the removed key back.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: check without the popup
&lt;/h2&gt;

&lt;p&gt;If you only check when the popup opens, a user who never opens it keeps Pro after you disable their key. An alarm in the service worker covers that:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Create the alarm only if it's missing. Calling create() on every service worker start&lt;/span&gt;
&lt;span class="c1"&gt;// resets its timer, and alarms aren't guaranteed to survive a browser restart.&lt;/span&gt;
&lt;span class="nx"&gt;chrome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;alarms&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;license&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;chrome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;alarms&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;license&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;periodInMinutes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;chrome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;alarms&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onAlarm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addListener&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;license&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;isPro&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;isPro()&lt;/code&gt; only calls the API once a day, so an hourly alarm is cheap.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6: let people move their key
&lt;/h2&gt;

&lt;p&gt;With an activation limit, someone who switches laptops will hit the cap eventually. Give them a button that frees the slot:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Returns false if the slot could not be freed on the server, so the UI can say "try again online".&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;deactivate&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;license&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;l&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;chrome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;storage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;license&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;l&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;freed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;deactivate&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;license_key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;instance_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;instanceId&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;deactivated&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;chrome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;storage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;remove&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;license&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;freed&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Remove the local copy even if the request fails, otherwise an offline user can't move their key at all. But tell them when the server slot wasn't freed, or they'll hit the activation limit later with no idea why.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing it
&lt;/h2&gt;

&lt;p&gt;Lemon Squeezy has a test mode with test cards. Buy your own product in test mode and paste the key. Disable the key in the dashboard, then force a re-check by setting &lt;code&gt;license.lastCheckedAt&lt;/code&gt; to 0 in the extension's storage (DevTools, Application, Extension storage). Pro should switch off. Then go offline and check that Pro stays on.&lt;/p&gt;

&lt;p&gt;If you plan to refund customers, try a refund in test mode as well and see what it does to the key. Don't assume a refund disables it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The longer version
&lt;/h2&gt;

&lt;p&gt;Keyturn Kit's free edition (MIT) is this same approach with React components, a settings page and tests with a mocked &lt;code&gt;fetch&lt;/code&gt;: &lt;a href="https://github.com/keyturnkit/keyturn-kit" rel="noopener noreferrer"&gt;https://github.com/keyturnkit/keyturn-kit&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;There's also a paid edition that adds Polar, Dodo Payments, Gumroad and Payhip, a free trial and per-day free usage. You don't need it to follow this post. The code above covers the basic flow.&lt;/p&gt;

</description>
      <category>chromeextension</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>typescript</category>
    </item>
  </channel>
</rss>
