<?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: ExtensionBooster</title>
    <description>The latest articles on DEV Community by ExtensionBooster (@extensionbooster).</description>
    <link>https://dev.to/extensionbooster</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F158707%2F27211157-d026-407e-b9df-17252214baa2.webp</url>
      <title>DEV Community: ExtensionBooster</title>
      <link>https://dev.to/extensionbooster</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/extensionbooster"/>
    <language>en</language>
    <item>
      <title>Google Hasn't Verified This App? Fix Your Chrome Extension</title>
      <dc:creator>ExtensionBooster</dc:creator>
      <pubDate>Sat, 07 Nov 2026 05:37:11 +0000</pubDate>
      <link>https://dev.to/extensionbooster/google-hasnt-verified-this-app-fix-your-chrome-extension-4ofl</link>
      <guid>https://dev.to/extensionbooster/google-hasnt-verified-this-app-fix-your-chrome-extension-4ofl</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://extensionbooster.net/blog/google-hasnt-verified-this-app-chrome-extension-oauth-verification-guide/?ref=devto" rel="noopener noreferrer"&gt;ExtensionBooster&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;"Google hasn't verified this app" is the screen a new user can hit right after clicking &lt;strong&gt;Sign in with Google&lt;/strong&gt; in your Chrome extension, and plenty of them close the tab instead of clicking through. It's not only a trust problem: an unverified OAuth project is capped at 100 new users for its entire lifetime. Here's why the warning shows up, what it limits, and the four steps that make it go away.&lt;/p&gt;

&lt;h2&gt;
  
  
  What triggers the warning
&lt;/h2&gt;

&lt;p&gt;Google treats an app as unverified when it requests a &lt;strong&gt;sensitive or restricted OAuth scope&lt;/strong&gt; and hasn't finished Google's verification. In practice, that happens in three ways:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;You request sensitive/restricted scopes and never configured them on the OAuth consent screen or asked for verification.&lt;/li&gt;
&lt;li&gt;Your code requests scopes that aren't selected on the consent screen configuration.&lt;/li&gt;
&lt;li&gt;You did everything right, but verification is still in progress.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Case 2 is the sneaky one. Ship a new feature, add a scope to &lt;code&gt;manifest.json&lt;/code&gt;, forget to update the consent screen in Google Cloud, and the warning reappears for an extension that worked fine yesterday.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why you can't just ignore it
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;100-user lifetime cap.&lt;/strong&gt; Unverified apps using those scopes are limited to 100 new users, and Google's FAQ says the cap covers the whole life of the project and can't be reset. User 101 simply can't authorize, which looks like a bug to them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Security Checkup.&lt;/strong&gt; Google may list your app as risky and unverified there, so users auditing their account later may revoke access.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Testing mode is not a loophole.&lt;/strong&gt; Only users on your test list can use it, and external apps in Testing get refresh tokens that expire in 7 days (unless they only ask for name, email and profile).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reviews.&lt;/strong&gt; Users write what they saw. "Google says this app isn't verified" in a one-star review scares off the next visitor before install.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 1: Shrink your scopes
&lt;/h2&gt;

&lt;p&gt;The scope category decides how much review you face. Non-sensitive scopes don't require app verification; sensitive and restricted ones do, and restricted ones also need a yearly security assessment. Before committing to a long review, check whether a narrower scope covers the feature:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;You need to...&lt;/th&gt;
&lt;th&gt;Consider&lt;/th&gt;
&lt;th&gt;Category&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Know who the user is&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;openid&lt;/code&gt;, &lt;code&gt;userinfo.email&lt;/code&gt;, &lt;code&gt;userinfo.profile&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Non-sensitive&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Save files your extension creates to Drive&lt;/td&gt;
&lt;td&gt;&lt;code&gt;drive.file&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Non-sensitive&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Store app settings in Drive&lt;/td&gt;
&lt;td&gt;&lt;code&gt;drive.appdata&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Non-sensitive&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Read or manage every Drive file&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;drive&lt;/code&gt;, &lt;code&gt;drive.readonly&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Restricted&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Send email for the user&lt;/td&gt;
&lt;td&gt;&lt;code&gt;gmail.send&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Sensitive&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Manage Gmail labels&lt;/td&gt;
&lt;td&gt;&lt;code&gt;gmail.labels&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Non-sensitive&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Read the user's mail&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;gmail.readonly&lt;/code&gt;, &lt;code&gt;gmail.metadata&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Restricted&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A "save to Drive" or "export to Docs" button almost never needs full Drive access. &lt;code&gt;drive.file&lt;/code&gt; handles files your extension creates or the user picks.&lt;/p&gt;

&lt;p&gt;Then stop front-loading scopes in the manifest. &lt;code&gt;chrome.identity.getAuthToken()&lt;/code&gt; takes a &lt;code&gt;scopes&lt;/code&gt; array that overrides the manifest list, so you can ask only when the user enables a feature:&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="c1"&gt;// background.js&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;BASE&lt;/span&gt; &lt;span class="o"&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;openid&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;https://www.googleapis.com/auth/userinfo.email&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;DRIVE_FILE&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://www.googleapis.com/auth/drive.file&lt;/span&gt;&lt;span class="dl"&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;enableDriveExport&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="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;grantedScopes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&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;identity&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getAuthToken&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;interactive&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="na"&gt;scopes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;BASE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;DRIVE_FILE&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="na"&gt;enableGranularPermissions&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="c1"&gt;// Users can decline individual scopes, so check before using Drive&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;grantedScopes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;DRIVE_FILE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;token&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With granular permissions, always check &lt;code&gt;grantedScopes&lt;/code&gt; and keep the rest of the extension working if one is declined. Every requested scope still has to be listed on the consent screen.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Prepare what reviewers check
&lt;/h2&gt;

&lt;p&gt;Most rejections come from missing basics:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;[ ] A homepage on a domain you own that explains the product (not just a login page, and a store listing doesn't count)&lt;/li&gt;
&lt;li&gt;[ ] A privacy policy on that same domain, linked from the homepage and consent screen, covering how you access, use, store and share Google user data&lt;/li&gt;
&lt;li&gt;[ ] All authorized domains verified in Google Search Console by an owner or editor of the Cloud project&lt;/li&gt;
&lt;li&gt;[ ] A demo video showing the full flow, including the OAuth grant (sensitive and restricted scopes)&lt;/li&gt;
&lt;li&gt;[ ] A written justification for each scope, including why a narrower one won't work&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Use the &lt;strong&gt;same app name&lt;/strong&gt; on the store listing, consent screen, homepage and video. Mismatches slow review and confuse users.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Budget for the timeline
&lt;/h2&gt;

&lt;p&gt;Google's estimates (explicitly not guaranteed):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Verification type&lt;/th&gt;
&lt;th&gt;Estimate&lt;/th&gt;
&lt;th&gt;Extra requirement&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Brand&lt;/td&gt;
&lt;td&gt;2-3 business days&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sensitive scopes&lt;/td&gt;
&lt;td&gt;~10 business days&lt;/td&gt;
&lt;td&gt;Demo video, scope justification&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Restricted scopes&lt;/td&gt;
&lt;td&gt;~6 weeks&lt;/td&gt;
&lt;td&gt;Annual security assessment&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Google doesn't charge for the security assessment, but you pay the assessor, so price that in before building a feature around full Gmail or Drive access. You can keep building and testing during review; just don't market a feature new users can't authorize yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Don't lose verification on the next release
&lt;/h2&gt;

&lt;p&gt;Changing your client or adding scopes after verification can send you back through review. Add this to your release checklist:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Diff requested scopes against the previous release, like you diff permissions.&lt;/li&gt;
&lt;li&gt;Add any new scope to the consent screen and request verification &lt;em&gt;before&lt;/em&gt; shipping it.&lt;/li&gt;
&lt;li&gt;Test sign-in with a Google account that has never authorized the extension.&lt;/li&gt;
&lt;li&gt;Watch for Google emails about scope reclassification and verification deadlines.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;And telling users to click &lt;strong&gt;Advanced&lt;/strong&gt; to continue? Fine for a handful of private beta testers. For a public extension it trains people to ignore security warnings and you still hit the 100-user cap.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The warning means sensitive/restricted scopes without completed verification, or code scopes that don't match the consent screen.&lt;/li&gt;
&lt;li&gt;Unverified apps get 100 new users, ever, for that project.&lt;/li&gt;
&lt;li&gt;Narrow scopes (&lt;code&gt;drive.file&lt;/code&gt;, sign-in only) can avoid heavy review entirely.&lt;/li&gt;
&lt;li&gt;Request extra scopes at the moment a feature is used, and handle declines.&lt;/li&gt;
&lt;li&gt;Re-check scopes and the consent screen on every release.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For the full walkthrough, including the FAQ and the manifest setup, read the &lt;a href="https://extensionbooster.net/blog/google-hasnt-verified-this-app-chrome-extension-oauth-verification-guide/?ref=devto" rel="noopener noreferrer"&gt;guide to fixing "Google hasn't verified this app" in Chrome extensions&lt;/a&gt;. If ratings are your next trust signal to work on, &lt;a href="https://extensionbooster.net/?ref=devto" rel="noopener noreferrer"&gt;ExtensionBooster helps developers get real Chrome extension reviews&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Have you been through Google's OAuth verification for an extension, and which step took the longest for you?&lt;/p&gt;

</description>
      <category>chrome</category>
      <category>security</category>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Chrome Web Store Listing: 5 Fixes for More Installs</title>
      <dc:creator>ExtensionBooster</dc:creator>
      <pubDate>Sat, 07 Nov 2026 02:09:24 +0000</pubDate>
      <link>https://dev.to/extensionbooster/chrome-web-store-listing-5-fixes-for-more-installs-jea</link>
      <guid>https://dev.to/extensionbooster/chrome-web-store-listing-5-fixes-for-more-installs-jea</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://extensionlaunch.com/blog/how-to-optimize-your-chrome-web-store-listing?ref=devto" rel="noopener noreferrer"&gt;ExtensionLaunch&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;If your Chrome Web Store listing gets impressions but few installs, you probably don't need more traffic. You need a page that converts the visitors you already have. Below are five fixes, one per listing element, plus a simple way to tell which one is actually broken.&lt;/p&gt;

&lt;h2&gt;
  
  
  Treat the listing like a landing page
&lt;/h2&gt;

&lt;p&gt;Google doesn't publish the store's ranking algorithm. But the signals you can observe line up with what you'd expect:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;How relevant your name and description are to the search query&lt;/li&gt;
&lt;li&gt;Install count and how fast it's growing&lt;/li&gt;
&lt;li&gt;Rating and number of reviews&lt;/li&gt;
&lt;li&gt;Engagement and retention (do installs stick?)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Only the first one is fully under your control. The other three improve when the page earns installs and keeps them. So a better listing helps twice: more visitors convert, and those conversions feed the ranking signals.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 5 fixes at a glance
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Element&lt;/th&gt;
&lt;th&gt;What it optimizes&lt;/th&gt;
&lt;th&gt;The quick win&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Name&lt;/td&gt;
&lt;td&gt;Search relevance&lt;/td&gt;
&lt;td&gt;Keyword first, brand second&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Icon&lt;/td&gt;
&lt;td&gt;Recognition at 16px&lt;/td&gt;
&lt;td&gt;One bold shape, high contrast&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Screenshots&lt;/td&gt;
&lt;td&gt;Conversion&lt;/td&gt;
&lt;td&gt;Caption the benefit on image #1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Description&lt;/td&gt;
&lt;td&gt;Skimming and search&lt;/td&gt;
&lt;td&gt;Put the value in the first 3 lines&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reviews&lt;/td&gt;
&lt;td&gt;Trust and ranking&lt;/td&gt;
&lt;td&gt;Ask right after a success moment&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Fixes 1 and 2: name and icon
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Name.&lt;/strong&gt; This is your highest-leverage search field. Lead with the words people type, then add your brand:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Weak:   Acme
Strong: Acme: Screenshot &amp;amp; Screen Recorder
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use the space you have, but keep it readable. No one searches for a brand word they've never heard, so a brand-only name is invisible in search.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Icon.&lt;/strong&gt; It looks big on the listing page, but users mostly see it tiny, in the toolbar and in search results. Design for 16x16:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;One clear shape or letter, not a detailed scene&lt;/li&gt;
&lt;li&gt;Strong contrast so it works on light and dark themes&lt;/li&gt;
&lt;li&gt;No small text (it turns to mush)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Quick test: shrink it to 16px and look at it from across the room. If you can't tell what it is, a user scanning a results grid can't either.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fixes 3 and 4: screenshots and description
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Screenshots&lt;/strong&gt; are where most installs are won or lost. Raw, unannotated UI wastes them. Think of the set as a short slide deck:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;First screenshot:&lt;/strong&gt; the single most valuable thing the extension does, with a short caption built into the image.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Middle screenshots:&lt;/strong&gt; one feature each.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Last screenshot:&lt;/strong&gt; social proof or a call to action, like your rating or a "works on any site" promise.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Use the full 1280x800 canvas and keep the text large. People skim these before they read any of your copy.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Description.&lt;/strong&gt; The store cuts it off, so the first two or three sentences have to sell on their own:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Line 1:&lt;/strong&gt; what it does and who it's for, in plain words&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lines 2-3:&lt;/strong&gt; the outcome, not a list of features&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;After that:&lt;/strong&gt; short, scannable feature bullets&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Write for a skimming human first. Use your main keyword a handful of times where it reads naturally, and cover the related phrases people search for the same need.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix 5: reviews, then measure
&lt;/h2&gt;

&lt;p&gt;Reviews affect ranking, and they're the last thing a hesitant visitor checks.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Ask after a win, not on install.&lt;/strong&gt; Prompt right after the user gets value, such as a first successful export or save.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make feedback easy to reach&lt;/strong&gt;, so unhappy users email you instead of leaving one star.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reply to reviews&lt;/strong&gt;, especially the critical ones. A calm public reply reassures the next reader more than the complaint scares them.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then use the dashboard's impressions, installs and uninstalls to find what's broken. Watch the ratios, not the totals:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Low install rate with healthy impressions:&lt;/strong&gt; the icon, name or first screenshot isn't selling. Fix the page.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;High uninstall rate:&lt;/strong&gt; the product doesn't match the promise. Fix onboarding or the pitch.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Change one element at a time and wait a week or two before judging it. Otherwise you won't know which change moved the numbers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Most listings have a conversion problem, not a traffic problem.&lt;/li&gt;
&lt;li&gt;Put the keyword first in the name, and design the icon for 16px.&lt;/li&gt;
&lt;li&gt;Make screenshot #1 a captioned benefit, not a raw UI capture.&lt;/li&gt;
&lt;li&gt;Front-load the description: the first three lines do the selling.&lt;/li&gt;
&lt;li&gt;Ask for ratings after a success moment, and reply to critical reviews.&lt;/li&gt;
&lt;li&gt;Judge changes by install rate and uninstall rate, one change at a time.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Store search alone is crowded, so it helps to have links and an audience pointing at your extension too. The full write-up, with example images, is in the &lt;a href="https://extensionlaunch.com/blog/how-to-optimize-your-chrome-web-store-listing?ref=devto" rel="noopener noreferrer"&gt;Chrome Web Store listing optimization guide&lt;/a&gt;, and you can get a linkable page in front of people browsing for new tools on the &lt;a href="https://extensionlaunch.com/?ref=devto" rel="noopener noreferrer"&gt;ExtensionLaunch browser extension directory&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Which change to your store listing made the biggest difference to your install rate, and roughly how much did it move?&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>chrome</category>
      <category>startup</category>
      <category>marketing</category>
    </item>
    <item>
      <title>Google Play In-App Review API: why the card won't show</title>
      <dc:creator>ExtensionBooster</dc:creator>
      <pubDate>Fri, 06 Nov 2026 13:51:33 +0000</pubDate>
      <link>https://dev.to/extensionbooster/google-play-in-app-review-api-why-the-card-wont-show-jpj</link>
      <guid>https://dev.to/extensionbooster/google-play-in-app-review-api-why-the-card-wont-show-jpj</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://appranker.net/blog/google-play-in-app-review-api?ref=devto" rel="noopener noreferrer"&gt;AppRanker&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;You wired up the Google Play In-App Review API, the task completes without an error, and no rating card ever appears. Usually nothing is broken: the card is quota-limited, the API never tells you what happened, and only one of Google's three test methods shows the real flow. This post gives you a working setup for Kotlin, Expo and Flutter, a rule for when to call it, and a table of the usual reasons the card stays hidden.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the API does (and won't tell you)
&lt;/h2&gt;

&lt;p&gt;The flow is two calls. &lt;code&gt;requestReviewFlow()&lt;/code&gt; fetches a &lt;code&gt;ReviewInfo&lt;/code&gt; object from Google Play, and &lt;code&gt;launchReviewFlow(activity, reviewInfo)&lt;/code&gt; shows a native Play card over your app where the user picks 1 to 5 stars and can add a comment. It runs on Android 5.0 (API 21) and later, including tablets and Google TV, plus ChromeOS, but only where the Google Play Store is installed.&lt;/p&gt;

&lt;p&gt;Three facts shape everything else:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;You get no result.&lt;/strong&gt; The completion callback fires whether the user rated, dismissed the card, or never saw it. Your code must carry on the same way in every case.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;There is a quota.&lt;/strong&gt; Google limits how often one user sees the card. Its own example is that calling it more than once in "less than a month" may display nothing, and the exact limit can change without notice.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The rules are strict.&lt;/strong&gt; No "Do you like the app?" question before or during the card, no reshaping or overlaying it, and no rewards for ratings. Play's ratings policy bans incentivized feedback outright.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The quota is why Google says not to put the flow behind a button. A user who has used up their quota taps it and sees nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Decide when to ask before writing the call
&lt;/h2&gt;

&lt;p&gt;Since each user gets roughly one shot per period, your trigger logic matters more than the integration. Aim for a moment right after the user succeeded at something, while they are between tasks, once they have used the app enough to judge it.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;App type&lt;/th&gt;
&lt;th&gt;Ask here&lt;/th&gt;
&lt;th&gt;Never here&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Productivity&lt;/td&gt;
&lt;td&gt;After the 3rd completed export or project&lt;/td&gt;
&lt;td&gt;First launch, mid-typing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fitness&lt;/td&gt;
&lt;td&gt;Workout summary screen&lt;/td&gt;
&lt;td&gt;During a workout, after a missed goal&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Game&lt;/td&gt;
&lt;td&gt;After a level win, before the next level&lt;/td&gt;
&lt;td&gt;Mid-game, after a loss&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Utility&lt;/td&gt;
&lt;td&gt;After the tool has worked several times&lt;/td&gt;
&lt;td&gt;After an error or crash recovery&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A small eligibility gate keeps calls rare. The thresholds below are an example to tune, not a Google requirement:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;DAY_MS&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;86_400_000L&lt;/span&gt;

&lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;reviewEligible&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prefs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;SharedPreferences&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Long&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;System&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;currentTimeMillis&lt;/span&gt;&lt;span class="p"&gt;()):&lt;/span&gt; &lt;span class="nc"&gt;Boolean&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
    &lt;span class="n"&gt;prefs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getInt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"core_action_count"&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="p"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
    &lt;span class="n"&gt;now&lt;/span&gt; &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="n"&gt;prefs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getLong&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"first_open_at"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="nc"&gt;DAY_MS&lt;/span&gt; &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
    &lt;span class="n"&gt;now&lt;/span&gt; &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="n"&gt;prefs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getLong&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"review_last_asked_at"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0L&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;90&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="nc"&gt;DAY_MS&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Save &lt;code&gt;review_last_asked_at&lt;/code&gt; at the moment you launch the flow. You will never learn whether a rating was left, so launch time is the only timestamp you can trust.&lt;/p&gt;

&lt;h2&gt;
  
  
  The integration in Kotlin, Expo and Flutter
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Kotlin.&lt;/strong&gt; Add &lt;code&gt;com.google.android.play:review:2.0.2&lt;/code&gt; (and &lt;code&gt;review-ktx&lt;/code&gt; with the same version for Kotlin extensions). Fetch &lt;code&gt;ReviewInfo&lt;/code&gt; shortly before the success moment, since it expires after a while:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;RatingAsk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;activity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Activity&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;manager&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ReviewManagerFactory&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="n"&gt;activity&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="py"&gt;info&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;ReviewInfo&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;

    &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;warmUp&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;manager&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;requestReviewFlow&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;addOnCompleteListener&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt;
        &lt;span class="n"&gt;info&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="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;isSuccessful&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(!&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;isSuccessful&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nc"&gt;Log&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;w&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"RatingAsk"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"code=${(t.exception as? ReviewException)?.errorCode}"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;show&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;next&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;Unit&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;ready&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;info&lt;/span&gt; &lt;span class="o"&gt;?:&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;manager&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;launchReviewFlow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;activity&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ready&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;addOnCompleteListener&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;info&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;
            &lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="c1"&gt;// runs no matter what the user did&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;Errors you may log: &lt;code&gt;PLAY_STORE_NOT_FOUND&lt;/code&gt; (-1), &lt;code&gt;INVALID_REQUEST&lt;/code&gt; (-2) and &lt;code&gt;INTERNAL_ERROR&lt;/code&gt; (-100). Log them, never show them to the user, and wait for the flow to finish before moving to the next screen.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Expo.&lt;/strong&gt; &lt;code&gt;npx expo install expo-store-review&lt;/code&gt;, then check &lt;code&gt;StoreReview.hasAction()&lt;/code&gt; before calling &lt;code&gt;StoreReview.requestReview()&lt;/code&gt;. Bare React Native apps can use &lt;code&gt;react-native-in-app-review&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Flutter.&lt;/strong&gt; The &lt;code&gt;in_app_review&lt;/code&gt; package gives you both sides:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;review&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;InAppReview&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;instance&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="n"&gt;Future&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;askIfEligible&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="n"&gt;eligible&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kd"&gt;async&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="n"&gt;eligible&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;review&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isAvailable&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;review&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;requestReview&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Settings "Rate us" item: not quota-limited&lt;/span&gt;
&lt;span class="n"&gt;Future&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;rateUs&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;review&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;openStoreListing&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a permanent "Rate us" menu entry on Android, link to &lt;code&gt;https://play.google.com/store/apps/details?id=&amp;lt;your.app.id&amp;gt;&amp;amp;showAllReviews=true&lt;/code&gt; (or the &lt;code&gt;market://&lt;/code&gt; form) instead of calling the API.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing: pick the right method
&lt;/h2&gt;

&lt;p&gt;Most "it's broken" reports come from testing in a setup that cannot show a working card.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Method&lt;/th&gt;
&lt;th&gt;Use it for&lt;/th&gt;
&lt;th&gt;Catch&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Internal test track&lt;/td&gt;
&lt;td&gt;Real end-to-end card&lt;/td&gt;
&lt;td&gt;None; quota is not applied here&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Internal app sharing&lt;/td&gt;
&lt;td&gt;Quick build checks&lt;/td&gt;
&lt;td&gt;Submit button is disabled&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;FakeReviewManager&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Unit and integration tests&lt;/td&gt;
&lt;td&gt;No UI, always succeeds&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For the internal test track, all four must hold: the account is on the track, it is the primary account selected in the Play Store, it installed the app from Play, and it has not reviewed the app yet. After that first Play install, you can push new builds from Android Studio and keep testing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Card not showing: quick diagnosis
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What you see&lt;/th&gt;
&lt;th&gt;Likely cause&lt;/th&gt;
&lt;th&gt;Fix&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Completes, no card, debug build&lt;/td&gt;
&lt;td&gt;This account never installed from Play&lt;/td&gt;
&lt;td&gt;Install once from the internal track&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Worked once, never again&lt;/td&gt;
&lt;td&gt;Quota hit or already reviewed&lt;/td&gt;
&lt;td&gt;Delete your review, or use the internal track&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Submit greyed out&lt;/td&gt;
&lt;td&gt;Internal app sharing install&lt;/td&gt;
&lt;td&gt;Expected; use the internal track&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Never shows on work phone&lt;/td&gt;
&lt;td&gt;Enterprise-protected account&lt;/td&gt;
&lt;td&gt;Test with a personal Gmail account&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;PLAY_STORE_NOT_FOUND&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Emulator without Play&lt;/td&gt;
&lt;td&gt;Use an image with Google Play&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Works on one account only&lt;/td&gt;
&lt;td&gt;Wrong primary account in Play Store&lt;/td&gt;
&lt;td&gt;Switch accounts in the Play Store&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If none fit, check that your eligibility gate ever returns true. A gate that never opens looks exactly like a broken API.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The API reports nothing back, so always continue your flow on completion.&lt;/li&gt;
&lt;li&gt;A quota applies (roughly monthly in Google's example), so never trigger it from a button.&lt;/li&gt;
&lt;li&gt;No pre-questions, no rewards, no altering the card.&lt;/li&gt;
&lt;li&gt;Ask right after a success, gated by usage count, days since install and time since the last ask.&lt;/li&gt;
&lt;li&gt;Test on the internal test track with a personal account that has not reviewed the app.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The original covers the policy quotes, the Bare React Native setup and an FAQ in more depth: see the &lt;a href="https://appranker.net/blog/google-play-in-app-review-api?ref=devto" rel="noopener noreferrer"&gt;complete Google Play In-App Review API guide for Android, React Native and Flutter&lt;/a&gt;. For more on getting your app seen once the ratings come in, browse the &lt;a href="https://appranker.net/?ref=devto" rel="noopener noreferrer"&gt;AppRanker app leaderboard and ASO guides&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;What trigger moment has worked best for your app's review prompt, and did you ever catch it silently burning the quota too early?&lt;/p&gt;

</description>
      <category>android</category>
      <category>reactnative</category>
      <category>flutter</category>
      <category>mobile</category>
    </item>
    <item>
      <title>Browser extension monitoring: spot a sold or hijacked add-on</title>
      <dc:creator>ExtensionBooster</dc:creator>
      <pubDate>Fri, 06 Nov 2026 09:15:38 +0000</pubDate>
      <link>https://dev.to/extensionbooster/browser-extension-monitoring-spot-a-sold-or-hijacked-add-on-1hln</link>
      <guid>https://dev.to/extensionbooster/browser-extension-monitoring-spot-a-sold-or-hijacked-add-on-1hln</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://extenify.app/blog/browser-extension-monitoring?ref=devto" rel="noopener noreferrer"&gt;Extenify&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Browser extension monitoring matters because the extension you vetted on install day is not necessarily the code running in your browser today. The big extension attacks of the past year did not come from shady new listings. They came from trusted extensions that were &lt;strong&gt;sold to a new owner or had their developer account phished&lt;/strong&gt;, then pushed an update that every user installed silently.&lt;/p&gt;

&lt;p&gt;Below: what those incidents had in common, the signals that showed up before or during them, and a tiny Node.js check you can run daily.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three incidents, one pattern
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;QuickLens (about 7,000 users).&lt;/strong&gt; Listed for sale in October 2025, transferred on February 1, 2026 to an owner with a contact address on a &lt;code&gt;.top&lt;/code&gt; domain, then updated on February 17 with code that stripped security headers like &lt;code&gt;X-Frame-Options&lt;/code&gt; and fetched JavaScript from a server to run on every page. All the original features kept working.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ShadyPanda (4.3 million Chrome and Edge users).&lt;/strong&gt; Koi Security described extensions that behaved normally for years before an update turned them into spyware or a remote code execution backdoor.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cyberhaven (December 2024).&lt;/strong&gt; An employee was phished with a fake "Chrome Web Store Developer Support" email and a malicious OAuth app. The attacker published version 24.10.4, which was live for roughly a day before the team caught it and pulled it within an hour.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In every case, the extension was already installed and trusted, and &lt;strong&gt;something visible on the listing changed&lt;/strong&gt; before or during the attack.&lt;/p&gt;

&lt;h2&gt;
  
  
  "No new permissions" is not a safety signal
&lt;/h2&gt;

&lt;p&gt;This is the part most developers get wrong. Chrome disables an extension when an update adds a permission that triggers a warning, and keeps it disabled until the user accepts. An attacker who wants the update to reach everyone therefore avoids new permissions and reuses the access the extension already has. If it already runs on all sites, it needs nothing more.&lt;/p&gt;

&lt;p&gt;So the useful signals are elsewhere:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Signal&lt;/th&gt;
&lt;th&gt;Sold extension&lt;/th&gt;
&lt;th&gt;Hijacked account&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Developer name, email, website&lt;/td&gt;
&lt;td&gt;Often changes, sometimes weeks early&lt;/td&gt;
&lt;td&gt;Usually the same&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Privacy policy URL&lt;/td&gt;
&lt;td&gt;Often moves to a new domain&lt;/td&gt;
&lt;td&gt;Usually the same&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Release timing&lt;/td&gt;
&lt;td&gt;First update after a quiet stretch&lt;/td&gt;
&lt;td&gt;A release the team never shipped, often at odd hours&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Code&lt;/td&gt;
&lt;td&gt;New script files, remote loading&lt;/td&gt;
&lt;td&gt;Small additions to a clean build&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reviews&lt;/td&gt;
&lt;td&gt;New 1-star reports of ads or redirects&lt;/td&gt;
&lt;td&gt;Same, if it stays live&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A dormant extension that suddenly ships right after its contact email moves to an unfamiliar domain is the QuickLens pattern, and it would have been visible about two weeks before the bad update.&lt;/p&gt;

&lt;h2&gt;
  
  
  A minimal daily check in Node.js
&lt;/h2&gt;

&lt;p&gt;Chrome downloads extensions from a public update endpoint, and you can request the same package for any public extension ID. Two facts make a cheap monitor possible: a removed or taken-down extension answers &lt;strong&gt;204 No Content&lt;/strong&gt; instead of a file, and any code change produces a different package. Here is a stripped-down sketch (Node 18+) that hashes whatever Chrome serves:&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="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createHash&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;node:crypto&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;url&lt;/span&gt; &lt;span class="o"&gt;=&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="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://clients2.google.com/service/update2/crx?response=redirect&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;amp;prodversion=155.0&amp;amp;acceptformat=crx3&amp;amp;x=&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;encodeURIComponent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`id=&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="s2"&gt;&amp;amp;uc`&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;snapshot&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;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="nf"&gt;url&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;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;204&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="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;served&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;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;res&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="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="s2"&gt;`&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="s2"&gt;: HTTP &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="s2"&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;buf&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&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;arrayBuffer&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="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;served&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="na"&gt;sha256&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;createHash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sha256&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;buf&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hex&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Store the result, compare it with yesterday's, and alert on a flip to &lt;code&gt;served: false&lt;/code&gt; or a hash change. The full guide goes further: it unpacks the CRX3 file (a short header plus a normal ZIP), reads &lt;code&gt;manifest.json&lt;/code&gt;, and reports exactly which script files are new or changed, plus a Firefox check against the official addons.mozilla.org API that returns authors, support email and declared permissions.&lt;/p&gt;

&lt;p&gt;A few lessons from running checks like this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Confirm on two runs.&lt;/strong&gt; During a staged rollout the endpoint can serve old and new versions alternately, so a flip-flop is not an incident.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Remote payloads stay invisible.&lt;/strong&gt; QuickLens loaded its real code at runtime. A fingerprint tells you the package changed, not what it downloads later, which is why publisher fields matter just as much.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Be polite.&lt;/strong&gt; One request per extension per day is plenty.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;On Firefox, track the author ID&lt;/strong&gt;, not the display name, since a name can be edited to look unchanged.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Sorting alerts so people read them
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Act today:&lt;/strong&gt; the store stops serving the package; a new version lands within weeks of a developer or contact change; new broad host access such as &lt;code&gt;&amp;lt;all_urls&amp;gt;&lt;/code&gt;; any version of &lt;em&gt;your own&lt;/em&gt; extension you did not publish.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Review this week:&lt;/strong&gt; a publisher change with no new version yet; a first release after six or more months of silence; a cluster of 1-star reviews mentioning ads, redirects or search changes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Log only:&lt;/strong&gt; routine bumps with the same publisher, same access and the usual build output.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you publish extensions, compare the live version on every store with your release log daily. A mismatch you cannot explain is the fastest sign that someone else is publishing in your name. If you manage a team's browsers, Chrome's &lt;code&gt;ExtensionSettings&lt;/code&gt; policy lets you block specific permissions and keep extensions away from sensitive hosts with &lt;code&gt;runtime_blocked_hosts&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The common attack is a trusted extension that changes hands or gets hijacked, not a new malicious one.&lt;/li&gt;
&lt;li&gt;Watch publisher fields, declared access and the actual code Chrome serves.&lt;/li&gt;
&lt;li&gt;Unchanged permissions prove nothing; attackers avoid triggering the re-consent prompt.&lt;/li&gt;
&lt;li&gt;A daily check catches takedowns (HTTP 204) and code changes; confirm on two runs before escalating.&lt;/li&gt;
&lt;li&gt;Monitor your own listing too: a version you did not ship means your account is compromised.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The original post has the complete CRX-parsing script, the Firefox AMO check and an FAQ: &lt;a href="https://extenify.app/blog/browser-extension-monitoring?ref=devto" rel="noopener noreferrer"&gt;how to monitor browser extensions for ownership changes and malicious updates&lt;/a&gt;. If you would rather not maintain scripts for dozens of listings, &lt;a href="https://extenify.app/?ref=devto" rel="noopener noreferrer"&gt;Extenify tracks Chrome, Edge and Firefox extension listings and alerts on publisher and version changes&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Have you ever had an extension you relied on change owners or ship an update you did not expect, and how did you find out?&lt;/p&gt;

</description>
      <category>security</category>
      <category>webdev</category>
      <category>javascript</category>
      <category>chrome</category>
    </item>
    <item>
      <title>Chrome Extension Compromised: A 3-Phase Response Checklist</title>
      <dc:creator>ExtensionBooster</dc:creator>
      <pubDate>Fri, 06 Nov 2026 05:36:27 +0000</pubDate>
      <link>https://dev.to/extensionbooster/chrome-extension-compromised-a-3-phase-response-checklist-485o</link>
      <guid>https://dev.to/extensionbooster/chrome-extension-compromised-a-3-phase-response-checklist-485o</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://extensionbooster.net/blog/chrome-extension-compromised-incident-response-plan-guide/?ref=devto" rel="noopener noreferrer"&gt;ExtensionBooster&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;If your Chrome extension is compromised, a malicious update can reach your users within hours, because Chrome checks installed extensions for updates "every few hours." In December 2024, one phishing campaign got into at least 35 extensions with more than 2.6 million users. Here is a response plan in three phases (first hour, first day, first month) that you can copy into your repo before you ever need it.&lt;/p&gt;

&lt;h2&gt;
  
  
  How attackers actually get in
&lt;/h2&gt;

&lt;p&gt;Two-step verification is not enough on its own. These are the three routes that keep coming up:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Route&lt;/th&gt;
&lt;th&gt;What happens&lt;/th&gt;
&lt;th&gt;What to lock down&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Malicious OAuth app&lt;/td&gt;
&lt;td&gt;A fake "Chrome Web Store Developer Support" email leads to a consent screen. You grant an attacker's app publishing access, and 2FA never triggers. This is how Cyberhaven was hit in December 2024.&lt;/td&gt;
&lt;td&gt;Third-party app connections on your Google account&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Leaked CI credentials&lt;/td&gt;
&lt;td&gt;The Chrome Web Store API client secret and refresh token in CI can publish just as well as you can.&lt;/td&gt;
&lt;td&gt;CI secrets, runners, old contractor machines&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ownership transfer&lt;/td&gt;
&lt;td&gt;A buyer takes over an extension that already has users and ships a harmful update.&lt;/td&gt;
&lt;td&gt;Any "we'd like to buy your extension" offer&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Catch it early: a version smoke alarm
&lt;/h2&gt;

&lt;p&gt;The clearest warning sign is a version in the Developer Dashboard that you never built. Other signs: Google emails about submissions you didn't make, users reporting new ads, redirects or permission prompts, and a sudden run of one-star reviews that mention malware.&lt;/p&gt;

&lt;p&gt;You can automate the first check. This script asks Chrome's update service which version it is serving and fails if that doesn't match your latest release tag. The response format isn't a documented API, so treat it as an early warning, not a guarantee.&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="c1"&gt;// verify-live-version.mjs: node verify-live-version.mjs &amp;lt;id&amp;gt; &amp;lt;version&amp;gt;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;extId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;wanted&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;argv&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;2&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;params&lt;/span&gt; &lt;span class="o"&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;response&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;updatecheck&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;acceptformat&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;crx3&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;prodversion&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;141.0&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`id=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;extId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;uc`&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;`https://clients2.google.com/service/update2/crx?&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;params&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;served&lt;/span&gt; &lt;span class="o"&gt;=&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;text&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&amp;lt;updatecheck&lt;/span&gt;&lt;span class="se"&gt;[^&lt;/span&gt;&lt;span class="sr"&gt;&amp;gt;&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;version="&lt;/span&gt;&lt;span class="se"&gt;([^&lt;/span&gt;&lt;span class="sr"&gt;"&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;"/&lt;/span&gt;&lt;span class="p"&gt;)?.[&lt;/span&gt;&lt;span class="mi"&gt;1&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;served&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&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;No version found in response&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&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;served&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;wanted&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Mismatch: live &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;served&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;, expected &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;wanted&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;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Live version matches: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;served&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it on a schedule in CI and send failures to a channel someone actually watches at night.&lt;/p&gt;

&lt;h2&gt;
  
  
  Phase 1: the first hour (stop the spread)
&lt;/h2&gt;

&lt;p&gt;Contain it first and investigate later. Keep a timestamped log of every step you take.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Roll back.&lt;/strong&gt; The rollback in the Developer Dashboard puts the previous version live "within a minute," and Google says it needs no review. It asks for a new version number and a reason. There are three catches:

&lt;ul&gt;
&lt;li&gt;It only goes back one version. If the attacker published twice, you land on the other bad build, and further rollbacks just cycle between those two.&lt;/li&gt;
&lt;li&gt;Pending and staged submissions are thrown away.&lt;/li&gt;
&lt;li&gt;During a percentage rollout, it returns to the last version that reached 100%.&lt;/li&gt;
&lt;li&gt;If there is no clean version to go back to, unpublish the item instead.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reclaim the Google account.&lt;/strong&gt; Change the password, remove 2-step methods you don't recognize, sign out all other sessions and revoke unknown third-party apps. Revoking the app is the step that would have cut off the Cyberhaven attacker.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prune publisher members&lt;/strong&gt; that don't need access.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rotate the Web Store API credentials&lt;/strong&gt; in CI and pause automated publishing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Report it&lt;/strong&gt; through the Chrome Web Store developer support form. Include the item ID, the bad version, when it went live and your evidence.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Phase 2: the first day (scope it and tell users)
&lt;/h2&gt;

&lt;p&gt;Unpack the rogue package and diff it against your last good release. Look for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;New permissions or host permissions in &lt;code&gt;manifest.json&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;New network destinations, especially in the service worker&lt;/li&gt;
&lt;li&gt;Code that reads cookies, tokens, form fields or page content&lt;/li&gt;
&lt;li&gt;Wider &lt;code&gt;content_scripts&lt;/code&gt; matches&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then rotate any secrets that shipped inside the extension and invalidate sessions on your backend.&lt;/p&gt;

&lt;p&gt;Your public notice should say what happened, which versions were affected and when, what they could access, what users should do now, and what you are changing. Your clean release can show the notice only to users who updated from an affected version:&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;COMPROMISED&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;3.4.1&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;3.4.2&lt;/span&gt;&lt;span class="dl"&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;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="nx"&gt;previousVersion&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;update&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;COMPROMISED&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;previousVersion&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="nx"&gt;chrome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tabs&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="na"&gt;url&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://example.com/security/incident/&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Reply to every angry review with the same short, factual message: confirm the incident, name the fixed version and link to the notice. Don't argue, and never ask anyone to change their rating.&lt;/p&gt;

&lt;h2&gt;
  
  
  Phase 3: the first month (make it harder next time)
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Turn on Verified CRX uploads&lt;/strong&gt; (Package tab). After you opt in, every update must be signed with your own key, so a stolen login alone can't publish. Generate the key offline:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;openssl genpkey &lt;span class="nt"&gt;-algorithm&lt;/span&gt; RSA &lt;span class="nt"&gt;-pkeyopt&lt;/span&gt; rsa_keygen_bits:2048 &lt;span class="nt"&gt;-out&lt;/span&gt; privatekey.pem
openssl rsa &lt;span class="nt"&gt;-in&lt;/span&gt; privatekey.pem &lt;span class="nt"&gt;-pubout&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keep the private key out of your Google account and out of every repo. If you lose it, Google says getting a replacement through support can take up to a week.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Give each person their own publisher role, with no shared logins.&lt;/li&gt;
&lt;li&gt;Use security keys or passkeys on every account that can publish.&lt;/li&gt;
&lt;li&gt;Require a manual approval before the CI publish job runs.&lt;/li&gt;
&lt;li&gt;Use percentage rollouts so a bad build reaches a small group first.&lt;/li&gt;
&lt;li&gt;Write a postmortem and publish a short, honest summary.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Your rating can recover. Since Google's August 20, 2026 update, the Chrome Web Store rating focuses on more recent reviews. A burst of one-stars hurts fast, but steady good reviews after the fix count for more than they used to.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Roll back first: it goes live in about a minute with no review.&lt;/li&gt;
&lt;li&gt;2FA doesn't stop malicious OAuth apps, so audit your third-party connections.&lt;/li&gt;
&lt;li&gt;CI publishing credentials are as sensitive as your password.&lt;/li&gt;
&lt;li&gt;Tell users which versions were affected and what to do, and reply to every review.&lt;/li&gt;
&lt;li&gt;Verified CRX uploads make a stolen login useless for publishing.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The full guide, including a copy-paste checklist and FAQ, is in the &lt;a href="https://extensionbooster.net/blog/chrome-extension-compromised-incident-response-plan-guide/?ref=devto" rel="noopener noreferrer"&gt;Chrome extension incident response plan on ExtensionBooster&lt;/a&gt;. There are more growth and trust guides for extension developers in the &lt;a href="https://extensionbooster.net/?ref=devto" rel="noopener noreferrer"&gt;ExtensionBooster developer resources&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Have you ever had a publishing account phished or a rogue update go out? What warned you first, users or your own monitoring?&lt;/p&gt;

</description>
      <category>chrome</category>
      <category>security</category>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Monetize a Chrome Extension: 4 Models That Pass Review</title>
      <dc:creator>ExtensionBooster</dc:creator>
      <pubDate>Fri, 06 Nov 2026 02:10:12 +0000</pubDate>
      <link>https://dev.to/extensionbooster/monetize-a-chrome-extension-4-models-that-pass-review-5439</link>
      <guid>https://dev.to/extensionbooster/monetize-a-chrome-extension-4-models-that-pass-review-5439</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://extensionlaunch.com/blog/monetize-chrome-extension?ref=devto" rel="noopener noreferrer"&gt;ExtensionLaunch&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;If you want to monetize a Chrome extension, the hard part isn't Stripe or pricing pages. It's picking a revenue model that won't get your listing pulled six months after launch. Below are the four models that fit Chrome Web Store policy, the rules each one has to follow, and the two "passive income" offers you should delete on sight.&lt;/p&gt;

&lt;h2&gt;
  
  
  First, the six policies that decide everything
&lt;/h2&gt;

&lt;p&gt;Every monetization choice gets judged against a handful of Developer Program Policies. Read them together, not one at a time:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Policy&lt;/th&gt;
&lt;th&gt;The short version for revenue&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Single purpose (Quality Guidelines)&lt;/td&gt;
&lt;td&gt;The money feature must belong to the extension's one narrow purpose&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Limited Use&lt;/td&gt;
&lt;td&gt;You can't sell or transfer user data, even "anonymized"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Accepting payment&lt;/td&gt;
&lt;td&gt;Say what's paid in the description and that &lt;em&gt;you&lt;/em&gt;, not Google, are the seller&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ads&lt;/td&gt;
&lt;td&gt;In context, easy to remove, and no AdSense&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Affiliate Ads&lt;/td&gt;
&lt;td&gt;Disclosure, a related user action, and a real user benefit&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Deceptive installation&lt;/td&gt;
&lt;td&gt;No surprise monetization after install&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;One extra trap: if your revenue feature needs new permissions unrelated to the core purpose, reviewers treat that as unrelated functionality. You get judged twice.&lt;/p&gt;

&lt;h2&gt;
  
  
  Model 1: Paid plans (the cleanest option)
&lt;/h2&gt;

&lt;p&gt;Google shut down Chrome Web Store payments back in 2020–2021. So every paid extension now brings its own checkout: a payment provider or merchant of record, a license backend, and an upgrade page on your site. Upside: no store revenue share, only your provider's fees.&lt;/p&gt;

&lt;p&gt;What the policy asks for:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;If basic functionality requires payment, &lt;strong&gt;say so in the description&lt;/strong&gt; users read before installing.&lt;/li&gt;
&lt;li&gt;State clearly that you are the seller, not Google.&lt;/li&gt;
&lt;li&gt;Post your terms of sale, including refunds.&lt;/li&gt;
&lt;li&gt;In the Payments &amp;amp; distribution tab, select &lt;strong&gt;"Contains in-app purchases"&lt;/strong&gt; (this adds a badge to your listing).&lt;/li&gt;
&lt;li&gt;Add a physical address to your developer account. It will be shown publicly on the listing.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Two practical notes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Check licenses on your server.&lt;/strong&gt; Anything in the extension package is readable, so the client should only display entitlements, never decide them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make the free tier useful.&lt;/strong&gt; Free users write the early reviews that make paid conversion possible. A paywall in the first minute turns your first ratings into complaints.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Model 2: Affiliate links (only the way Google now requires)
&lt;/h2&gt;

&lt;p&gt;This is how coupon, cashback and shopping extensions earn money, and it's the area Google tightened most recently. After the late-2024 coupon-extension controversy, the Affiliate Ads policy was updated, with enforcement starting June 10, 2025. You need all three:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Disclosure in three places:&lt;/strong&gt; the store listing, the UI, and before installation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A direct user benefit:&lt;/strong&gt; a discount, cashback or donation. No benefit, no link.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A related user action before every code, link or cookie.&lt;/strong&gt; Silently refreshing a cookie or swapping out an existing affiliate code are listed violations.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In code, that means the affiliate step lives inside the click handler that delivers the benefit, and nowhere else:&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="c1"&gt;// popup.js: attribution only after the user asks for a discount that exists&lt;/span&gt;
&lt;span class="nx"&gt;applyButton&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;click&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="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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;offer&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;findValidOffer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;currentStore&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;offer&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;showMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;No working discount for this store right now.&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="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// no discount, no affiliate link&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;applyOffer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;offer&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// code + attribution applied together&lt;/span&gt;
  &lt;span class="nf"&gt;showMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Applied &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;offer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;code&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;offer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;savingsText&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Shipping to Firefox too? Mozilla doesn't allow modifying web content or redirects to add affiliate tags at all; affiliate promos are only fine inside UI clearly owned by the add-on. Design for both stores from day one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Models 3 and 4: Ads in your own UI, or low-risk sponsorships
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Ads&lt;/strong&gt; can work on new tab pages, dashboards and side panels, within limits:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Shown in context, clearly tied to your product, and removable via settings or uninstall&lt;/li&gt;
&lt;li&gt;Never imitating system notifications or warnings&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No AdSense&lt;/strong&gt; (its own policy forbids use in extensions)&lt;/li&gt;
&lt;li&gt;An extension whose &lt;em&gt;primary purpose&lt;/em&gt; is serving ads isn't allowed, and injecting ads into web pages alongside another feature breaks single purpose&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The pattern that holds up: one quiet, labelled ad slot on a page the user opened on purpose, plus an ad-free paid tier.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lower-risk options&lt;/strong&gt; earn less per user but rarely cause review trouble:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Donations&lt;/strong&gt; (GitHub Sponsors or a "Support this project" link) for open source and hobby tools&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sponsorships&lt;/strong&gt; in your newsletter, site or changelog. Inside the extension, a sponsor counts as an ad&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Team and enterprise licenses&lt;/strong&gt;, which often out-earn consumer plans because companies pay for admin controls, invoicing and private deployment&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The offers to ignore: data deals and bandwidth SDKs
&lt;/h2&gt;

&lt;p&gt;Most pitches promising "passive revenue" from "anonymized data" fail two policies:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Limited Use&lt;/strong&gt; bans selling user data to ad platforms or data brokers, and explicitly covers aggregated or de-identified data.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Single purpose&lt;/strong&gt; catches SDKs that turn the user's browser into something else. In October 2026, Chrome Extensions DevRel said a bandwidth-sharing SDK bundled into an extension with other features would likely violate it, "even if you have a clear opt-in from users."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In 2025, researchers found one such library in more than 200 Chrome, Edge and Firefox extensions, quietly using browsers to scrape sites.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick picker by extension type
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Extension type&lt;/th&gt;
&lt;th&gt;Model that fits&lt;/th&gt;
&lt;th&gt;Watch out for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Productivity / AI tools&lt;/td&gt;
&lt;td&gt;Freemium + paid plan&lt;/td&gt;
&lt;td&gt;Server cost per free user&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Shopping / coupons&lt;/td&gt;
&lt;td&gt;Affiliate on user action&lt;/td&gt;
&lt;td&gt;Disclosure in 3 places&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;New tab / dashboards&lt;/td&gt;
&lt;td&gt;Paid plan or labelled ad slot&lt;/td&gt;
&lt;td&gt;No AdSense&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Developer tools&lt;/td&gt;
&lt;td&gt;Paid or team license&lt;/td&gt;
&lt;td&gt;Server-side license checks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Open source / hobby&lt;/td&gt;
&lt;td&gt;Donations, sponsors&lt;/td&gt;
&lt;td&gt;Keep payment logic out of the package&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Key takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Paid plans are the safest model: bring your own payments, disclose what's paid, select "Contains in-app purchases".&lt;/li&gt;
&lt;li&gt;Affiliate links need disclosure in three places, a real benefit, and a user action every time.&lt;/li&gt;
&lt;li&gt;Ads must stay in your own UI, be removable, and never use AdSense.&lt;/li&gt;
&lt;li&gt;Data deals and bandwidth or scraping SDKs are off the table, opt-in or not.&lt;/li&gt;
&lt;li&gt;Choose the model before launch: it changes your permissions, privacy fields, listing copy and public account details.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The full article has the policy quotes, the sponsored-tile grey area and a pre-launch checklist: &lt;a href="https://extensionlaunch.com/blog/monetize-chrome-extension?ref=devto" rel="noopener noreferrer"&gt;how to monetize a Chrome extension without breaking Chrome Web Store policy&lt;/a&gt;. When your pricing is ready, you can launch it on &lt;a href="https://extensionlaunch.com/?ref=devto" rel="noopener noreferrer"&gt;ExtensionLaunch, a launch directory for new Chrome extensions&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Which monetization model are you running in your extension right now, and what conversion rate or revenue per user have you seen from it?&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>chrome</category>
      <category>startup</category>
      <category>javascript</category>
    </item>
    <item>
      <title>DEX code optimization warning: enable R8 without crashes</title>
      <dc:creator>ExtensionBooster</dc:creator>
      <pubDate>Thu, 05 Nov 2026 13:51:35 +0000</pubDate>
      <link>https://dev.to/extensionbooster/dex-code-optimization-warning-enable-r8-without-crashes-3c1o</link>
      <guid>https://dev.to/extensionbooster/dex-code-optimization-warning-enable-r8-without-crashes-3c1o</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://appranker.net/blog/play-console-dex-code-optimization-r8?ref=devto" rel="noopener noreferrer"&gt;AppRanker&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;If Play Console is showing you "DEX code optimization is below our threshold" next to something like "Obfuscation 2%", your release build almost certainly isn't running R8 at all. That is the default for several cross-platform templates, and from February 2027 it starts to matter. Below is what Google is measuring, which stacks ship with R8 off, and a release-safe way to switch it on.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the warning is actually measuring
&lt;/h2&gt;

&lt;p&gt;On August 26, 2026, Google Play announced new technical quality thresholds that take effect in February 2027. One of them is about DEX code: apps need at least &lt;strong&gt;25% coverage on each of three metrics&lt;/strong&gt; (shrinking, optimization and obfuscation) using R8 or another shrinker.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Apps&lt;/th&gt;
&lt;th&gt;Games&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Rule applies when DEX code exceeds&lt;/td&gt;
&lt;td&gt;10 MB&lt;/td&gt;
&lt;td&gt;50 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Minimum shrinking&lt;/td&gt;
&lt;td&gt;25%&lt;/td&gt;
&lt;td&gt;25%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Minimum optimization&lt;/td&gt;
&lt;td&gt;25%&lt;/td&gt;
&lt;td&gt;25%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Minimum obfuscation&lt;/td&gt;
&lt;td&gt;25%&lt;/td&gt;
&lt;td&gt;25%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;What each number means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Shrinking&lt;/strong&gt;: the share of classes, fields and methods R8 is allowed to drop when unused.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Optimization&lt;/strong&gt;: the share it may rewrite, for example by inlining or merging classes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Obfuscation&lt;/strong&gt;: the share it may rename to short identifiers.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Google says missing these "can affect an app's visibility and publishing capabilities on Google Play". A single-digit score doesn't mean R8 did a poor job. It means R8 never ran, or your keep rules left it almost nothing to touch.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a "JavaScript app" has 10 MB of DEX
&lt;/h2&gt;

&lt;p&gt;Your Hermes bundle or AOT-compiled Dart isn't DEX. But the framework runtime, native modules, Firebase, ad SDKs, analytics and billing libraries are all Java or Kotlin bytecode, and that adds up fast. Whether R8 runs depends on the template you started from:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Stack&lt;/th&gt;
&lt;th&gt;R8 in release by default&lt;/th&gt;
&lt;th&gt;Where to change it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;React Native CLI&lt;/td&gt;
&lt;td&gt;Off (&lt;code&gt;enableProguardInReleaseBuilds = false&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;android/app/build.gradle&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Expo SDK 57 and earlier&lt;/td&gt;
&lt;td&gt;Off&lt;/td&gt;
&lt;td&gt;&lt;code&gt;expo-build-properties&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Expo SDK 58&lt;/td&gt;
&lt;td&gt;On&lt;/td&gt;
&lt;td&gt;&lt;code&gt;android/gradle.properties&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Flutter&lt;/td&gt;
&lt;td&gt;Always on, optimize preset&lt;/td&gt;
&lt;td&gt;&lt;code&gt;android/app/proguard-rules.pro&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Capacitor&lt;/td&gt;
&lt;td&gt;Off, uses &lt;code&gt;proguard-android.txt&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;android/app/build.gradle&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;.NET for Android / MAUI&lt;/td&gt;
&lt;td&gt;Runs with &lt;code&gt;-dontobfuscate&lt;/code&gt; hardcoded&lt;/td&gt;
&lt;td&gt;Tracked upstream (dotnet/android #12535)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;First, check whether the rule even applies to you.&lt;/strong&gt; In Play Console go to Test and release &amp;gt; App bundle explorer, select your latest production bundle, and read the DEX size plus the three percentages. Under 10 MB? Not urgent yet, though still worth doing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Turning R8 on, per stack
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;React Native CLI and Capacitor&lt;/strong&gt; use the same release block in &lt;code&gt;android/app/build.gradle&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight groovy"&gt;&lt;code&gt;&lt;span class="kt"&gt;def&lt;/span&gt; &lt;span class="n"&gt;enableProguardInReleaseBuilds&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;

&lt;span class="n"&gt;android&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;buildTypes&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;release&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
      &lt;span class="n"&gt;minifyEnabled&lt;/span&gt; &lt;span class="n"&gt;enableProguardInReleaseBuilds&lt;/span&gt;
      &lt;span class="n"&gt;shrinkResources&lt;/span&gt; &lt;span class="n"&gt;enableProguardInReleaseBuilds&lt;/span&gt;
      &lt;span class="n"&gt;proguardFiles&lt;/span&gt; &lt;span class="nf"&gt;getDefaultProguardFile&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"proguard-android-optimize.txt"&lt;/span&gt;&lt;span class="o"&gt;),&lt;/span&gt; &lt;span class="s2"&gt;"proguard-rules.pro"&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
  &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two gotchas: use &lt;code&gt;proguard-android-optimize.txt&lt;/code&gt;, not &lt;code&gt;proguard-android.txt&lt;/code&gt; (the old file contains &lt;code&gt;-dontoptimize&lt;/code&gt;, and Android Gradle plugin 9 no longer supports it), and remove any leftover &lt;code&gt;android.enableR8.fullMode=false&lt;/code&gt; from &lt;code&gt;gradle.properties&lt;/code&gt;. On Capacitor, resist a blanket &lt;code&gt;-keep class com.getcapacitor.** { *; }&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Expo SDK 57 and earlier&lt;/strong&gt;: set it in &lt;code&gt;app.json&lt;/code&gt;, then &lt;code&gt;npx expo prebuild&lt;/code&gt; or rebuild on EAS. &lt;code&gt;extraProguardRules&lt;/code&gt; is appended to &lt;code&gt;proguard-rules.pro&lt;/code&gt;, so it survives a clean prebuild.&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;"expo"&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;span class="nl"&gt;"plugins"&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;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"expo-build-properties"&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;span class="nl"&gt;"android"&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;span class="nl"&gt;"enableMinifyInReleaseBuilds"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"enableShrinkResourcesInReleaseBuilds"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"extraProguardRules"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"-keep class com.example.reflected.Model { *; }"&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;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;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;On SDK 58 R8 is already on. If a release build breaks after upgrading, fix the keep rules instead of opting out, because opting out puts you back under the threshold.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Flutter&lt;/strong&gt; already runs R8, so a flagged Flutter app usually has a plugin or custom rule keeping whole packages. Dart's &lt;code&gt;--obfuscate&lt;/code&gt; flag doesn't help here: it renames Dart symbols, not DEX.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Native Android / KMP&lt;/strong&gt;: confirm &lt;code&gt;isMinifyEnabled&lt;/code&gt; in the app module. AGP 9.3+ also adds an &lt;code&gt;optimization&lt;/code&gt; block per build type, with keep rules allowed in &lt;code&gt;src/&amp;lt;variant&amp;gt;/keepRules/*.keep&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Avoiding "works in debug, crashes in release"
&lt;/h2&gt;

&lt;p&gt;R8 only touches release builds, so a debug build or dev client proves nothing. Run a real release variant on a physical device:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx react-native run-android &lt;span class="nt"&gt;--mode&lt;/span&gt; release   &lt;span class="c"&gt;# React Native CLI&lt;/span&gt;
npx expo run:android &lt;span class="nt"&gt;--variant&lt;/span&gt; release        &lt;span class="c"&gt;# Expo, local&lt;/span&gt;
./gradlew bundleRelease                       &lt;span class="c"&gt;# any Android project&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Typical symptoms when R8 removes or renames something accessed by name at runtime: &lt;code&gt;ClassNotFoundException&lt;/code&gt; at startup, &lt;code&gt;NoSuchFieldException&lt;/code&gt;/&lt;code&gt;NoSuchMethodException&lt;/code&gt;, or model fields that quietly vanish after JSON serialization. Expo issue #49779 is a good example: &lt;code&gt;Calendar.createEventAsync()&lt;/code&gt; failed only in minified SDK 57 builds.&lt;/p&gt;

&lt;p&gt;Rules for keep rules that fix crashes without sinking your scores:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Read &lt;code&gt;app/build/outputs/mapping/release/missing_rules.txt&lt;/code&gt; first; R8 writes what it needs there.&lt;/li&gt;
&lt;li&gt;Keep a class, not a package: &lt;code&gt;-keep class com.example.api.UserDto { *; }&lt;/code&gt;, never &lt;code&gt;com.example.**&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Prefer &lt;code&gt;-keepclassmembers&lt;/code&gt; when a JSON parser only needs the fields.&lt;/li&gt;
&lt;li&gt;Check whether the library already ships consumer rules before adding your own.&lt;/li&gt;
&lt;li&gt;Upgrade the crashing library; a missing rule is a library bug that may already be fixed.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Still flagged with R8 on? Run the analyzer
&lt;/h2&gt;

&lt;p&gt;A broad keep rule is the usual culprit, and Google's R8 Configuration Analyzer ranks rules by how much code each blocks. On AGP 9.3+:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;./gradlew :app:analyzeReleaseR8Config
&lt;span class="c"&gt;# report: app/build/reports/r8/r8-config-analyzer-release.html&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It also flags subsumed rules and rules matching nothing. Third-party SDKs are often to blame: one open issue on Google's ads mediation repo measured adapter consumer rules each blocking 5% to 10.6% of an app's live code.&lt;/p&gt;

&lt;p&gt;Last thing: Play deobfuscates its own crash and ANR reports from the bundle (AGP 4.1+), but Sentry, Crashlytics and Bugsnag each need the mapping file uploaded through their own plugin or CLI. Verify that step in CI.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Each of shrinking, optimization and obfuscation must hit 25% by February 2027 if your DEX exceeds 10 MB (50 MB for games).&lt;/li&gt;
&lt;li&gt;React Native CLI, Capacitor and Expo SDK 57 and earlier ship with R8 off; Flutter and Expo SDK 58 have it on.&lt;/li&gt;
&lt;li&gt;Use &lt;code&gt;proguard-android-optimize.txt&lt;/code&gt; and test a real release build on a device.&lt;/li&gt;
&lt;li&gt;Narrow, class-level keep rules beat package-wide ones; the R8 Configuration Analyzer shows which rules cost you most.&lt;/li&gt;
&lt;li&gt;Make sure your crash reporter gets the mapping file for every release.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The original has the full FAQ and sources in this &lt;a href="https://appranker.net/blog/play-console-dex-code-optimization-r8?ref=devto" rel="noopener noreferrer"&gt;guide to fixing the Play Console DEX code optimization warning with R8&lt;/a&gt;, and once your build is in shape you can list it on the &lt;a href="https://appranker.net/?ref=devto" rel="noopener noreferrer"&gt;AppRanker app leaderboard for Android and iOS apps&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;What was the first thing that broke when you switched R8 on in your React Native, Expo or Flutter release build?&lt;/p&gt;

</description>
      <category>android</category>
      <category>reactnative</category>
      <category>flutter</category>
      <category>mobile</category>
    </item>
    <item>
      <title>MCP Server in Node.js: What the v2 TypeScript SDK Changes</title>
      <dc:creator>ExtensionBooster</dc:creator>
      <pubDate>Thu, 05 Nov 2026 09:16:13 +0000</pubDate>
      <link>https://dev.to/extensionbooster/mcp-server-in-nodejs-what-the-v2-typescript-sdk-changes-45i2</link>
      <guid>https://dev.to/extensionbooster/mcp-server-in-nodejs-what-the-v2-typescript-sdk-changes-45i2</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://extenify.app/blog/mcp-server-node-js?ref=devto" rel="noopener noreferrer"&gt;Extenify&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;If you build an MCP server in Node.js today, most tutorials you find will still show the v1 &lt;code&gt;@modelcontextprotocol/sdk&lt;/code&gt; package and &lt;code&gt;server.tool()&lt;/code&gt;, and both are now behind the curve. Version 2 of the official TypeScript SDK is the stable line, it implements the 2026-07-28 MCP spec, and it changes how you structure a server. Here is what changed, a minimal stateless server, and the security checks worth adding before you connect Claude.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually changed in 2026
&lt;/h2&gt;

&lt;p&gt;The 2026-07-28 spec revision is the biggest shift since remote servers appeared. The parts that affect your code:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No more sessions.&lt;/strong&gt; Protocol-level sessions and the &lt;code&gt;Mcp-Session-Id&lt;/code&gt; header are gone from Streamable HTTP.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No initialize handshake.&lt;/strong&gt; Each request carries its protocol version and client capabilities in &lt;code&gt;_meta&lt;/code&gt;, and servers must implement &lt;code&gt;server/discover&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One stream for updates.&lt;/strong&gt; &lt;code&gt;subscriptions/listen&lt;/code&gt; replaces the HTTP GET endpoint and resource subscriptions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deprecations.&lt;/strong&gt; Roots, Sampling, Logging and the old HTTP+SSE transport are deprecated. Dynamic Client Registration gives way to Client ID Metadata Documents for OAuth.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The SDK follows suit. The single package is split into &lt;code&gt;@modelcontextprotocol/server&lt;/code&gt;, &lt;code&gt;@modelcontextprotocol/client&lt;/code&gt; and adapters such as &lt;code&gt;@modelcontextprotocol/node&lt;/code&gt;. You need Node.js 20+ and Zod 4 (Zod 3 is not supported), and &lt;code&gt;server.tool()&lt;/code&gt; is replaced by &lt;code&gt;registerTool()&lt;/code&gt; with a config object.&lt;/p&gt;

&lt;h2&gt;
  
  
  The upgrade trap
&lt;/h2&gt;

&lt;p&gt;Bumping the package version does &lt;strong&gt;not&lt;/strong&gt; make your server speak the new protocol. According to the v1-to-v2 migration guide, a server that still calls &lt;code&gt;server.connect(new StdioServerTransport())&lt;/code&gt; serves 2025-era clients only. You need &lt;code&gt;createMcpHandler()&lt;/code&gt; (HTTP) or &lt;code&gt;serveStdio()&lt;/code&gt; (local) to serve both old and new clients.&lt;/p&gt;

&lt;p&gt;This matters more than it sounds, because clients lag behind specs. In the original article's tests, both the v2 and v1 clients still opened with an &lt;code&gt;initialize&lt;/code&gt; request for protocol version 2025-11-25. Use the handler that speaks both and you stop caring which version a client picks. Existing projects can start from the official codemod:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx @modelcontextprotocol/codemod@latest v1-to-v2 &lt;span class="nb"&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  A minimal stateless server
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;createMcpHandler()&lt;/code&gt; takes a factory and calls it once per HTTP request, so nothing lives between requests and the endpoint scales horizontally. Here is the shape of it with a single read-only tool:&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="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createServer&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;node:http&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createMcpHandler&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;McpServer&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@modelcontextprotocol/server&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;hostHeaderValidation&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;originValidation&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;toNodeHandler&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@modelcontextprotocol/node&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;zod/v4&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;buildServer&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;authInfo&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;server&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;McpServer&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;my-stats&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;1.0.0&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="nx"&gt;server&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;registerTool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;get_summary&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;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Latest totals for one project owned by the caller.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;inputSchema&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;projectId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
    &lt;span class="na"&gt;annotations&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;readOnlyHint&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="na"&gt;openWorldHint&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="p"&gt;},&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;projectId&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="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;loadSummary&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;authInfo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;extra&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;projectId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// your DB call&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;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;text&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&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;stringify&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="p"&gt;}],&lt;/span&gt; &lt;span class="na"&gt;structuredContent&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="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;server&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;mcp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;toNodeHandler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;createMcpHandler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;buildServer&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;hosts&lt;/span&gt; &lt;span class="o"&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;localhost&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;127.0.0.1&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;checkHost&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;hostHeaderValidation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;hosts&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;checkOrigin&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;originValidation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;hosts&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nf"&gt;createServer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&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="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="nf"&gt;checkHost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&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="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;checkOrigin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&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="k"&gt;return&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;user&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;verifyBearer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;authorization&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// your token check&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;user&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;writeHead&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;401&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;WWW-Authenticate&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;Bearer&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;end&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;auth&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;token&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;clientId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pat&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;scopes&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;read&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="na"&gt;extra&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;user&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;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;mcp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&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="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;listen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;127.0.0.1&lt;/span&gt;&lt;span class="dl"&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 few details that are easy to miss:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The Zod schema is the contract.&lt;/strong&gt; The SDK turns it into JSON Schema for the client and rejects bad arguments before your handler runs, with an error the model can read.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;req.auth&lt;/code&gt; is how the caller reaches your tools.&lt;/strong&gt; &lt;code&gt;toNodeHandler()&lt;/code&gt; forwards it as &lt;code&gt;authInfo&lt;/code&gt;. The SDK never verifies tokens, so that must happen first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep the factory cheap.&lt;/strong&gt; Create database clients once at module scope, not inside &lt;code&gt;buildServer&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Return errors as results.&lt;/strong&gt; An &lt;code&gt;isError: true&lt;/code&gt; result with a plain-language message ("call list_projects to see valid IDs") helps the model recover instead of failing.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For a personal tool, &lt;code&gt;serveStdio()&lt;/code&gt; from &lt;code&gt;@modelcontextprotocol/server/stdio&lt;/code&gt; accepts the same factory, so HTTP and stdio can share one set of tool definitions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Security checks a model-driven API needs
&lt;/h2&gt;

&lt;p&gt;An MCP server is an API that a model drives, which adds a few risks on top of the usual ones:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Validate Host and Origin.&lt;/strong&gt; The spec says servers must validate &lt;code&gt;Origin&lt;/code&gt; to prevent DNS rebinding and should bind to localhost when local. The &lt;code&gt;@modelcontextprotocol/node&lt;/code&gt; guards return 403 for an unknown Host or Origin and let Origin-less (non-browser) requests through.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scope every query to the caller.&lt;/strong&gt; Never accept a user ID as a tool argument, because a model can be talked into passing someone else's.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hash tokens at rest&lt;/strong&gt; and keep them out of URLs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Treat text output as untrusted.&lt;/strong&gt; If a tool returns user-written text such as reviews, "ignore previous instructions" reaches the model verbatim. Keep those tools read-only and away from tools that write.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pick auth for the client.&lt;/strong&gt; A bearer token works for Claude Code and scripts. For per-user data in claude.ai you need OAuth 2.0, and sign-in starts only when your server answers 401 with a &lt;code&gt;WWW-Authenticate&lt;/code&gt; header.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Design tools for the model, not for humans
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Return computed answers ("+1.5% in 7 days") rather than raw rows the model has to subtract.&lt;/li&gt;
&lt;li&gt;Stay within client limits: about 150,000 characters per tool result and a 240-second timeout on claude.ai and Claude Desktop, and 25,000 tokens by default in Claude Code (&lt;code&gt;MAX_MCP_OUTPUT_TOKENS&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;Write descriptions that say when to call the tool and what the arguments look like.&lt;/li&gt;
&lt;li&gt;Register tools in a stable order. The new spec asks for a deterministic &lt;code&gt;tools/list&lt;/code&gt; order to help client and prompt caching.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Key takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Use &lt;code&gt;@modelcontextprotocol/server&lt;/code&gt; plus an adapter for new servers; v2 needs Node.js 20+ and Zod 4.&lt;/li&gt;
&lt;li&gt;Upgrading the package alone keeps you on the 2025 protocol. Use &lt;code&gt;createMcpHandler()&lt;/code&gt; or &lt;code&gt;serveStdio()&lt;/code&gt; to serve both generations of clients.&lt;/li&gt;
&lt;li&gt;Stateless per-request servers are the default now; sessions are gone from the spec.&lt;/li&gt;
&lt;li&gt;Validate Host and Origin, scope every query to the authenticated user, and mark data tools &lt;code&gt;readOnlyHint: true&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Keep results small and descriptions model-friendly.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The original post walks through the full build, including a MongoDB data layer, three analytics tools, end-to-end tests with both SDK clients and connecting to Claude: read the &lt;a href="https://extenify.app/blog/mcp-server-node-js?ref=devto" rel="noopener noreferrer"&gt;complete guide to building an MCP server in Node.js with the v2 SDK&lt;/a&gt;. If the data you want to expose is browser extension stats, &lt;a href="https://extenify.app/?ref=devto" rel="noopener noreferrer"&gt;Extenify tracks daily users, ratings and versions across Chrome, Edge and Firefox stores&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Have you moved an MCP server to the v2 SDK yet, and did the stateless model break anything that relied on sessions?&lt;/p&gt;

</description>
      <category>node</category>
      <category>ai</category>
      <category>javascript</category>
      <category>mcp</category>
    </item>
    <item>
      <title>Extension Not in Chrome Web Store Search? 9 Causes to Check</title>
      <dc:creator>ExtensionBooster</dc:creator>
      <pubDate>Thu, 05 Nov 2026 05:36:51 +0000</pubDate>
      <link>https://dev.to/extensionbooster/extension-not-in-chrome-web-store-search-9-causes-to-check-8i6</link>
      <guid>https://dev.to/extensionbooster/extension-not-in-chrome-web-store-search-9-causes-to-check-8i6</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://extensionbooster.net/blog/extension-not-showing-in-chrome-web-store-search-fixes/?ref=devto" rel="noopener noreferrer"&gt;ExtensionBooster&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Your extension not showing in Chrome Web Store search is a confusing bug to chase: the dashboard says it's live and the listing URL loads, yet typing its name into the store returns nothing. Behind that one symptom sit two separate problems, and the fix depends on which one you have. Here's a quick way to tell them apart, plus the nine causes worth checking.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step zero: hidden, or just ranked low?
&lt;/h2&gt;

&lt;p&gt;Open a signed-out Chrome profile or a Guest window first, so your own account doesn't skew what you see. Then try:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A search for your &lt;strong&gt;exact extension name&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;The same exact-name search from a signed-in adult Google account.&lt;/li&gt;
&lt;li&gt;A search for your &lt;strong&gt;main keyword&lt;/strong&gt; (say, "tab manager").&lt;/li&gt;
&lt;li&gt;Your listing URL opened directly.&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;th&gt;Diagnosis&lt;/th&gt;
&lt;th&gt;Causes to check&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Listing URL fails or shows a notice&lt;/td&gt;
&lt;td&gt;Not published, or taken down&lt;/td&gt;
&lt;td&gt;2, 7&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;URL works, exact name returns nothing&lt;/td&gt;
&lt;td&gt;Hidden from that searcher&lt;/td&gt;
&lt;td&gt;1, 3, 4, 5, 6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Exact name works, keyword doesn't&lt;/td&gt;
&lt;td&gt;Indexed, but ranks low&lt;/td&gt;
&lt;td&gt;8, 9&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Causes 1–4: status and distribution settings
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. You published a few hours ago.&lt;/strong&gt; Google's discovery docs say a new extension may take a few hours to be indexed. Give it a day before digging in. Don't resubmit to "nudge" the index; a new submission goes back into review, which can take days.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. It isn't live yet.&lt;/strong&gt; Check the dashboard status:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Pending review&lt;/strong&gt;: most reviews finish in a few days, some take a few weeks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Staged&lt;/strong&gt;: if you turned off automatic publishing, the item waits for you. You get 30 days to publish before it reverts to a draft and has to be reviewed again.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rejected&lt;/strong&gt;: users get no notice, so the rejection email and the dashboard are your only signals.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;3. Visibility is Unlisted or Private.&lt;/strong&gt; This one fools people because Unlisted items install fine from a direct link. Unlisted creates no store listing at all, so it never appears in search. Switch it to Public in the Distribution tab.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. The searcher's country isn't in your Regions.&lt;/strong&gt; Published items only show in search for the regions you selected. A limited launch or a test setting you forgot to revert is the usual culprit. Select all regions, then clear only the ones you really want to exclude.&lt;/p&gt;

&lt;h2&gt;
  
  
  Causes 5–7: language, maturity, and takedowns
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;5. Your listing language doesn't match the query.&lt;/strong&gt; Someone searching in Spanish is matched against your English text. Add localized listings (name, summary, description) for your biggest non-English markets.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;6. The Mature flag is on.&lt;/strong&gt; Mature items don't appear in search for signed-out users, only for signed-in adult accounts. If it shows up signed in but not signed out, check this flag under Additional fields in the Store listing tab.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;7. It was taken down, or it's still Manifest V2.&lt;/strong&gt; Taken-down items vanish from search, collections and category pages. And as of &lt;strong&gt;August 31, 2026&lt;/strong&gt;, all remaining MV2 extensions were removed from the store. If yours was still on MV2, migrating to Manifest V3 and resubmitting is the only way back.&lt;/p&gt;

&lt;h2&gt;
  
  
  Causes 8–9: it's indexed, it just ranks low
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;8. Low ranking for your keyword.&lt;/strong&gt; This is the most common answer. Google says search ranking draws on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the item name&lt;/li&gt;
&lt;li&gt;description relevance&lt;/li&gt;
&lt;li&gt;popularity and user experience&lt;/li&gt;
&lt;li&gt;number of ratings and average rating&lt;/li&gt;
&lt;li&gt;usage stats, such as downloads vs. uninstalls over time&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A brand-new listing with zero ratings is up against items with thousands of users for the same term. What helps, roughly by impact:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A name and summary (132 characters max) that say what the extension &lt;em&gt;does&lt;/em&gt;, not just the brand.&lt;/li&gt;
&lt;li&gt;A description built around one use case, with the keyword used naturally in the opening lines.&lt;/li&gt;
&lt;li&gt;A steady flow of ratings from real users.&lt;/li&gt;
&lt;li&gt;Onboarding that stops installs from turning into quick uninstalls.&lt;/li&gt;
&lt;li&gt;Traffic from outside the store, which Google itself suggests to help an item rise.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;9. Keyword stuffing or duplicate listings.&lt;/strong&gt; Repetitive or irrelevant keywords can get an item suspended, and the spam policy bans multiple extensions with duplicate functionality. Strip keyword lists and competitor names, and merge near-copies into one listing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick checklist
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[ ] Status = Published (not Pending, Staged or Rejected)
[ ] Visibility = Public
[ ] Regions include the searcher's country
[ ] Mature flag only if content really is mature
[ ] No takedown email; item is Manifest V3
[ ] A few hours (ideally a day) since publishing
[ ] Name + summary describe the core function
[ ] Keyword in the first lines of the description
[ ] No keyword lists or duplicate listings
[ ] Real ratings coming in, early uninstalls kept low
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Key takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Run an exact-name search signed out before anything else; it separates "hidden" from "ranked low".&lt;/li&gt;
&lt;li&gt;Hidden items are a dashboard problem: status, visibility, regions, the Mature flag or a takedown.&lt;/li&gt;
&lt;li&gt;MV2 extensions were removed from the store on August 31, 2026.&lt;/li&gt;
&lt;li&gt;Ranking draws on name, description relevance, ratings count and average, and installs vs. uninstalls.&lt;/li&gt;
&lt;li&gt;Keyword stuffing and duplicate listings can get you suspended, not boosted.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The original post has the exact Google documentation quotes and an FAQ: &lt;a href="https://extensionbooster.net/blog/extension-not-showing-in-chrome-web-store-search-fixes/?ref=devto" rel="noopener noreferrer"&gt;why your extension isn't showing in Chrome Web Store search, with 9 fixes&lt;/a&gt;. For more on ratings and listing growth, see the &lt;a href="https://extensionbooster.net/?ref=devto" rel="noopener noreferrer"&gt;ExtensionBooster guides for Chrome extension developers&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Have you ever had a live extension that refused to show up in store search? Which of these causes turned out to be the culprit for you?&lt;/p&gt;

</description>
      <category>chrome</category>
      <category>webdev</category>
      <category>seo</category>
      <category>extensions</category>
    </item>
    <item>
      <title>Product Hunt Alternatives for Extensions: 7 Sites Compared</title>
      <dc:creator>ExtensionBooster</dc:creator>
      <pubDate>Thu, 05 Nov 2026 02:09:29 +0000</pubDate>
      <link>https://dev.to/extensionbooster/product-hunt-alternatives-for-extensions-7-sites-compared-jhk</link>
      <guid>https://dev.to/extensionbooster/product-hunt-alternatives-for-extensions-7-sites-compared-jhk</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://extensionlaunch.com/blog/product-hunt-alternatives?ref=devto" rel="noopener noreferrer"&gt;ExtensionLaunch&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;If you're hunting for Product Hunt alternatives for a browser extension, you've probably hit the same walls: your launch might not get featured, a new account has to wait a week before it can post, and you can't relaunch the same product for two months. Here's a comparison of seven other launch platforms, what each one costs, and a simple way to stack them over a month.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Product Hunt alone isn't enough for an extension
&lt;/h2&gt;

&lt;p&gt;Product Hunt is still the best-known launch site, but its rules shape who wins:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Featuring isn't guaranteed&lt;/strong&gt;, and the guidelines say decisions are generally final.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;New accounts wait a week&lt;/strong&gt; before posting.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You need a two-month gap&lt;/strong&gt; (and a significant update) to relaunch.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The audience is broad.&lt;/strong&gt; SaaS, AI and hardware all compete for the same front page, and makers who already have a following do best.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A smaller platform that's focused on your niche often sends fewer visitors, but more of them actually install.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 7 alternatives at a glance
&lt;/h2&gt;

&lt;p&gt;Details as checked on October 30, 2026:&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;Best for&lt;/th&gt;
&lt;th&gt;Free path&lt;/th&gt;
&lt;th&gt;Paid path&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;ExtensionLaunch&lt;/td&gt;
&lt;td&gt;Extensions and mobile apps&lt;/td&gt;
&lt;td&gt;Daily quota of free listings&lt;/td&gt;
&lt;td&gt;One-time plans&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hacker News (Show HN)&lt;/td&gt;
&lt;td&gt;Developer tools&lt;/td&gt;
&lt;td&gt;Free, no queue&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Uneed&lt;/td&gt;
&lt;td&gt;Any digital tool&lt;/td&gt;
&lt;td&gt;Waiting line plus vote threshold&lt;/td&gt;
&lt;td&gt;$14.99 fast-track / $39.99 pick your date&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Peerlist Launchpad&lt;/td&gt;
&lt;td&gt;Makers with a dev or design profile&lt;/td&gt;
&lt;td&gt;Free with a verified profile&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;BetaList&lt;/td&gt;
&lt;td&gt;Startups with their own website&lt;/td&gt;
&lt;td&gt;None found&lt;/td&gt;
&lt;td&gt;Plan chosen at submission&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DevHunt&lt;/td&gt;
&lt;td&gt;Developer tools only&lt;/td&gt;
&lt;td&gt;Free listing&lt;/td&gt;
&lt;td&gt;$19 chosen week / $49 boosted&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tiny Startups&lt;/td&gt;
&lt;td&gt;Small, bootstrapped products&lt;/td&gt;
&lt;td&gt;Hand-approved&lt;/td&gt;
&lt;td&gt;£69 listing&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Most platforms work the same way: &lt;strong&gt;a free path with a catch&lt;/strong&gt; (a long queue, a vote threshold or a verified profile) and &lt;strong&gt;a paid path that skips it&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The catches worth knowing before you submit
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Show HN&lt;/strong&gt; needs something people can try right away, ideally without a signup or email. Landing pages, waitlists and newsletters don't qualify. Expect comments on broad permissions or required accounts, and never ask friends to upvote.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Uneed's free line&lt;/strong&gt; assigns you a date at least 30 days out, and when this was checked the next free date was May 2027. You need 10 upvotes to stay published and 20 for a do-follow link. If you fall below 10, your launch goes back to your dashboard and can't rejoin the free line. The $14.99 fast-track gets you a date about two weeks out.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;BetaList&lt;/strong&gt; won't accept an app store page as your product URL, so you need a landing page on your own domain first. It features about 20 startups a day to a newsletter of 70,000+ subscribers, but it won't feature the same site twice within 12 months. You get one shot.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;DevHunt&lt;/strong&gt; is only for dev tools: APIs, SDKs, frameworks, IDE add-ons, testing and monitoring. Voting needs a GitHub login, and launches go out in a Tuesday newsletter.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Peerlist&lt;/strong&gt; launches weekly on Mondays and needs a verified profile and a 100% complete project. It works best if you're already active there.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tiny Startups&lt;/strong&gt; suits extensions for founders, marketers and small teams. Its newsletter reaches 25,000+ subscribers.&lt;/p&gt;

&lt;p&gt;(Disclosure carried over from the original: the author of the source guide runs ExtensionLaunch, a launch directory that builds your listing from a store link or ID.)&lt;/p&gt;

&lt;h2&gt;
  
  
  How to stagger launches without burning out
&lt;/h2&gt;

&lt;p&gt;Here's a reusable method:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Match the platform to the user.&lt;/strong&gt; Dev tools go to Show HN and DevHunt. Consumer extensions go to extension directories and broad discovery sites.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write one product brief and reuse it:&lt;/strong&gt; name, 60-character tagline, 160-character summary, description, category, pricing, logo, screenshots and demo video.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Space launches a week or more apart&lt;/strong&gt;, so you have time to answer every comment.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tag every link with its own &lt;code&gt;utm_source&lt;/code&gt;.&lt;/strong&gt; The Chrome Web Store's Google Analytics integration records installs, so you can compare platforms directly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Judge by week-one retention, not votes.&lt;/strong&gt; A hundred upvotes from other makers is worth less than twenty installs that stick.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://chromewebstore.google.com/detail/your-id?utm_source=uneed&amp;amp;utm_medium=launch
https://chromewebstore.google.com/detail/your-id?utm_source=peerlist&amp;amp;utm_medium=launch
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A sample four-week plan for a consumer extension:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Week 1:&lt;/strong&gt; extension directory, once the store listing is public&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Week 2:&lt;/strong&gt; Product Hunt&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Week 3:&lt;/strong&gt; a paid Uneed date&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Week 4:&lt;/strong&gt; Peerlist, the following Monday&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For a dev tool, swap Show HN and DevHunt into that plan.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Product Hunt still has a place: it's free, it's big, and you keep a public page even if you aren't featured. Treat it as one date in a sequence.&lt;/li&gt;
&lt;li&gt;Launch where your users already browse, not where the biggest crowd is.&lt;/li&gt;
&lt;li&gt;Read the fine print: vote thresholds, custom-domain rules and one-time-only policies decide whether a launch is worth your time.&lt;/li&gt;
&lt;li&gt;Use one brief, one UTM tag per platform, and retention as the metric.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The full guide has per-platform details and an FAQ: &lt;a href="https://extensionlaunch.com/blog/product-hunt-alternatives?ref=devto" rel="noopener noreferrer"&gt;Product Hunt alternatives for launching a browser extension&lt;/a&gt;. If your extension's store listing is already live, you can also &lt;a href="https://extensionlaunch.com/?ref=devto" rel="noopener noreferrer"&gt;list it in the ExtensionLaunch extension directory&lt;/a&gt; as your first launch date.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Besides Product Hunt, which launch platform sent you the most installs that actually stuck, and roughly how many?&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>startup</category>
      <category>marketing</category>
      <category>chrome</category>
    </item>
    <item>
      <title>Android developer verification: keep your APK installable</title>
      <dc:creator>ExtensionBooster</dc:creator>
      <pubDate>Wed, 04 Nov 2026 13:51:05 +0000</pubDate>
      <link>https://dev.to/extensionbooster/android-developer-verification-keep-your-apk-installable-3acc</link>
      <guid>https://dev.to/extensionbooster/android-developer-verification-keep-your-apk-installable-3acc</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://appranker.net/blog/android-developer-verification-checklist?ref=devto" rel="noopener noreferrer"&gt;AppRanker&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Android developer verification has been enforced since September 30, 2026, and the failure mode is quiet: your build is fine, your CI is green, but a user on a certified phone in Brazil, Indonesia, Singapore or Thailand taps your APK and the normal install path simply refuses it. Google plans to roll the same rule out to every certified device in 2027. Below is what the rule actually checks, which commands get you the certificate data it asks for, and a checklist you can finish in one sitting.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the check looks at
&lt;/h2&gt;

&lt;p&gt;There are two things Android wants to see before it installs your app the normal way:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;A verified developer identity.&lt;/strong&gt; The account behind the app has passed ID verification (government ID for individuals; organizations also need a D-U-N-S number).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A registered package.&lt;/strong&gt; Each package name is tied to that account, along with the signing key or keys that sign it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If both are true, the app counts as "registered" and installs as usual. If either is missing, new installs through the standard flow are blocked in the enforcement regions. Google's stated motivation is malware: its March 2026 announcement says sideloaded sources carry over 90 times more malware than Google Play.&lt;/p&gt;

&lt;p&gt;Key dates:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;When&lt;/th&gt;
&lt;th&gt;What&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;March 2026&lt;/td&gt;
&lt;td&gt;Verification opens to all developers in Play Console and the Android Developer Console&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;June 2026&lt;/td&gt;
&lt;td&gt;Google confirms September 30 and the four launch countries&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;August 2026&lt;/td&gt;
&lt;td&gt;Developer APIs, limited distribution accounts and the user "advanced flow" ship&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;September 30, 2026&lt;/td&gt;
&lt;td&gt;Enforcement begins in Brazil, Indonesia, Singapore, Thailand&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2027&lt;/td&gt;
&lt;td&gt;Planned global rollout to all certified devices&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The first phase covers Google Play plus HONOR App Market, OPPO App Market, Samsung Galaxy Store, Palm Store, V-Appstore and GetApps.&lt;/p&gt;

&lt;h2&gt;
  
  
  How much work you have depends on where you ship
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Play-only apps.&lt;/strong&gt; Google says more than 99% of Play apps were registered automatically from details you already gave Play, and new apps get their package registered when you create them. Your job is a quick audit: check the Play Console Home page for any app flagged as needing registration, confirm your details under Settings &amp;gt; Developer account, and fix anything left over. Play Console's guidance warns that unregistered apps risk removal from Google Play globally, so do not skip this.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;APKs distributed elsewhere&lt;/strong&gt; (your website, partner stores, enterprise channels, F-Droid-style repos). This is where the real work is. With a Play Console account, you can register off-Play apps from inside Play Console. Without one, create an Android Developer Console account, verify your identity and register packages there. A full account is a one-time $25. If you are an organization without a D-U-N-S number, request it today: it can take up to 28 days.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Students and hobbyists.&lt;/strong&gt; A free limited distribution account needs no government ID but only reaches up to 20 devices. Good for a class project or a closed test group, not for a public release.&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting the certificate data with keytool and apksigner
&lt;/h2&gt;

&lt;p&gt;Registration works per package name. For a &lt;strong&gt;new&lt;/strong&gt; package you supply the public key certificate. For an &lt;strong&gt;existing&lt;/strong&gt; package with installs, you prove key ownership by uploading an APK signed with the matching private key.&lt;/p&gt;

&lt;p&gt;Print fingerprints and export the certificate from your keystore (swap in your own path and alias):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# SHA-256 and other fingerprints for the signing key&lt;/span&gt;
keytool &lt;span class="nt"&gt;-list&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="nt"&gt;-keystore&lt;/span&gt; release.jks &lt;span class="nt"&gt;-alias&lt;/span&gt; release

&lt;span class="c"&gt;# Public key certificate as PEM, for registering a new package&lt;/span&gt;
keytool &lt;span class="nt"&gt;-exportcert&lt;/span&gt; &lt;span class="nt"&gt;-rfc&lt;/span&gt; &lt;span class="nt"&gt;-keystore&lt;/span&gt; release.jks &lt;span class="nt"&gt;-alias&lt;/span&gt; release &lt;span class="nt"&gt;-file&lt;/span&gt; release-cert.pem
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Before registering, confirm the key in your keystore is the one your users actually have installed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;apksigner verify &lt;span class="nt"&gt;--print-certs&lt;/span&gt; app-release.apk
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the fingerprints disagree, stop. The usual causes are an old key, a key held by a build service, or a release someone else signed. Once they match, sign the proof APK the console asks for:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;apksigner sign &lt;span class="nt"&gt;--ks&lt;/span&gt; release.jks &lt;span class="nt"&gt;--ks-key-alias&lt;/span&gt; release &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--out&lt;/span&gt; signed-proof.apk unsigned-proof.apk
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In Play Console the flow is: Android developer verification page &amp;gt; &lt;strong&gt;Package names&lt;/strong&gt; tab &amp;gt; &lt;strong&gt;Register package name&lt;/strong&gt;. You get an email when it completes.&lt;/p&gt;

&lt;p&gt;A few edge cases worth knowing: if one package name has been signed by several keys (forks, repackaged builds), the key behind more than 50% of known installs gets priority. With no majority, any key with at least 50 installs can register directly; smaller ones go through a request to Google. Agencies handling many packages can automate this with the Android Developer ID Status API and the Android Developer Console API.&lt;/p&gt;

&lt;h2&gt;
  
  
  What still works without registration
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;ADB installs are exempt&lt;/strong&gt;, so your local dev and test loop does not change.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The advanced flow&lt;/strong&gt; lets power users allow unverified apps: enable developer mode, confirm no one is pressuring them, restart, wait a day, re-authenticate with biometrics or PIN, then allow unverified installs for seven days or indefinitely, with a warning on every install.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That second path is a safety valve, not a distribution channel. Hardly any regular user will wait 24 hours to install your app. Treat registration as required.&lt;/p&gt;

&lt;h2&gt;
  
  
  The checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Identity verified (Play Console Settings &amp;gt; Developer account, or Android Developer Console)&lt;/li&gt;
&lt;li&gt;[ ] D-U-N-S number on file if you publish as an organization&lt;/li&gt;
&lt;li&gt;[ ] Chosen the right account type: free limited (20 devices) or full ($25)&lt;/li&gt;
&lt;li&gt;[ ] Listed every package you distribute, on and off Play, including old downloads&lt;/li&gt;
&lt;li&gt;[ ] Checked each package's registration status&lt;/li&gt;
&lt;li&gt;[ ] Exported the certificate (new) or prepared a signed proof APK (existing)&lt;/li&gt;
&lt;li&gt;[ ] Matched fingerprints with &lt;code&gt;apksigner verify --print-certs&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;[ ] Removed download links for packages you no longer maintain&lt;/li&gt;
&lt;li&gt;[ ] Documented which key signs each package and who controls it&lt;/li&gt;
&lt;li&gt;[ ] Added "register package before announcing" to your release checklist&lt;/li&gt;
&lt;li&gt;[ ] Told sideload users in the four countries their updates will keep working&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Key takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Enforcement is live in four countries now and is planned globally for 2027.&lt;/li&gt;
&lt;li&gt;Play-only developers are mostly registered already, but should still audit Play Console.&lt;/li&gt;
&lt;li&gt;Off-Play APKs need explicit registration; start D-U-N-S early because it can take 28 days.&lt;/li&gt;
&lt;li&gt;Use &lt;code&gt;keytool&lt;/code&gt; and &lt;code&gt;apksigner&lt;/code&gt; to make sure the key you register is the key users already trust.&lt;/li&gt;
&lt;li&gt;ADB stays exempt, so testing is unaffected.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The original post has the full timeline, FAQ and sources in its &lt;a href="https://appranker.net/blog/android-developer-verification-checklist?ref=devto" rel="noopener noreferrer"&gt;Android developer verification checklist for app makers&lt;/a&gt;. Once your app is installable again, it still has to be found, and &lt;a href="https://appranker.net/?ref=devto" rel="noopener noreferrer"&gt;AppRanker's app leaderboard&lt;/a&gt; links each listing straight to its store page.&lt;/p&gt;

&lt;p&gt;If you ship APKs outside Google Play, what was the hardest part of registering your packages: the identity step, the D-U-N-S wait, or tracking down which key signed your old releases?&lt;/p&gt;

</description>
      <category>android</category>
      <category>mobile</category>
      <category>security</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Gemini API logging is on by default now: opt out in Node.js</title>
      <dc:creator>ExtensionBooster</dc:creator>
      <pubDate>Wed, 04 Nov 2026 09:15:18 +0000</pubDate>
      <link>https://dev.to/extensionbooster/gemini-api-logging-is-on-by-default-now-opt-out-in-nodejs-1d0m</link>
      <guid>https://dev.to/extensionbooster/gemini-api-logging-is-on-by-default-now-opt-out-in-nodejs-1d0m</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://extenify.app/blog/gemini-api-logging?ref=devto" rel="noopener noreferrer"&gt;Extenify&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Gemini API logging flipped to "on" for every paid project created on or after October 26, 2026, and nothing in your code or your error logs will tell you. From that date, &lt;code&gt;generateContent&lt;/code&gt; calls in new projects keep the full prompt, the response and earlier turns for up to 55 days. Here is what gets stored, who can read it, and the few lines of Node.js that put the decision back in your hands.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually changed
&lt;/h2&gt;

&lt;p&gt;Google's changelog says new paid-tier projects now &lt;strong&gt;store Generate Content requests by default&lt;/strong&gt;, the same way the Interactions API always has. Projects created before October 26 keep whatever they had, which is usually off.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;API&lt;/th&gt;
&lt;th&gt;Project created before Oct 26, 2026&lt;/th&gt;
&lt;th&gt;Created on or after Oct 26, 2026&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Generate Content&lt;/td&gt;
&lt;td&gt;Off unless someone enabled it&lt;/td&gt;
&lt;td&gt;On&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Interactions API&lt;/td&gt;
&lt;td&gt;On&lt;/td&gt;
&lt;td&gt;On&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The scope is wider than the name suggests:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Batch and streaming calls (&lt;code&gt;batchGenerateContent&lt;/code&gt;, &lt;code&gt;streamGenerateContent&lt;/code&gt;) are logged too.&lt;/li&gt;
&lt;li&gt;Calls through the OpenAI-compatible endpoint are logged.&lt;/li&gt;
&lt;li&gt;Only paid projects have logs. Free-tier keys have no logs but fall under different (looser) data terms.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A few inputs are never logged: Imagen, Veo, Gemini embedding models, the Robotics model, requests containing video, GIFs or PDFs, and public preview agents. So an embeddings pipeline is safe; a review classifier is not.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a log entry holds, and for how long
&lt;/h2&gt;

&lt;p&gt;This is not a token counter. Opening an entry in Google AI Studio's &lt;strong&gt;Logs&lt;/strong&gt; page shows the whole payload: prompt, response and prior conversation context. Anyone with access to the project can read it.&lt;/p&gt;

&lt;p&gt;Retention works like this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;55 days&lt;/strong&gt; by default, after which entries are marked for deletion.&lt;/li&gt;
&lt;li&gt;Owners can shorten it to &lt;strong&gt;7, 14 or 28 days&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Entries you save into a &lt;strong&gt;dataset never expire&lt;/strong&gt; until you delete the dataset.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;On the paid tier, logged data is not used for training. That changes only if you share a dataset with Google or join the invite-only Contributor Tier, both of which move the data under the Unpaid Services terms, where human reviewers may read it.&lt;/p&gt;

&lt;p&gt;One thing opting out does &lt;strong&gt;not&lt;/strong&gt; touch: Google's separate abuse monitoring still keeps prompts and outputs for 55 days, used only for policy enforcement.&lt;/p&gt;

&lt;h2&gt;
  
  
  Opt out per request in Node.js
&lt;/h2&gt;

&lt;p&gt;A request-level setting overrides the project setting, so the most dependable fix lives in code. In &lt;code&gt;@google/genai&lt;/code&gt; (2.31.0 or later), &lt;code&gt;store&lt;/code&gt; sits inside &lt;code&gt;config&lt;/code&gt;. Over REST it is a top-level &lt;code&gt;store&lt;/code&gt; field next to &lt;code&gt;contents&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The trap is scattered call sites. Wrap the SDK once and make "don't store" the default:&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="c1"&gt;// llm/gemini.js - the only file allowed to import @google/genai&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;GoogleGenAI&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@google/genai&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;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;GoogleGenAI&lt;/span&gt;&lt;span class="p"&gt;({});&lt;/span&gt; &lt;span class="c1"&gt;// uses GEMINI_API_KEY&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;askGemini&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;contents&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{},&lt;/span&gt; &lt;span class="nx"&gt;keepLog&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// store is set last, so a stray config.store can't re-enable logging&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;generateContent&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="nx"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;contents&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;config&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;config&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;store&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;keepLog&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;Then guard it in CI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# fail the build if anything else imports the SDK directly&lt;/span&gt;
&lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rl&lt;/span&gt; &lt;span class="s2"&gt;"@google/genai"&lt;/span&gt; src &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;*&lt;/span&gt;.js | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="s2"&gt;"llm/gemini.js"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pin the minimum SDK version in &lt;code&gt;package.json&lt;/code&gt;. Older versions silently ignore the setting.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Interactions API caveat:&lt;/strong&gt; it stores requests on purpose so you can continue a conversation with &lt;code&gt;previous_interaction_id&lt;/code&gt;. Its &lt;code&gt;store&lt;/code&gt; flag is a top-level argument, and turning it off means you must send the full history yourself on each turn.&lt;/p&gt;

&lt;h2&gt;
  
  
  Belt and braces: the project switch
&lt;/h2&gt;

&lt;p&gt;In AI Studio, open &lt;strong&gt;Logs&lt;/strong&gt;, pick the project that owns your production key and check the &lt;strong&gt;Settings&lt;/strong&gt; panel. It has separate toggles for Generate Content and Interactions. Turn Generate Content off unless you use it deliberately.&lt;/p&gt;

&lt;p&gt;You want both layers:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The &lt;strong&gt;request flag&lt;/strong&gt; protects you from the project someone creates next month with the new default.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;project switch&lt;/strong&gt; protects you from a call site that forgot the flag, and covers OpenAI-compatible calls where the docs show no per-request option.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Repeat the check for every project that holds a production key. New features and new environments often get fresh projects, which is exactly where the new default applies.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you keep logging on
&lt;/h2&gt;

&lt;p&gt;Logs are useful: real production prompts make better evaluation sets than invented ones. If you keep them, keep them tidy:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Cut retention to 7 or 14 days.&lt;/li&gt;
&lt;li&gt;Strip emails, phone numbers and order IDs before sending. A classifier rarely needs them.&lt;/li&gt;
&lt;li&gt;Treat datasets as permanent copies and delete them after the evaluation.&lt;/li&gt;
&lt;li&gt;Don't share datasets containing user content with Google.&lt;/li&gt;
&lt;li&gt;Limit who can open the project.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This matters most if you pipe user text through Gemini: store reviews, support tickets, or page content from a browser extension's AI feature. Make sure your privacy policy and store data disclosures still describe what your backend really does.&lt;/p&gt;

&lt;p&gt;For comparison, OpenAI's Responses API also keeps application state for 30 days by default and accepts &lt;code&gt;store: false&lt;/code&gt;. If you abstract several providers behind one client, make "no storage" the default there too.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Paid Gemini projects created on or after Oct 26, 2026 log full Generate Content requests by default; older projects are unchanged.&lt;/li&gt;
&lt;li&gt;Logs live up to 55 days; datasets built from them never expire.&lt;/li&gt;
&lt;li&gt;Send &lt;code&gt;store: false&lt;/code&gt; (SDK 2.31.0+) through a single wrapper, and switch off project-level logging too.&lt;/li&gt;
&lt;li&gt;Opting out doesn't stop Google's separate 55-day abuse-monitoring retention.&lt;/li&gt;
&lt;li&gt;If you keep logs, shorten retention and redact personal data first.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The original post has the full checklist, FAQ and the exact policy quotes in the &lt;a href="https://extenify.app/blog/gemini-api-logging?ref=devto" rel="noopener noreferrer"&gt;complete guide to Gemini API logging and how to opt out&lt;/a&gt;. If store reviews are the text you feed into an LLM, &lt;a href="https://extenify.app/?ref=devto" rel="noopener noreferrer"&gt;Extenify's app review and extension analytics dashboard&lt;/a&gt; collects them daily so you only send the ones that matter.&lt;/p&gt;

&lt;p&gt;Did your team find out about this default from the changelog, or from spotting full prompts in AI Studio's Logs page?&lt;/p&gt;

</description>
      <category>ai</category>
      <category>javascript</category>
      <category>node</category>
      <category>privacy</category>
    </item>
  </channel>
</rss>
