<?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: Anand Rathnas</title>
    <description>The latest articles on DEV Community by Anand Rathnas (@anand_rathnas_d5b608cc3de).</description>
    <link>https://dev.to/anand_rathnas_d5b608cc3de</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3671625%2F8642714b-af2d-4fc1-9097-c08fc07fdab5.png</url>
      <title>DEV Community: Anand Rathnas</title>
      <link>https://dev.to/anand_rathnas_d5b608cc3de</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/anand_rathnas_d5b608cc3de"/>
    <language>en</language>
    <item>
      <title>Why Our Android Build Was Signed with the Wrong Key (A Regex Cautionary Tale)</title>
      <dc:creator>Anand Rathnas</dc:creator>
      <pubDate>Fri, 22 May 2026 01:47:58 +0000</pubDate>
      <link>https://dev.to/anand_rathnas_d5b608cc3de/why-our-android-build-was-signed-with-the-wrong-key-a-regex-cautionary-tale-281g</link>
      <guid>https://dev.to/anand_rathnas_d5b608cc3de/why-our-android-build-was-signed-with-the-wrong-key-a-regex-cautionary-tale-281g</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;This article was originally published on &lt;a href="https://jo4.io/blog/expo-prebuild-android-signing-regex-bug/" rel="noopener noreferrer"&gt;Jo4 Blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;"Your Android App Bundle is not signed with the correct key."&lt;/p&gt;

&lt;p&gt;That was the Google Play Console rejection after what should have been a routine release. The SHA1 fingerprint on the uploaded AAB didn't match the expected upload key. We'd been deploying to the Play Store for weeks with no issues. What changed?&lt;/p&gt;

&lt;p&gt;Nothing, as it turned out. The signing had been broken &lt;em&gt;the whole time&lt;/em&gt; -- we just hadn't noticed until Google tightened its fingerprint check.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Setup
&lt;/h2&gt;

&lt;p&gt;We use Expo with &lt;code&gt;expo prebuild --clean&lt;/code&gt; to generate the &lt;code&gt;android/&lt;/code&gt; directory before each build. Because it's regenerated every time, the entire &lt;code&gt;android/&lt;/code&gt; folder is gitignored. This means any customization to &lt;code&gt;build.gradle&lt;/code&gt; needs to happen through a post-prebuild injection script.&lt;/p&gt;

&lt;p&gt;Our &lt;code&gt;scripts/release.sh&lt;/code&gt; runs after prebuild and uses Node.js to patch the generated &lt;code&gt;build.gradle&lt;/code&gt; with the release signing configuration. It finds the &lt;code&gt;release&lt;/code&gt; buildType block and replaces &lt;code&gt;signingConfig signingConfigs.debug&lt;/code&gt; with &lt;code&gt;signingConfig signingConfigs.release&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Sounds straightforward. But the regex doing this work had a subtle, devastating bug.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Symptom
&lt;/h2&gt;

&lt;p&gt;After &lt;code&gt;release.sh&lt;/code&gt; ran, we expected &lt;code&gt;build.gradle&lt;/code&gt; to look like this:&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="n"&gt;buildTypes&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;debug&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;signingConfig&lt;/span&gt; &lt;span class="n"&gt;signingConfigs&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;debug&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;signingConfig&lt;/span&gt; &lt;span class="n"&gt;signingConfigs&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;release&lt;/span&gt;  &lt;span class="c1"&gt;// patched&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;Instead, it looked like this:&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="n"&gt;buildTypes&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;debug&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;signingConfig&lt;/span&gt; &lt;span class="n"&gt;signingConfigs&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;release&lt;/span&gt;  &lt;span class="c1"&gt;// WRONG&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;signingConfig&lt;/span&gt; &lt;span class="n"&gt;signingConfigs&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;debug&lt;/span&gt;    &lt;span class="c1"&gt;// WRONG&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;The configs were &lt;strong&gt;swapped&lt;/strong&gt;. Debug was using the release keystore. Release was using the debug keystore. The build succeeded (both keystores are valid), but the release AAB was signed with the debug key.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Buggy Regex
&lt;/h2&gt;

&lt;p&gt;Here's the regex our script used:&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;patched&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;buildGradle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="sr"&gt;/release &lt;/span&gt;&lt;span class="se"&gt;\{[\s\S]&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt;&lt;span class="se"&gt;?&lt;/span&gt;&lt;span class="sr"&gt;signingConfig signingConfigs&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="sr"&gt;debug/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;match&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;match&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;signingConfig signingConfigs.debug&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;signingConfig signingConfigs.release&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;The intent: find &lt;code&gt;release {&lt;/code&gt; followed (lazily) by &lt;code&gt;signingConfig signingConfigs.debug&lt;/code&gt;, then replace that &lt;code&gt;debug&lt;/code&gt; with &lt;code&gt;release&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The problem: &lt;code&gt;release {&lt;/code&gt; appears &lt;strong&gt;twice&lt;/strong&gt; in the generated &lt;code&gt;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="n"&gt;signingConfigs&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;debug&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// ...&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="c1"&gt;// &amp;lt;-- FIRST occurrence of "release {"&lt;/span&gt;
        &lt;span class="n"&gt;storeFile&lt;/span&gt; &lt;span class="nf"&gt;file&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"release.keystore"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;storePassword&lt;/span&gt; &lt;span class="s2"&gt;"..."&lt;/span&gt;
        &lt;span class="n"&gt;keyAlias&lt;/span&gt; &lt;span class="s2"&gt;"..."&lt;/span&gt;
        &lt;span class="n"&gt;keyPassword&lt;/span&gt; &lt;span class="s2"&gt;"..."&lt;/span&gt;
    &lt;span class="o"&gt;}&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;debug&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;signingConfig&lt;/span&gt; &lt;span class="n"&gt;signingConfigs&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;debug&lt;/span&gt;   &lt;span class="c1"&gt;// &amp;lt;-- target line&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="c1"&gt;// &amp;lt;-- SECOND occurrence of "release {"&lt;/span&gt;
        &lt;span class="n"&gt;signingConfig&lt;/span&gt; &lt;span class="n"&gt;signingConfigs&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;debug&lt;/span&gt;   &lt;span class="c1"&gt;// &amp;lt;-- intended target&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;The lazy quantifier &lt;code&gt;[\s\S]*?&lt;/code&gt; matched the &lt;strong&gt;first&lt;/strong&gt; &lt;code&gt;release {&lt;/code&gt; (inside &lt;code&gt;signingConfigs&lt;/code&gt;), then expanded minimally until it found &lt;code&gt;signingConfig signingConfigs.debug&lt;/code&gt;. The first &lt;code&gt;signingConfig signingConfigs.debug&lt;/code&gt; it encountered was inside the &lt;strong&gt;debug buildType&lt;/strong&gt;. So the regex matched from &lt;code&gt;signingConfigs.release {&lt;/code&gt; all the way down to the debug buildType's signing config -- and replaced it.&lt;/p&gt;

&lt;p&gt;This is the core misunderstanding: lazy quantifiers don't find the &lt;em&gt;closest&lt;/em&gt; &lt;code&gt;release {&lt;/code&gt; to the target. They find the &lt;em&gt;first&lt;/em&gt; &lt;code&gt;release {&lt;/code&gt; in the file, then minimize the gap from there. If the first match is in the wrong block, the lazy expansion crosses block boundaries to reach the target string.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Fix
&lt;/h2&gt;

&lt;p&gt;Anchor the regex to the &lt;code&gt;buildTypes&lt;/code&gt; section:&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;patched&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;buildGradle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&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;buildTypes&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt;&lt;span class="se"&gt;\{[\s\S]&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt;&lt;span class="se"&gt;?&lt;/span&gt;&lt;span class="sr"&gt;release&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt;&lt;span class="se"&gt;\{[\s\S]&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt;&lt;span class="se"&gt;?)&lt;/span&gt;&lt;span class="sr"&gt;signingConfig signingConfigs&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="sr"&gt;debug/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;$1signingConfig signingConfigs.release&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;By requiring &lt;code&gt;buildTypes {&lt;/code&gt; before &lt;code&gt;release {&lt;/code&gt;, the regex skips the &lt;code&gt;signingConfigs.release&lt;/code&gt; block entirely. The capture group grabs everything from &lt;code&gt;buildTypes {&lt;/code&gt; through &lt;code&gt;release {&lt;/code&gt; and any content before the signing config line. Then we replace just the &lt;code&gt;signingConfig&lt;/code&gt; reference while preserving the surrounding structure.&lt;/p&gt;

&lt;p&gt;The key difference:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;BEFORE: /release \{[\s\S]*?signingConfig signingConfigs\.debug/
AFTER:  /(buildTypes\s*\{[\s\S]*?release\s*\{[\s\S]*?)signingConfig signingConfigs\.debug/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;buildTypes\s*\{&lt;/code&gt; anchor ensures we're in the right block before we ever look for &lt;code&gt;release {&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bonus Bug: versionCode Stuck at 1
&lt;/h2&gt;

&lt;p&gt;While debugging the signing issue, we found a second problem. Expo prebuild defaults &lt;code&gt;versionCode&lt;/code&gt; to &lt;code&gt;1&lt;/code&gt; in every generated &lt;code&gt;build.gradle&lt;/code&gt;. Google Play requires &lt;code&gt;versionCode&lt;/code&gt; to be strictly increasing -- you can never upload a version code equal to or lower than a previously uploaded one.&lt;/p&gt;

&lt;p&gt;Our fix: auto-generate &lt;code&gt;versionCode&lt;/code&gt; from epoch minutes in the release script:&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="nv"&gt;VERSION_CODE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$((&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%s&lt;span class="si"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="m"&gt;60&lt;/span&gt;&lt;span class="k"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This produces a value like &lt;code&gt;29432517&lt;/code&gt; that increases every minute. No manual tracking, no CI state to maintain, and no collisions as long as you don't release twice in the same minute.&lt;/p&gt;

&lt;p&gt;The Node.js injection then patches this into &lt;code&gt;build.gradle&lt;/code&gt;:&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="nx"&gt;buildGradle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;buildGradle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="sr"&gt;/versionCode &lt;/span&gt;&lt;span class="se"&gt;\d&lt;/span&gt;&lt;span class="sr"&gt;+/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="s2"&gt;`versionCode &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="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;VERSION_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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Lessons Learned
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Lazy quantifiers aren't always lazy enough.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;*?&lt;/code&gt; quantifier minimizes the match &lt;em&gt;after&lt;/em&gt; fixing the start position. If your start anchor (&lt;code&gt;release {&lt;/code&gt;) appears multiple times, the regex locks onto the first occurrence and expands from there. It doesn't backtrack to try the second occurrence unless the first one fails entirely.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. When a pattern appears in multiple blocks, anchor to the surrounding context.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Don't match &lt;code&gt;release {&lt;/code&gt; when you mean &lt;code&gt;buildTypes { ... release {&lt;/code&gt;. The extra context eliminates ambiguity. This applies to any structured text you're patching with regex -- Gradle files, XML, YAML, anything with nested blocks.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Gitignored generated files need persistent injection scripts -- and those scripts need tests.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;We tested the app. We tested the build. We never tested &lt;code&gt;release.sh&lt;/code&gt; in isolation. A simple assertion -- "after running the script, the release buildType should have &lt;code&gt;signingConfigs.release&lt;/code&gt;" -- would have caught this immediately.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Always verify build artifacts before pushing to a store.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A one-line check would have saved hours:&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;# Verify the AAB is signed with the correct key&lt;/span&gt;
jarsigner &lt;span class="nt"&gt;-verify&lt;/span&gt; &lt;span class="nt"&gt;-verbose&lt;/span&gt; &lt;span class="nt"&gt;-certs&lt;/span&gt; app-release.aab | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s2"&gt;"SHA1:"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the fingerprint doesn't match your expected upload key, stop. Don't submit and hope for the best.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Meta-Lesson
&lt;/h2&gt;

&lt;p&gt;Regex on structured data is inherently fragile. Every time &lt;code&gt;expo prebuild&lt;/code&gt; changes the generated &lt;code&gt;build.gradle&lt;/code&gt; format, our regex could break in new and creative ways. The real long-term fix is to use Expo's config plugins to inject signing configuration declaratively, removing the regex entirely. We're migrating to that approach now.&lt;/p&gt;

&lt;p&gt;But until then -- anchor your patterns, test your scripts, and verify your artifacts.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Have you been burned by regex matching across block boundaries?&lt;/strong&gt; What's your approach to patching generated build files? We'd love to hear about it in the comments.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Building &lt;a href="https://jo4.io" rel="noopener noreferrer"&gt;jo4.io&lt;/a&gt; -- a URL shortener with analytics, QR codes, and a mobile app that is now correctly signed.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>android</category>
      <category>debugging</category>
      <category>regex</category>
    </item>
    <item>
      <title>CDN Cache Invalidation: Why Deleted URLs Still Redirect (And How We Fixed It)</title>
      <dc:creator>Anand Rathnas</dc:creator>
      <pubDate>Wed, 20 May 2026 01:48:55 +0000</pubDate>
      <link>https://dev.to/anand_rathnas_d5b608cc3de/cdn-cache-invalidation-why-deleted-urls-still-redirect-and-how-we-fixed-it-18mm</link>
      <guid>https://dev.to/anand_rathnas_d5b608cc3de/cdn-cache-invalidation-why-deleted-urls-still-redirect-and-how-we-fixed-it-18mm</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;This article was originally published on &lt;a href="https://jo4.io/blog/cdn-cache-invalidation-stale-redirects/" rel="noopener noreferrer"&gt;Jo4 Blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;You deleted the URL. Redis says it's gone. The database confirms it. But users click the link and still get redirected to the destination. &lt;code&gt;cf-cache-status: HIT&lt;/code&gt;. Cloudflare is happily serving a cached copy that nobody told it to forget.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;We run a URL shortener behind Cloudflare CDN. For performance, we cache redirect responses at the edge with a 2-hour TTL. This means popular short URLs resolve in under 50ms globally without touching our origin.&lt;/p&gt;

&lt;p&gt;The issue surfaced when a customer deleted a short URL and then clicked it to verify. Still working. They tried again 30 minutes later. Still working. They opened a support ticket.&lt;/p&gt;

&lt;p&gt;Same story with URL updates. A user changed the destination from &lt;code&gt;https://old-site.com&lt;/code&gt; to &lt;code&gt;https://new-site.com&lt;/code&gt;. The short URL kept redirecting to the old destination. OG metadata updates had the same problem — social cards showed stale titles and images because the HTML page was cached at the edge.&lt;/p&gt;

&lt;p&gt;Three distinct mutations, all broken:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Delete URL&lt;/strong&gt; — link stays alive via CDN cache&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Update URL&lt;/strong&gt; — old destination served from CDN, old metadata in Redis&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Admin force-expire&lt;/strong&gt; — neither Redis nor CDN gets invalidated&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The Root Cause: Layered Caching, Partial Invalidation
&lt;/h2&gt;

&lt;p&gt;Our caching architecture has two layers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User Request → Cloudflare CDN (edge cache) → Spring Boot API → Redis (app cache) → PostgreSQL
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When a URL is deleted, the service correctly invalidated the Redis cache. But it never told Cloudflare. The CDN layer continued serving stale responses until the TTL expired naturally.&lt;/p&gt;

&lt;p&gt;The update path was worse. &lt;code&gt;UrlService.updateUrl()&lt;/code&gt; wrote the new destination to the database but invalidated neither Redis nor Cloudflare. Reads hit Redis first, got the old cached value, and never saw the database update.&lt;/p&gt;

&lt;p&gt;Admin operations were the worst. &lt;code&gt;AdminService.forceExpireUrl()&lt;/code&gt; and &lt;code&gt;AdminService.deleteUrl()&lt;/code&gt; updated the database directly and skipped both cache layers entirely. Admin code had been written as direct repository calls, bypassing the service-layer cache invalidation that regular user operations went through.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Fix: Purge Both Layers on Every Mutation
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Step 1: Add &lt;code&gt;purgeUrls()&lt;/code&gt; to CloudflareService
&lt;/h3&gt;

&lt;p&gt;Cloudflare exposes &lt;code&gt;POST /zones/{zone_id}/purge_cache&lt;/code&gt; with a &lt;code&gt;{"files": [...]}&lt;/code&gt; body. We wrapped it in a service method:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Async&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;purgeUrls&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;urls&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(!&lt;/span&gt;&lt;span class="n"&gt;isEnabled&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;urls&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;urls&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isEmpty&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// Cloudflare API allows max 30 URLs per purge request&lt;/span&gt;
    &lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;batches&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;partition&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;urls&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;batch&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;batches&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;purgeUrlBatch&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;batch&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;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;purgeUrlBatch&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;urls&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;endpoint&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;CLOUDFLARE_API_BASE&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;"/zones/"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;zoneId&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;"/purge_cache"&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;HttpHeaders&lt;/span&gt; &lt;span class="n"&gt;headers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;createHeaders&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
        &lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Object&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"files"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;urls&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="nc"&gt;HttpEntity&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Object&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;request&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;HttpEntity&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&amp;gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

        &lt;span class="nc"&gt;ResponseEntity&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;CloudflareResponse&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;restTemplate&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;exchange&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;endpoint&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;HttpMethod&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;POST&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;CloudflareResponse&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;class&lt;/span&gt;
        &lt;span class="o"&gt;);&lt;/span&gt;

        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getBody&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getBody&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;isSuccess&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;info&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Purged {} URL(s) from Cloudflare cache"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;urls&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;size&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;warn&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Cloudflare cache purge returned non-success for URLs: {}"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;urls&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;span class="k"&gt;catch&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Exception&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;warn&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Failed to purge Cloudflare cache: {}"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getMessage&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 design decisions here:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;@Async&lt;/code&gt; (fire-and-forget).&lt;/strong&gt; CDN purge should never block the user operation. If Cloudflare is slow or down, the delete/update still completes instantly. The cache will expire naturally via TTL as a fallback.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Batched in groups of 30.&lt;/strong&gt; Cloudflare's API limits purge requests to 30 URLs per call. A single short URL can produce up to 3 cacheable URLs (UI page, API endpoint, custom domain), so this limit matters for bulk operations.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Build the List of Cacheable URLs
&lt;/h3&gt;

&lt;p&gt;Each short URL can be cached under multiple paths. We need to purge all of them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;addCacheableUrls&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;urls&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;shortUrl&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;customDomain&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// UI page (HTML with OG tags, served via Cloudflare CDN)&lt;/span&gt;
    &lt;span class="n"&gt;urls&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;add&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;uiHost&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;"/a/"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;shortUrl&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="c1"&gt;// API endpoint (JSON, also cached by Cloudflare)&lt;/span&gt;
    &lt;span class="n"&gt;urls&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;add&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;apiHost&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;"/api/v1/public/a/"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;shortUrl&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="c1"&gt;// Custom domain URL (if configured)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;StringUtils&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isNotBlank&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;customDomain&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;urls&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;add&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"https://"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;customDomain&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;"/a/"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;shortUrl&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;For updates where the short URL or custom domain itself changed, we purge both old and new URLs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;purgeCloudflareCache&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;shortUrl&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;customDomain&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
                                   &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;oldShortUrl&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;oldCustomDomain&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;urlsToPurge&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;ArrayList&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&amp;gt;();&lt;/span&gt;
    &lt;span class="n"&gt;addCacheableUrls&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;urlsToPurge&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;shortUrl&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;customDomain&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;oldShortUrl&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;oldShortUrl&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;equals&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;shortUrl&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;addCacheableUrls&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;urlsToPurge&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;oldShortUrl&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;oldCustomDomain&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;oldCustomDomain&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;oldCustomDomain&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;equals&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;customDomain&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;urlsToPurge&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;add&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"https://"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;oldCustomDomain&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;"/a/"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;shortUrl&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(!&lt;/span&gt;&lt;span class="n"&gt;urlsToPurge&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isEmpty&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;cloudflareService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;purgeUrls&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;urlsToPurge&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;h3&gt;
  
  
  Step 3: Wire Into Every Mutation Path
&lt;/h3&gt;

&lt;p&gt;This is where the original bug lived. We had to audit every code path that mutates URL state:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;UrlService&lt;/strong&gt; (user-facing operations):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;updateUrl()&lt;/code&gt; — added Redis invalidation + Cloudflare purge&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;deleteUrl()&lt;/code&gt; — already had Redis invalidation, added Cloudflare purge&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;AdminService&lt;/strong&gt; (admin operations):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;forceExpireUrl()&lt;/code&gt; — added both Redis + Cloudflare invalidation&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;deleteUrl()&lt;/code&gt; — added both Redis + Cloudflare invalidation&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;refreshMetadata()&lt;/code&gt; — added Cloudflare purge (OG tags changed)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The admin fix required a dedicated helper since admin code was calling repositories directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;invalidateUrlCaches&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;UrlEntity&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;urlCacheService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;invalidateCache&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getShortUrl&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;

    &lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;urlsToPurge&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;ArrayList&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&amp;gt;();&lt;/span&gt;
    &lt;span class="n"&gt;urlsToPurge&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;add&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;uiHost&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;"/a/"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getShortUrl&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
    &lt;span class="n"&gt;urlsToPurge&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;add&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;apiHost&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;"/api/v1/public/a/"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getShortUrl&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;StringUtils&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isNotBlank&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getCustomDomain&lt;/span&gt;&lt;span class="o"&gt;()))&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;urlsToPurge&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;add&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"https://"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getCustomDomain&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;"/a/"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getShortUrl&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;cloudflareService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;purgeUrls&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;urlsToPurge&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;One method. Both cache layers. Called from every admin mutation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why &lt;code&gt;@Async&lt;/code&gt; Is the Right Call
&lt;/h2&gt;

&lt;p&gt;CDN purge is a network call to Cloudflare's API. It adds 100-300ms of latency. If we made it synchronous:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;User deletes a URL — waits an extra 200ms for Cloudflare confirmation&lt;/li&gt;
&lt;li&gt;Cloudflare API is down — user's delete fails or hangs&lt;/li&gt;
&lt;li&gt;Bulk operations — each URL adds another round-trip&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;With &lt;code&gt;@Async&lt;/code&gt;, the user operation completes immediately. The purge runs in the background thread pool. If it fails, the cache expires naturally via TTL (2 hours max). The user never notices.&lt;/p&gt;

&lt;p&gt;The tradeoff: there's a brief window (milliseconds to seconds) where the CDN might still serve stale content after an update. For a URL shortener, this is acceptable. For something like financial data, you'd want synchronous purge with error handling.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lessons Learned
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Cache invalidation has layers.&lt;/strong&gt; If your architecture has &lt;code&gt;CDN → Redis → Database&lt;/code&gt;, you need to invalidate from the outside in. Clearing Redis doesn't help if Cloudflare is still serving cached responses. Most requests never reach your app server when the CDN has a hit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Admin code is a blind spot.&lt;/strong&gt; Admin operations often bypass service-layer abstractions. They call repositories directly for flexibility, but that means they skip whatever cache invalidation the service layer provides. Audit every mutation path, not just the user-facing ones.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Fire-and-forget is correct for CDN purge.&lt;/strong&gt; Don't block user operations on external API calls. Use &lt;code&gt;@Async&lt;/code&gt;, log failures, and rely on TTL expiration as your safety net. The worst case is stale content for a bounded time window.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Enumerate all cacheable URLs.&lt;/strong&gt; A single logical resource can exist at multiple CDN URLs. Miss one and you have a partial purge. Our short URLs have three: the UI page, the API endpoint, and the custom domain variant. All three need purging.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Ever been bitten by a stale CDN cache hiding a "deleted" resource?&lt;/strong&gt; What's your cache invalidation strategy?&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Building &lt;a href="https://jo4.io" rel="noopener noreferrer"&gt;jo4.io&lt;/a&gt; - URL shortener with analytics, custom domains, and team workspaces.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>cloudflare</category>
      <category>caching</category>
      <category>webdev</category>
      <category>java</category>
    </item>
    <item>
      <title>How to Track Link Clicks with Meta Pixel for Facebook Retargeting</title>
      <dc:creator>Anand Rathnas</dc:creator>
      <pubDate>Mon, 18 May 2026 01:49:34 +0000</pubDate>
      <link>https://dev.to/anand_rathnas_d5b608cc3de/how-to-track-link-clicks-with-meta-pixel-for-facebook-retargeting-4g4n</link>
      <guid>https://dev.to/anand_rathnas_d5b608cc3de/how-to-track-link-clicks-with-meta-pixel-for-facebook-retargeting-4g4n</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;This article was originally published on &lt;a href="https://jo4.io/blog/meta-pixel-retargeting-jo4/" rel="noopener noreferrer"&gt;Jo4 Blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;You're running Facebook ads. You want to retarget people who clicked your links. But you're sending traffic to someone else's site (affiliate offer, client landing page, whatever) where you can't add your pixel.&lt;/p&gt;

&lt;p&gt;Here's how to fire Meta Pixel on every click using jo4's built-in retargeting.&lt;/p&gt;

&lt;h2&gt;
  
  
  How It Works
&lt;/h2&gt;

&lt;p&gt;When someone clicks your jo4 short link, the redirect page loads your Meta Pixel before sending them to the destination. Facebook sees the &lt;code&gt;PageView&lt;/code&gt; event with your pixel ID.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User clicks jo4.io/abc123
    ↓
Pixel fires (PageView event)
    ↓
User redirected to destination
    ↓
Facebook audience updated
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This happens in milliseconds. Your audience grows with every click.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A jo4 account (free tier works)&lt;/li&gt;
&lt;li&gt;A Meta Pixel ID from Facebook Business Manager&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 1: Get Your Meta Pixel ID
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Go to &lt;a href="https://business.facebook.com/events_manager" rel="noopener noreferrer"&gt;Facebook Events Manager&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Select your pixel (or create one)&lt;/li&gt;
&lt;li&gt;Copy the 15-16 digit Pixel ID&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;It looks like: &lt;code&gt;123456789012345&lt;/code&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Add Pixel to Your Link
&lt;/h2&gt;

&lt;p&gt;When creating or editing a link in jo4:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Expand the &lt;strong&gt;Retargeting&lt;/strong&gt; section&lt;/li&gt;
&lt;li&gt;Paste your Pixel ID in the &lt;strong&gt;Meta Pixel ID&lt;/strong&gt; field&lt;/li&gt;
&lt;li&gt;Save the link&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That's it. Every click on this link now fires your pixel.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Verify It's Working
&lt;/h2&gt;

&lt;p&gt;Use the &lt;a href="https://chrome.google.com/webstore/detail/meta-pixel-helper/fdgfkebogiimcoedlicjlajpkdmockpc" rel="noopener noreferrer"&gt;Meta Pixel Helper&lt;/a&gt; Chrome extension:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Install the extension&lt;/li&gt;
&lt;li&gt;Click your jo4 short link&lt;/li&gt;
&lt;li&gt;Check the extension icon - it should show a green checkmark&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;You'll see:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;PageView&lt;/code&gt; event fired&lt;/li&gt;
&lt;li&gt;Your Pixel ID in the details&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Advanced: Track Multiple Pixels
&lt;/h2&gt;

&lt;p&gt;Need different pixels for different campaigns? Each jo4 link can have its own pixel ID. Create separate links for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Different ad accounts&lt;/li&gt;
&lt;li&gt;Different clients&lt;/li&gt;
&lt;li&gt;A/B testing audiences&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Advanced: Combine with UTM Parameters
&lt;/h2&gt;

&lt;p&gt;jo4 passes UTM parameters through to your destination. Combine with pixel tracking for full attribution:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;jo4.io/abc123 (with Meta Pixel + UTMs)
    ↓
Pixel fires with PageView
    ↓
User lands on destination.com/?utm_source=facebook&amp;amp;utm_campaign=spring_sale
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now you have:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Facebook audience data (for retargeting)&lt;/li&gt;
&lt;li&gt;UTM tracking (for conversion attribution)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What Events Are Tracked?
&lt;/h2&gt;

&lt;p&gt;Currently, jo4 fires a &lt;code&gt;PageView&lt;/code&gt; event on every link click. This is what you need for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Building Custom Audiences ("People who clicked my links")&lt;/li&gt;
&lt;li&gt;Retargeting campaigns&lt;/li&gt;
&lt;li&gt;Lookalike audience creation&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For &lt;code&gt;Purchase&lt;/code&gt;, &lt;code&gt;Lead&lt;/code&gt;, or other conversion events, those fire on your destination site where the action happens.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cost Comparison
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Solution&lt;/th&gt;
&lt;th&gt;Cost&lt;/th&gt;
&lt;th&gt;Complexity&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Add pixel to destination&lt;/td&gt;
&lt;td&gt;$0&lt;/td&gt;
&lt;td&gt;Need site access&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Third-party redirect service&lt;/td&gt;
&lt;td&gt;$50-200/mo&lt;/td&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;jo4 retargeting&lt;/td&gt;
&lt;td&gt;$0-16/mo&lt;/td&gt;
&lt;td&gt;Low&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Most link shorteners charge extra for retargeting features. With jo4, it's included in every plan including free.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common Pitfalls
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Wrong Pixel ID format&lt;/strong&gt;: Make sure you're using the numeric Pixel ID, not the Pixel Name or Business Manager ID.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ad blockers&lt;/strong&gt;: Some users run ad blockers that prevent pixels from firing. This is normal - your tracked audience will be slightly smaller than total clicks.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pixel not verified&lt;/strong&gt;: Facebook requires domain verification for some features. The pixel will still fire for building audiences even without verification.&lt;/p&gt;

&lt;h2&gt;
  
  
  Result
&lt;/h2&gt;

&lt;p&gt;After setup, your Facebook audience grows automatically with every link click. No code changes to destination sites. No complex integrations.&lt;/p&gt;

&lt;p&gt;Check Events Manager after a few clicks - you'll see the PageView events with your short link URL.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Already using Meta Pixel with short links?&lt;/strong&gt; Share your setup in the comments!&lt;/p&gt;

&lt;p&gt;&lt;em&gt;&lt;a href="https://jo4.io" rel="noopener noreferrer"&gt;jo4.io&lt;/a&gt; - URL shortener with built-in retargeting pixels for marketers who track everything.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>marketing</category>
      <category>facebook</category>
      <category>analytics</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>iOS App Store Screenshots and Compliance: The Gotchas After Your Build Succeeds</title>
      <dc:creator>Anand Rathnas</dc:creator>
      <pubDate>Sat, 16 May 2026 01:36:05 +0000</pubDate>
      <link>https://dev.to/anand_rathnas_d5b608cc3de/ios-app-store-screenshots-and-compliance-the-gotchas-after-your-build-succeeds-2aje</link>
      <guid>https://dev.to/anand_rathnas_d5b608cc3de/ios-app-store-screenshots-and-compliance-the-gotchas-after-your-build-succeeds-2aje</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;This article was originally published on &lt;a href="https://jo4.io/blog/ios-appstore-screenshots-compliance/" rel="noopener noreferrer"&gt;Jo4 Blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Your EAS build succeeded. The IPA uploaded to App Store Connect. Time to submit for review, right?&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Click.&lt;/em&gt; "Unable to Add for Review."&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;App Store Connect has requirements that have nothing to do with your code. Screenshots need exact dimensions. Export compliance needs declarations for every country. Privacy questionnaires want to know about every SDK you use.&lt;/p&gt;

&lt;p&gt;Here's everything that blocked my submission and how I fixed it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Part 1: Screenshot Dimensions
&lt;/h2&gt;

&lt;p&gt;I ran the simulator, took screenshots, uploaded them. Error:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Screenshots must be 1284 x 2778 pixels
Uploaded: 1320 x 2868 pixels
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;iPhone 16 Pro Max uses different dimensions than what App Store Connect expects for the "6.5-inch display" category.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&gt;&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;# Resize all iPhone screenshots to App Store requirements&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;f &lt;span class="k"&gt;in&lt;/span&gt; ./assets/appstore/iphone/&lt;span class="k"&gt;*&lt;/span&gt;.png&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;sips &lt;span class="nt"&gt;-z&lt;/span&gt; 2778 1284 &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$f&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;sips&lt;/code&gt; is macOS's built-in image processing tool. The &lt;code&gt;-z&lt;/code&gt; flag resizes to exact dimensions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Part 2: iPad Screenshots - The Stretching Disaster
&lt;/h2&gt;

&lt;p&gt;"Easy," I thought. "Just resize the phone screenshots for iPad."&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;# DON'T DO THIS&lt;/span&gt;
sips &lt;span class="nt"&gt;-z&lt;/span&gt; 2732 2048 phone-screenshot.png &lt;span class="nt"&gt;--out&lt;/span&gt; ipad-screenshot.png
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The result looked like someone grabbed my UI and pulled it sideways. Buttons were ovals. Text was bloated. Everything was wrong.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The actual fix:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Boot an actual iPad simulator and take native screenshots:&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;# Start the iPad simulator&lt;/span&gt;
xcrun simctl boot &lt;span class="s2"&gt;"iPad Pro 13-inch (M4)"&lt;/span&gt;

&lt;span class="c"&gt;# Build and run on iPad&lt;/span&gt;
npx expo run:ios &lt;span class="nt"&gt;--device&lt;/span&gt; &lt;span class="s2"&gt;"iPad Pro 13-inch (M4)"&lt;/span&gt;

&lt;span class="c"&gt;# Take screenshots at native resolution&lt;/span&gt;
xcrun simctl io booted screenshot ./assets/appstore/ipad/screenshot01.png
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Phone and tablet are different form factors. The UI adapts. Resizing just stretches.&lt;/p&gt;

&lt;h2&gt;
  
  
  Part 3: The Screenshot Content Problem
&lt;/h2&gt;

&lt;p&gt;Now I had the right dimensions. But my app requires login. Screenshots of a login screen aren't compelling.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The solution: Onboarding carousel with demo data.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I created a branch (&lt;code&gt;ft/screenshots&lt;/code&gt;) with:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;An &lt;code&gt;OnboardingCarousel&lt;/code&gt; component showing app features&lt;/li&gt;
&lt;li&gt;Hardcoded demo data (fake URLs, fake analytics)&lt;/li&gt;
&lt;li&gt;A flag to show this instead of the login screen
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Check if we're in "demo mode" for screenshots&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;isDemoMode&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;OnboardingCarousel&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The carousel showed:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;"Shorten Any URL" with a mock input and result&lt;/li&gt;
&lt;li&gt;"Track Every Click" with demo analytics charts&lt;/li&gt;
&lt;li&gt;"Share From Anywhere" showing the iOS share sheet integration&lt;/li&gt;
&lt;li&gt;"Generate QR Codes" with a sample QR code&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Took screenshots of each carousel page. Stashed the changes. Real users never see it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Part 4: Export Compliance - The France Question
&lt;/h2&gt;

&lt;p&gt;Uploaded screenshots. Hit submit. New blocker:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Missing Compliance: Export Compliance Information Required
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The form asks about encryption. My app uses HTTPS. Does that count?&lt;/p&gt;

&lt;p&gt;Then it asks specifically about France:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Does your app qualify for any exemptions provided under category 5 part 2?"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The options mention DES, triple-DES, RC4... algorithms I'm not using.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The correct answer:&lt;/strong&gt; "None of the algorithms mentioned above"&lt;/p&gt;

&lt;p&gt;If your app only uses HTTPS (TLS) through iOS's built-in networking, you select that it uses encryption, but then confirm you're only using standard iOS APIs. No custom cryptographic implementations = no export restrictions beyond what Apple already handles.&lt;/p&gt;

&lt;h2&gt;
  
  
  Part 5: Privacy Declarations
&lt;/h2&gt;

&lt;p&gt;App Store Connect wants to know every piece of data your app collects. For each data type, you specify:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Is it linked to the user's identity?&lt;/li&gt;
&lt;li&gt;Is it used for tracking?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;My app uses Auth0 and Sentry. Here's what I declared:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Auth0:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Email Address: Linked to identity, not for tracking&lt;/li&gt;
&lt;li&gt;Name: Linked to identity, not for tracking&lt;/li&gt;
&lt;li&gt;User ID: Linked to identity, not for tracking&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Sentry (crash reporting):&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Crash Data: Not linked to identity, not for tracking&lt;/li&gt;
&lt;li&gt;Performance Data: Not linked to identity, not for tracking&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;The gotcha:&lt;/strong&gt; If you use analytics, you need to declare it. If you use third-party login, you're collecting identity data. Be honest - Apple reviews this.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Complete Screenshot Workflow
&lt;/h2&gt;

&lt;p&gt;For anyone doing this in the future:&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: Capture iPhone Screenshots
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Boot iPhone 15 Pro Max (or similar 6.5" device)&lt;/span&gt;
xcrun simctl boot &lt;span class="s2"&gt;"iPhone 15 Pro Max"&lt;/span&gt;

&lt;span class="c"&gt;# Run your app&lt;/span&gt;
npx expo run:ios &lt;span class="nt"&gt;--device&lt;/span&gt; &lt;span class="s2"&gt;"iPhone 15 Pro Max"&lt;/span&gt;

&lt;span class="c"&gt;# Navigate to each screen and capture&lt;/span&gt;
xcrun simctl io booted screenshot ./assets/appstore/iphone/screenshot01.png
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 2: Resize to App Store Dimensions
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# iPhone 6.5" requires 1284 x 2778&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;f &lt;span class="k"&gt;in&lt;/span&gt; ./assets/appstore/iphone/&lt;span class="k"&gt;*&lt;/span&gt;.png&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;sips &lt;span class="nt"&gt;-z&lt;/span&gt; 2778 1284 &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$f&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 3: Capture iPad Screenshots Separately
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Boot iPad Pro 13"&lt;/span&gt;
xcrun simctl boot &lt;span class="s2"&gt;"iPad Pro 13-inch (M4)"&lt;/span&gt;

&lt;span class="c"&gt;# Run your app on iPad&lt;/span&gt;
npx expo run:ios &lt;span class="nt"&gt;--device&lt;/span&gt; &lt;span class="s2"&gt;"iPad Pro 13-inch (M4)"&lt;/span&gt;

&lt;span class="c"&gt;# Capture at native resolution (2048 x 2732)&lt;/span&gt;
xcrun simctl io booted screenshot ./assets/appstore/ipad/screenshot01.png
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 4: Compliance Declarations
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Export Compliance: Standard iOS HTTPS = no additional export requirements&lt;/li&gt;
&lt;li&gt;Privacy: Declare Auth0 data as identity-linked, crash reporting as not linked&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Lessons Learned
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Don't resize across form factors.&lt;/strong&gt; Phone screenshots stretched to iPad dimensions look terrible. Capture natively on each device type.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Create demo content for screenshots.&lt;/strong&gt; Login screens don't sell apps. Build an onboarding flow or demo mode, capture screenshots, then remove it.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Export compliance isn't scary.&lt;/strong&gt; If you're using standard iOS networking (URLSession, Alamofire, etc.), you're just using Apple's TLS implementation. Select the exemption options.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Privacy declarations require honesty.&lt;/strong&gt; List every SDK that touches user data. Auth0, Sentry, analytics - all of it.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;&lt;code&gt;sips&lt;/code&gt; is your friend.&lt;/strong&gt; Built into macOS, handles resizing without installing ImageMagick or Photoshop.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;




&lt;p&gt;&lt;strong&gt;Submitting your first iOS app?&lt;/strong&gt; What unexpected blockers did you hit? Drop them in the comments.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Building &lt;a href="https://jo4.io" rel="noopener noreferrer"&gt;jo4.io&lt;/a&gt; - URL shortener with a mobile app that survived App Store review.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ios</category>
      <category>mobile</category>
      <category>reactnative</category>
      <category>appstore</category>
    </item>
    <item>
      <title>One Push, Two App Stores: Parallel iOS and Android Builds with GitHub Actions</title>
      <dc:creator>Anand Rathnas</dc:creator>
      <pubDate>Thu, 14 May 2026 01:39:58 +0000</pubDate>
      <link>https://dev.to/anand_rathnas_d5b608cc3de/one-push-two-app-stores-parallel-ios-and-android-builds-with-github-actions-36gh</link>
      <guid>https://dev.to/anand_rathnas_d5b608cc3de/one-push-two-app-stores-parallel-ios-and-android-builds-with-github-actions-36gh</guid>
      <description>&lt;p&gt;Liquid syntax error: Unknown tag 'endraw'&lt;/p&gt;
</description>
      <category>githubactions</category>
      <category>devops</category>
      <category>mobile</category>
      <category>cicd</category>
    </item>
    <item>
      <title>Why We Removed Ads from Our Free Tools (And Put Them Only on Blog Posts)</title>
      <dc:creator>Anand Rathnas</dc:creator>
      <pubDate>Tue, 12 May 2026 01:36:44 +0000</pubDate>
      <link>https://dev.to/anand_rathnas_d5b608cc3de/why-we-removed-ads-from-our-free-tools-and-put-them-only-on-blog-posts-2k7j</link>
      <guid>https://dev.to/anand_rathnas_d5b608cc3de/why-we-removed-ads-from-our-free-tools-and-put-them-only-on-blog-posts-2k7j</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;This article was originally published on &lt;a href="https://jo4.io/blog/adsense-utility-pages-mistake/" rel="noopener noreferrer"&gt;Jo4 Blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;We built a suite of free developer tools: JSON formatter, JWT decoder, QR generator, UTM builder, and about a dozen others.&lt;/p&gt;

&lt;p&gt;Then we added AdSense to every page.&lt;/p&gt;

&lt;p&gt;Then we removed it from most of them.&lt;/p&gt;

&lt;p&gt;Here's why.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Original Plan
&lt;/h2&gt;

&lt;p&gt;The logic seemed sound:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Free tools bring traffic (SEO)&lt;/li&gt;
&lt;li&gt;Traffic sees ads&lt;/li&gt;
&lt;li&gt;Ads generate revenue&lt;/li&gt;
&lt;li&gt;Revenue funds development&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;We added a simple &lt;code&gt;&amp;lt;AdSlot /&amp;gt;&lt;/code&gt; component to our &lt;code&gt;UtilityPageLayout&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Before: Ad on every utility page&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;UtilityPageLayout&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;children&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;main&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;AdSlot&lt;/span&gt; &lt;span class="na"&gt;position&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"top"&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;children&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;AdSlot&lt;/span&gt; &lt;span class="na"&gt;position&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"bottom"&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;main&lt;/span&gt;&lt;span class="p"&gt;&amp;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;Every tool page now had ads at the top and bottom.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Actually Happened
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Week 1:&lt;/strong&gt; Impressions up, RPM decent.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Week 2:&lt;/strong&gt; Noticed something odd in analytics.&lt;/p&gt;

&lt;p&gt;Users on utility pages had:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Higher bounce rate&lt;/strong&gt; (+15%)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lower time on site&lt;/strong&gt; (-20%)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fewer conversions to signup&lt;/strong&gt; (-25%)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The ads were working as ads. They were also working as exit doors.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem with Ads on Utility Pages
&lt;/h2&gt;

&lt;p&gt;Utility pages have a specific user intent: &lt;strong&gt;Do one thing, leave.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Someone using a JSON formatter:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Pastes JSON&lt;/li&gt;
&lt;li&gt;Gets formatted output&lt;/li&gt;
&lt;li&gt;Copies it&lt;/li&gt;
&lt;li&gt;Leaves&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Total time on page: 30 seconds.&lt;/p&gt;

&lt;p&gt;An ad in this flow is a distraction. Worse, it's a &lt;em&gt;competing&lt;/em&gt; call-to-action. The user came to format JSON. The ad says "Hey, check out this other thing."&lt;/p&gt;

&lt;p&gt;If they click the ad, they leave. If they don't click the ad, it just... sits there, making the page feel cluttered.&lt;/p&gt;

&lt;h2&gt;
  
  
  Blog Posts Are Different
&lt;/h2&gt;

&lt;p&gt;Blog posts have a different user intent: &lt;strong&gt;Learn something.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Someone reading a blog post:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Searches for a problem&lt;/li&gt;
&lt;li&gt;Reads the solution&lt;/li&gt;
&lt;li&gt;Maybe reads related sections&lt;/li&gt;
&lt;li&gt;Considers the author's credibility&lt;/li&gt;
&lt;li&gt;Might explore more content&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Total time on page: 3-5 minutes.&lt;/p&gt;

&lt;p&gt;An ad in this flow is... fine. The user is already in "reading mode." They're not trying to complete a task. A well-placed ad between sections doesn't interrupt a workflow because there's no workflow to interrupt.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Fix
&lt;/h2&gt;

&lt;p&gt;We removed ads from utility pages entirely:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// After: No ads in UtilityPageLayout&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;UtilityPageLayout&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;children&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;main&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;children&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;main&lt;/span&gt;&lt;span class="p"&gt;&amp;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;And kept them only on blog posts:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- posts template --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;article&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"blog-post"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;header&amp;gt;&lt;/span&gt;...&lt;span class="nt"&gt;&amp;lt;/header&amp;gt;&lt;/span&gt;

  &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"ad-container ad-top"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="c"&gt;&amp;lt;!-- Ad after title, before content --&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;

  &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"post-content"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    {{ content }}
  &lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;

  &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"ad-container ad-bottom"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="c"&gt;&amp;lt;!-- Ad after content, before footer --&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;

  &lt;span class="nt"&gt;&amp;lt;footer&amp;gt;&lt;/span&gt;...&lt;span class="nt"&gt;&amp;lt;/footer&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/article&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The Results
&lt;/h2&gt;

&lt;p&gt;After removing ads from utility pages:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Bounce rate: &lt;strong&gt;Back to baseline&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Time on site: &lt;strong&gt;+10%&lt;/strong&gt; (people explored more)&lt;/li&gt;
&lt;li&gt;Signups from utility pages: &lt;strong&gt;+30%&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Ad revenue from blog posts alone: &lt;strong&gt;About the same&lt;/strong&gt; (blog traffic is smaller but more engaged)&lt;/p&gt;

&lt;p&gt;Net effect: &lt;strong&gt;More signups, same ad revenue.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Principle
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Match monetization to intent.&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Page Type&lt;/th&gt;
&lt;th&gt;User Intent&lt;/th&gt;
&lt;th&gt;Monetization&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Utility tools&lt;/td&gt;
&lt;td&gt;Complete task quickly&lt;/td&gt;
&lt;td&gt;None (or subtle "Made by X")&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Blog posts&lt;/td&gt;
&lt;td&gt;Learn, explore&lt;/td&gt;
&lt;td&gt;Ads okay&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Landing pages&lt;/td&gt;
&lt;td&gt;Evaluate product&lt;/td&gt;
&lt;td&gt;None (focus on conversion)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Documentation&lt;/td&gt;
&lt;td&gt;Find answer&lt;/td&gt;
&lt;td&gt;None (builds trust)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Ads work when they don't compete with the page's purpose. On a blog post, the purpose is consumption. On a utility page, the purpose is production.&lt;/p&gt;

&lt;h2&gt;
  
  
  What We Do Instead on Utility Pages
&lt;/h2&gt;

&lt;p&gt;Instead of ads, utility pages now have:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Subtle branding&lt;/strong&gt;: "Built by jo4.io" in the footer&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Relevant CTAs&lt;/strong&gt;: "Need to track these links? Try jo4.io" on the UTM builder&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cross-links&lt;/strong&gt;: "You might also like: QR Generator, Link Shortener"&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;These don't generate direct ad revenue, but they:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Keep users in our ecosystem&lt;/li&gt;
&lt;li&gt;Build brand recognition&lt;/li&gt;
&lt;li&gt;Convert better than ads ever did&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The Takeaway
&lt;/h2&gt;

&lt;p&gt;Free tools are marketing, not monetization.&lt;/p&gt;

&lt;p&gt;The ROI of a free JSON formatter isn't the $0.03 per ad click. It's the developer who bookmarks it, uses it weekly, and eventually needs a URL shortener for their project.&lt;/p&gt;

&lt;p&gt;Ads on utility pages optimize for the wrong metric.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Do you run ads on your free tools?&lt;/strong&gt; Curious how others handle this tradeoff.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Building &lt;a href="https://jo4.io" rel="noopener noreferrer"&gt;jo4.io&lt;/a&gt; - free developer tools that won't interrupt your workflow.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>adsense</category>
      <category>ux</category>
      <category>monetization</category>
    </item>
    <item>
      <title>The Auth0 Pricing Trap: Why Upgrading to Paid Gives You Less</title>
      <dc:creator>Anand Rathnas</dc:creator>
      <pubDate>Sat, 09 May 2026 01:35:57 +0000</pubDate>
      <link>https://dev.to/anand_rathnas_d5b608cc3de/the-auth0-pricing-trap-why-upgrading-to-paid-gives-you-less-3m5f</link>
      <guid>https://dev.to/anand_rathnas_d5b608cc3de/the-auth0-pricing-trap-why-upgrading-to-paid-gives-you-less-3m5f</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;This article was originally published on &lt;a href="https://jo4.io/blog/auth0-free-plan-startup-trap/" rel="noopener noreferrer"&gt;Jo4 Blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I was about to upgrade our Auth0 plan to get a cleaner domain. Then I looked at the pricing page.&lt;/p&gt;

&lt;p&gt;And closed the tab.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Setup
&lt;/h2&gt;

&lt;p&gt;Auth0 gives you a randomly generated tenant URL when you sign up:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;dev-exjsxdx8c6qt3uhf.us.auth0.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Not exactly brand-inspiring. I wanted something cleaner like &lt;code&gt;jo4.us.auth0.com&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;To get a custom tenant name, you need to create a new tenant. To create a new tenant on the free plan:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;❌ You have reached the limit for Tenants in your current plan.
   Upgrade your plan to create more tenants.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Fine, I thought. What does the paid plan cost?&lt;/p&gt;

&lt;h2&gt;
  
  
  The Math That Doesn't Math
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Free Plan:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;25,000 MAU included&lt;/li&gt;
&lt;li&gt;1 tenant&lt;/li&gt;
&lt;li&gt;Basic features&lt;/li&gt;
&lt;li&gt;$0/month&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Essentials Plan (Paid):&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;500 MAU included&lt;/li&gt;
&lt;li&gt;Multiple tenants&lt;/li&gt;
&lt;li&gt;MFA, RBAC&lt;/li&gt;
&lt;li&gt;$35/month (B2C)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Wait. The &lt;em&gt;paid&lt;/em&gt; plan includes &lt;strong&gt;fewer&lt;/strong&gt; users than the free plan?&lt;/p&gt;

&lt;p&gt;Yes. When you upgrade from free to Essentials, you go from 25,000 included MAUs to 500 included MAUs. Want more? Pay per MAU.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Real Pricing Table
&lt;/h2&gt;

&lt;p&gt;Here's what Auth0 pricing actually looks like:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Plan&lt;/th&gt;
&lt;th&gt;Included MAU&lt;/th&gt;
&lt;th&gt;Price&lt;/th&gt;
&lt;th&gt;Cost per Additional MAU&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Free&lt;/td&gt;
&lt;td&gt;25,000&lt;/td&gt;
&lt;td&gt;$0&lt;/td&gt;
&lt;td&gt;N/A (hard limit)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Essentials&lt;/td&gt;
&lt;td&gt;500&lt;/td&gt;
&lt;td&gt;$35/mo&lt;/td&gt;
&lt;td&gt;~$0.07/MAU&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Professional&lt;/td&gt;
&lt;td&gt;1,000&lt;/td&gt;
&lt;td&gt;$240/mo&lt;/td&gt;
&lt;td&gt;~$0.24/MAU&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Enterprise&lt;/td&gt;
&lt;td&gt;Custom&lt;/td&gt;
&lt;td&gt;$30k+/year&lt;/td&gt;
&lt;td&gt;"Let's talk"&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;So if you have 10,000 users and want to upgrade to Essentials, you'd pay:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$35 base + (9,500 × $0.07) = $35 + $665 = $700/month
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a cleaner URL and MFA.&lt;/p&gt;

&lt;h2&gt;
  
  
  What You Actually Get on Free
&lt;/h2&gt;

&lt;p&gt;The free tier is surprisingly capable:&lt;/p&gt;

&lt;p&gt;✅ 25,000 monthly active users&lt;br&gt;
✅ Social login (Google, Apple, GitHub, etc.)&lt;br&gt;
✅ Email/password authentication&lt;br&gt;
✅ Passwordless (magic links)&lt;br&gt;
✅ Universal Login (hosted login page)&lt;br&gt;
✅ Basic user management&lt;br&gt;
✅ 3 team members&lt;/p&gt;

&lt;p&gt;What you DON'T get:&lt;/p&gt;

&lt;p&gt;❌ Multi-factor authentication (MFA)&lt;br&gt;
❌ Role-based access control (RBAC)&lt;br&gt;
❌ Multiple tenants&lt;br&gt;
❌ Custom domains (like &lt;code&gt;auth.yourapp.com&lt;/code&gt;)&lt;br&gt;
❌ More than 5 organizations (B2B)&lt;/p&gt;

&lt;h2&gt;
  
  
  When to Actually Upgrade
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Stay on Free if:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You have &amp;lt; 25,000 MAU&lt;/li&gt;
&lt;li&gt;You don't need MFA&lt;/li&gt;
&lt;li&gt;You can live with &lt;code&gt;dev-xxx.auth0.com&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;You're B2C or have &amp;lt; 5 B2B customers&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Upgrade to Essentials if:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You NEED MFA (compliance, enterprise customers)&lt;/li&gt;
&lt;li&gt;You have &amp;lt; 2,000 MAU (cost is reasonable)&lt;/li&gt;
&lt;li&gt;Multiple environments are critical (staging/prod tenants)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Upgrade to Professional if:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You need &amp;gt; 3 SSO connections&lt;/li&gt;
&lt;li&gt;You have enterprise customers requiring specific compliance&lt;/li&gt;
&lt;li&gt;You're at the "money is less important than time" stage&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Go Enterprise if:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You have &amp;gt; 25,000 MAU anyway&lt;/li&gt;
&lt;li&gt;You need 99.99% SLA&lt;/li&gt;
&lt;li&gt;You want a dedicated account manager to yell at&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The Alternative: Don't Upgrade
&lt;/h2&gt;

&lt;p&gt;Here's my actual decision:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Keep the free plan&lt;/strong&gt; - 25,000 MAU is plenty for now&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Accept the ugly URL&lt;/strong&gt; - Users see it for ~1 second during OAuth redirect&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Revisit when we need MFA&lt;/strong&gt; - That's the real trigger, not vanity URLs&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The &lt;code&gt;dev-exjsxdx8c6qt3uhf.us.auth0.com&lt;/code&gt; domain is ugly, but it works. Users don't care. They're looking at their phone, waiting for the login to complete.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Real Question
&lt;/h2&gt;

&lt;p&gt;Before upgrading Auth0, ask yourself:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Am I upgrading because I need the features, or because the free tier feels unprofessional?"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If it's the latter, save your money. Put it toward features your users actually see.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Not Self-Host?
&lt;/h2&gt;

&lt;p&gt;"Just implement auth yourself" is advice I hear often. Here's why I'm staying with Auth0:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Auth0 handles:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Password hashing (bcrypt/argon2)&lt;/li&gt;
&lt;li&gt;Password reset flows&lt;/li&gt;
&lt;li&gt;Email verification&lt;/li&gt;
&lt;li&gt;Brute force protection&lt;/li&gt;
&lt;li&gt;Account lockout&lt;/li&gt;
&lt;li&gt;Breach detection&lt;/li&gt;
&lt;li&gt;Compliance (SOC2, HIPAA options)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;One auth mistake = security incident.&lt;/strong&gt; Auth0's free tier is free insurance.&lt;/p&gt;

&lt;p&gt;The value isn't the login page. It's not storing passwords in your database.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;What's your auth setup?&lt;/strong&gt; Self-hosted, Auth0, Clerk, something else? I'm curious what other indie hackers are using.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Building &lt;a href="https://jo4.io" rel="noopener noreferrer"&gt;jo4.io&lt;/a&gt; - a URL shortener that definitely doesn't store your passwords.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>authentication</category>
      <category>startup</category>
      <category>saas</category>
      <category>pricing</category>
    </item>
    <item>
      <title>Publishing an Expo App to the App Store: The Parts Nobody Warns You About</title>
      <dc:creator>Anand Rathnas</dc:creator>
      <pubDate>Thu, 07 May 2026 01:36:17 +0000</pubDate>
      <link>https://dev.to/anand_rathnas_d5b608cc3de/publishing-an-expo-app-to-the-app-store-the-parts-nobody-warns-you-about-1ffi</link>
      <guid>https://dev.to/anand_rathnas_d5b608cc3de/publishing-an-expo-app-to-the-app-store-the-parts-nobody-warns-you-about-1ffi</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;This article was originally published on &lt;a href="https://jo4.io/blog/ios-app-store-launch-expo-eas/" rel="noopener noreferrer"&gt;Jo4 Blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;"Just run &lt;code&gt;eas build --platform ios --auto-submit&lt;/code&gt; and you're done!"&lt;/p&gt;

&lt;p&gt;Famous last words.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;I had a React Native app built with Expo. Android was already live on the Play Store. iOS should be the same process, right?&lt;/p&gt;

&lt;p&gt;Here's what actually happened over the next 4 hours.&lt;/p&gt;

&lt;h2&gt;
  
  
  Part 1: The Provisioning Profile Dance
&lt;/h2&gt;

&lt;p&gt;First build attempt:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;❌ Provisioning profile doesn't support the App Groups capability
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;My app has a Share Extension (for sharing URLs from other apps). Share Extensions need their own bundle ID, their own provisioning profile, and their own set of capabilities.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Go to Apple Developer Portal → Identifiers&lt;/li&gt;
&lt;li&gt;Find both your main app ID AND the ShareExtension ID&lt;/li&gt;
&lt;li&gt;Enable "App Groups" capability on BOTH&lt;/li&gt;
&lt;li&gt;Go to Profiles → Delete the old profiles&lt;/li&gt;
&lt;li&gt;Let EAS regenerate them&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;EAS will ask to create new profiles. Say yes. It knows what it's doing (mostly).&lt;/p&gt;

&lt;h2&gt;
  
  
  Part 2: Sign in with Apple - The Checkbox That Wasn't
&lt;/h2&gt;

&lt;p&gt;Apple requires apps with third-party login to also offer Sign in with Apple. My app uses Auth0, which supports Apple auth. Should be simple.&lt;/p&gt;

&lt;p&gt;Build attempt #2:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;❌ Disabled: Sign In with Apple
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The app.config.js was missing one line:&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="nx"&gt;ios&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// ... other config&lt;/span&gt;
  &lt;span class="nl"&gt;usesAppleSignIn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;  &lt;span class="c1"&gt;// This one. This is the line.&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Added it, rebuilt. Profile regenerated with the capability. Build succeeded.&lt;/p&gt;

&lt;h2&gt;
  
  
  Part 3: The "invalid_client" Mystery
&lt;/h2&gt;

&lt;p&gt;App built. App ran. Tapped "Sign in with Apple."&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;invalid_client
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Checked Auth0 config. Everything looked right:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Client ID: &lt;code&gt;io.jo4.mobile&lt;/code&gt; ✓&lt;/li&gt;
&lt;li&gt;Team ID: correct ✓&lt;/li&gt;
&lt;li&gt;Key ID: correct ✓&lt;/li&gt;
&lt;li&gt;Private key: pasted from .p8 file ✓&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Spent 30 minutes rechecking these values.&lt;/p&gt;

&lt;p&gt;Turns out, the Apple Sign in Key I created had &lt;strong&gt;empty "Enabled Services"&lt;/strong&gt;. The key existed but wasn't actually configured for Sign in with Apple.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Apple Developer → Keys → Click your key → Edit&lt;/li&gt;
&lt;li&gt;Check "Sign in with Apple"&lt;/li&gt;
&lt;li&gt;Click Configure → Select your app as Primary App ID&lt;/li&gt;
&lt;li&gt;Save&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Tried again:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;invalid_request
Invalid client id or web redirect url
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;Different error. Progress.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Part 4: Services ID - The Thing Auth0 Docs Bury
&lt;/h2&gt;

&lt;p&gt;Here's what I didn't understand: Auth0 uses a web-based OAuth flow for Apple Sign in (via Universal Login). This means Apple sees it as a &lt;strong&gt;web app&lt;/strong&gt;, not a native app.&lt;/p&gt;

&lt;p&gt;Web apps need a &lt;strong&gt;Services ID&lt;/strong&gt;, not just an App ID.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Apple Developer → Identifiers → Create Services ID&lt;/li&gt;
&lt;li&gt;Name it something like &lt;code&gt;io.jo4.mobile.auth0&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Enable "Sign in with Apple"&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Configure with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Primary App ID: Your main app&lt;/li&gt;
&lt;li&gt;Domains: &lt;code&gt;your-tenant.us.auth0.com&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Return URLs: &lt;code&gt;https://your-tenant.us.auth0.com/login/callback&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Update Auth0's Apple connection to use the &lt;strong&gt;Services ID&lt;/strong&gt; as the Client ID&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Finally:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;✅ Sign in with Apple works
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The Complete Checklist
&lt;/h2&gt;

&lt;p&gt;For anyone doing this in the future:&lt;/p&gt;

&lt;h3&gt;
  
  
  Apple Developer Portal
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;[ ] App ID has "Sign in with Apple" capability&lt;/li&gt;
&lt;li&gt;[ ] App ID has "App Groups" capability (if using extensions)&lt;/li&gt;
&lt;li&gt;[ ] ShareExtension ID has matching capabilities&lt;/li&gt;
&lt;li&gt;[ ] Key created with "Sign in with Apple" enabled&lt;/li&gt;
&lt;li&gt;[ ] Key configured with correct Primary App ID&lt;/li&gt;
&lt;li&gt;[ ] Services ID created (for Auth0/web-based flows)&lt;/li&gt;
&lt;li&gt;[ ] Services ID configured with Auth0 domain and callback URL&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  app.config.js (Expo)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;ios&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;bundleIdentifier&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;your.bundle.id&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;usesAppleSignIn&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="nx"&gt;entitlements&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;com.apple.security.application-groups&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;group.your.bundle.id&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;h3&gt;
  
  
  Auth0
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Apple connection uses &lt;strong&gt;Services ID&lt;/strong&gt; as Client ID (not App ID)&lt;/li&gt;
&lt;li&gt;[ ] Team ID, Key ID, and private key are correct&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  EAS
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Delete old provisioning profiles if capabilities changed&lt;/li&gt;
&lt;li&gt;[ ] Let EAS regenerate profiles with new capabilities&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Lessons Learned
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;EAS is magic, until it isn't.&lt;/strong&gt; The happy path is genuinely one command. The unhappy path is a maze of Apple portals.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Capabilities cascade.&lt;/strong&gt; If your main app needs a capability, your extensions probably do too.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Auth0 + Apple = Services ID.&lt;/strong&gt; This isn't obvious from either company's docs. Web-based OAuth flows need a Services ID, not an App ID.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Apple's error messages lie.&lt;/strong&gt; "invalid_client" can mean the key isn't configured. "invalid_request" can mean you need a Services ID. Neither error tells you this.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The App Store submission is the easy part.&lt;/strong&gt; Once EAS builds successfully with &lt;code&gt;--auto-submit&lt;/code&gt;, it just... works. The hard part is getting that first successful build.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;




&lt;p&gt;&lt;strong&gt;Building a mobile app?&lt;/strong&gt; Save yourself the debugging session and bookmark this checklist.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Building &lt;a href="https://jo4.io" rel="noopener noreferrer"&gt;jo4.io&lt;/a&gt; - a URL shortener with a mobile app that now actually exists on the App Store.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>ios</category>
      <category>expo</category>
      <category>mobile</category>
    </item>
    <item>
      <title>Why We Killed Hold Windows in Our Affiliate Marketplace</title>
      <dc:creator>Anand Rathnas</dc:creator>
      <pubDate>Sat, 02 May 2026 01:35:40 +0000</pubDate>
      <link>https://dev.to/anand_rathnas_d5b608cc3de/why-we-killed-hold-windows-in-our-affiliate-marketplace-1jdn</link>
      <guid>https://dev.to/anand_rathnas_d5b608cc3de/why-we-killed-hold-windows-in-our-affiliate-marketplace-1jdn</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;This article was originally published on &lt;a href="https://jo4.io/blog/affiliate-marketplace-simplification/" rel="noopener noreferrer"&gt;Jo4 Blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;We spent weeks building a settlement system for our affiliate marketplace. Hold windows. Clawbacks. Carry-forwards. Commission auto-approval schedulers. The works.&lt;/p&gt;

&lt;p&gt;Then we deleted it all.&lt;/p&gt;

&lt;h2&gt;
  
  
  What We Built (and Why)
&lt;/h2&gt;

&lt;p&gt;The idea was straightforward: when an affiliate drives a conversion, don't pay them immediately. Hold the commission for X days. If the customer refunds, claw back the commission. If there's a remainder below the payout threshold, carry it forward to next month.&lt;/p&gt;

&lt;p&gt;Sounds reasonable, right? Every major affiliate network does something like this.&lt;/p&gt;

&lt;p&gt;So we built:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Hold windows&lt;/strong&gt; — configurable per campaign (7, 14, 30 days)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Clawback logic&lt;/strong&gt; — refunds during hold period reduce the affiliate's balance&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Carry-forwards&lt;/strong&gt; — sub-threshold amounts roll to next settlement period&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Auto-approval scheduler&lt;/strong&gt; — commissions move from HELD → APPROVED after the hold window&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What Went Wrong
&lt;/h2&gt;

&lt;p&gt;Legal review flagged it.&lt;/p&gt;

&lt;p&gt;The problem wasn't technical — it was regulatory. Holding affiliate funds creates obligations:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Money transmission concerns&lt;/strong&gt; — holding and releasing funds on a schedule starts to look like money transmission in some jurisdictions&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dispute resolution requirements&lt;/strong&gt; — clawbacks need a formal dispute process, not just an automatic deduction&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Accounting complexity&lt;/strong&gt; — carry-forwards create accrued liabilities that need proper bookkeeping&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tax reporting&lt;/strong&gt; — when was the income earned? When the conversion happened, when the hold expired, or when the payout was made?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;We're a URL shortener that added an affiliate marketplace. We're not a payment processor. Building the compliance infrastructure for hold windows was going to cost more than the feature was worth.&lt;/p&gt;

&lt;h2&gt;
  
  
  What We Did Instead
&lt;/h2&gt;

&lt;p&gt;Deleted it. All of it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;- CommissionAutoApprovalScheduler.java (deleted)
- holdWindowDays field (removed from campaigns)
- clawbackAmount, previousCarryForward (removed from settlements)
- HELD commission status (removed)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Replaced with:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Firm offers&lt;/strong&gt; — brands mark campaigns as non-negotiable. Publishers accept the commission as-is. No back-and-forth.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Immediate settlement&lt;/strong&gt; — conversions are confirmed by Stripe webhooks. When Stripe says the charge succeeded, the commission is earned. Period.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Monthly payouts&lt;/strong&gt; — simple monthly settlement with no holds. If there's a refund, the brand eats it (they can adjust their commission rates accordingly).&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  What We Added
&lt;/h2&gt;

&lt;p&gt;The simplification freed up time for features that actually matter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Partnership lifecycle&lt;/strong&gt; — pause, resume, terminate partnerships with full event tracking&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Multi-channel notifications&lt;/strong&gt; — email, in-app, and push notifications for partnership events&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Campaign budgets and expiry&lt;/strong&gt; — brands set a maximum spend and end date, campaigns auto-pause when limits are hit&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Firm offers&lt;/strong&gt; — skip the negotiation dance when the brand knows what they want to pay&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The Numbers
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Settlement-related DB tables&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Commission statuses&lt;/td&gt;
&lt;td&gt;6 (PENDING, HELD, APPROVED, CLAWED_BACK, PAID, FAILED)&lt;/td&gt;
&lt;td&gt;3 (PENDING, APPROVED, PAID)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Settlement logic (lines)&lt;/td&gt;
&lt;td&gt;~800&lt;/td&gt;
&lt;td&gt;~200&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Legal questions&lt;/td&gt;
&lt;td&gt;Many&lt;/td&gt;
&lt;td&gt;Few&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Lessons Learned
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Legal review before building, not after&lt;/strong&gt; — we should have asked "can we hold affiliate funds?" before writing a single line of code. Would have saved weeks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Complexity is a liability&lt;/strong&gt; — every line of settlement logic was a potential bug, a potential legal issue, and a potential support ticket. Less code = less risk.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Copy the leader carefully&lt;/strong&gt; — "Amazon Associates does hold windows" doesn't mean you should. Amazon has a legal team. You have a Notion doc.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Simpler products attract more users&lt;/strong&gt; — publishers don't want to learn about hold windows and carry-forwards. They want to drive traffic and get paid.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;KISS isn't lazy, it's strategic&lt;/strong&gt; — deleting working code feels wrong. It's not. It's the highest-ROI engineering decision you can make.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;strong&gt;Ever deleted a feature you spent weeks building?&lt;/strong&gt; What was the hardest "kill your darlings" moment in your product? Share below.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Building &lt;a href="https://jo4.io" rel="noopener noreferrer"&gt;jo4.io&lt;/a&gt; — a URL shortener with an affiliate marketplace that pays publishers without the complexity.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>startup</category>
      <category>buildinpublic</category>
      <category>saas</category>
      <category>webdev</category>
    </item>
    <item>
      <title>3 Auth Bugs We Shipped to Production (Spring + Auth0)</title>
      <dc:creator>Anand Rathnas</dc:creator>
      <pubDate>Fri, 01 May 2026 01:37:02 +0000</pubDate>
      <link>https://dev.to/anand_rathnas_d5b608cc3de/3-auth-bugs-we-shipped-to-production-spring-auth0-nkg</link>
      <guid>https://dev.to/anand_rathnas_d5b608cc3de/3-auth-bugs-we-shipped-to-production-spring-auth0-nkg</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;This article was originally published on &lt;a href="https://jo4.io/blog/spring-security-auth-bugs-multitenant/" rel="noopener noreferrer"&gt;Jo4 Blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;We found three authentication bugs in production. Not from penetration testing. Not from a security audit. From a single user saying "I can't log in sometimes."&lt;/p&gt;

&lt;p&gt;All three bugs were interconnected. Fixing one revealed the next. We shipped the fix in a single commit because pulling on one thread unraveled the whole chain.&lt;/p&gt;

&lt;p&gt;Here's each bug, why it existed, and how we fixed it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 1: The 405 That Shouldn't Exist
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Symptom:&lt;/strong&gt; Sentry alerts showing &lt;code&gt;HttpRequestMethodNotSupportedException&lt;/code&gt; — HTTP 405 "Method Not Allowed" — on endpoints that absolutely accept the methods being used.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Investigation:&lt;/strong&gt; The stack traces pointed at bot traffic. Scanners probing random paths with random HTTP methods. &lt;code&gt;PROPFIND /admin&lt;/code&gt;. &lt;code&gt;OPTIONS /api/v1/protected/users&lt;/code&gt;. &lt;code&gt;TRACE /oauth/token&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;These should return 404 or be handled gracefully. Instead, they were hitting our impersonation filter, which assumed any request reaching it was a valid authenticated request. When the filter tried to process a &lt;code&gt;PROPFIND&lt;/code&gt; request on a path that only accepts &lt;code&gt;GET&lt;/code&gt;, Spring threw a &lt;code&gt;MethodNotAllowed&lt;/code&gt; before our error handler could catch it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&gt; Add &lt;code&gt;HttpRequestMethodNotSupportedException&lt;/code&gt; to our global exception handler:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@ExceptionHandler&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;HttpRequestMethodNotSupportedException&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;class&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;ResponseEntity&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ErrorResponse&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;handleMethodNotAllowed&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
        &lt;span class="nc"&gt;HttpRequestMethodNotSupportedException&lt;/span&gt; &lt;span class="n"&gt;ex&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;ResponseEntity&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;HttpStatus&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;METHOD_NOT_ALLOWED&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ErrorResponse&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Method not allowed"&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;Simple. But finding it required understanding that our filter was letting garbage requests through to the controller layer.&lt;/p&gt;

&lt;p&gt;Which led us to Bug 2.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 2: The Filter That Ran Too Early
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Symptom:&lt;/strong&gt; Admin impersonation — a feature that lets support staff act as a specific user — worked &lt;em&gt;sometimes&lt;/em&gt;. Other times it silently failed and the admin saw their own account instead of the target user.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The architecture:&lt;/strong&gt; We have an &lt;code&gt;ImpersonationFilter&lt;/code&gt; that checks for an &lt;code&gt;X-Impersonate-User&lt;/code&gt; header. If present and the caller is an admin, it swaps the security context to the target user.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The problem:&lt;/strong&gt; The filter executed &lt;em&gt;before&lt;/em&gt; our user sync filter.&lt;/p&gt;

&lt;p&gt;In our Auth0 integration, the first request from a new Auth0 user triggers a "sync" — we look up the Auth0 subject in our database and create a local user record if one doesn't exist. This happens in &lt;code&gt;Auth0UserSyncFilter&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The filter chain looked like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Request → ImpersonationFilter → Auth0UserSyncFilter → Controller
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When an admin's &lt;em&gt;first&lt;/em&gt; request included the impersonation header:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;ImpersonationFilter&lt;/code&gt; runs. Tries to look up the admin user. But the admin hasn't been synced yet. Lookup returns null. Impersonation silently fails.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Auth0UserSyncFilter&lt;/code&gt; runs. Creates the admin user record.&lt;/li&gt;
&lt;li&gt;Controller runs. Admin sees their own account, not the target.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;On the &lt;em&gt;second&lt;/em&gt; request, the admin user exists. Impersonation works. Hence "works sometimes."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&gt; Reorder the filters:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Request → Auth0UserSyncFilter → ImpersonationFilter → Controller
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The sync must happen before any filter that depends on the user existing in the database. We enforced this with explicit &lt;code&gt;@Order&lt;/code&gt; annotations:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Order&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;// Runs first — ensures user exists&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Auth0UserSyncFilter&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;OncePerRequestFilter&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;span class="nd"&gt;@Order&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;// Runs second — can now look up the user&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ImpersonationFilter&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;OncePerRequestFilter&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;Spring Security's filter chain doesn't guarantee ordering by default. If you register filters without explicit ordering, you're at the mercy of component scanning order, which can vary between environments.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 3: The Race Condition in User Creation
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Symptom:&lt;/strong&gt; Intermittent &lt;code&gt;DataIntegrityViolationException&lt;/code&gt; — duplicate key constraint on the &lt;code&gt;users&lt;/code&gt; table — during peak traffic.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Root cause:&lt;/strong&gt; The Auth0 user sync had a classic check-then-act race condition:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Thread A                         // Thread B&lt;/span&gt;
&lt;span class="n"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;findByAuth0Sub&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sub&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;         &lt;span class="n"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;findByAuth0Sub&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sub&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// user is null                     // user is null&lt;/span&gt;
&lt;span class="n"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;createUser&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sub&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;      &lt;span class="n"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;createUser&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sub&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// SUCCESS                          // DataIntegrityViolationException!&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two concurrent requests from the same user (common on app startup — the mobile app fires multiple API calls simultaneously) both see "user doesn't exist" and both try to create the record. One succeeds. One hits the unique constraint.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&gt; Catch the constraint violation and retry the lookup:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nc"&gt;UserEntity&lt;/span&gt; &lt;span class="nf"&gt;syncUser&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;auth0Sub&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;UserEntity&lt;/span&gt; &lt;span class="n"&gt;existing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;userRepository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;findByAuth0Sub&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;auth0Sub&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;existing&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;existing&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;try&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;createNewUser&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;auth0Sub&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;DataIntegrityViolationException&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Another thread created the user between our check and insert.&lt;/span&gt;
        &lt;span class="c1"&gt;// Just fetch the record they created.&lt;/span&gt;
        &lt;span class="nc"&gt;UserEntity&lt;/span&gt; &lt;span class="n"&gt;raced&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;userRepository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;findByAuth0Sub&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;auth0Sub&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raced&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;raced&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// Genuine constraint violation, not a race&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;This is the optimistic concurrency pattern. Instead of acquiring a lock before the check (pessimistic), we let the race happen and recover from the loser's exception. It's cheaper under normal load (no locking overhead) and handles the edge case gracefully.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why All Three Were Connected
&lt;/h2&gt;

&lt;p&gt;The 405 errors drew attention to our filter chain. Investigating the filter chain revealed the ordering bug. Fixing the ordering bug and putting more load on the sync path exposed the race condition.&lt;/p&gt;

&lt;p&gt;It's a common pattern in production debugging: the bug you're investigating isn't the bug that matters. It's the thread that leads you to the real problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lessons Learned
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Handle every HTTP method in your exception handler.&lt;/strong&gt; Bots send &lt;code&gt;PROPFIND&lt;/code&gt;, &lt;code&gt;TRACE&lt;/code&gt;, &lt;code&gt;PATCH&lt;/code&gt; to paths that don't support them. Don't let these bubble up as unhandled exceptions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Spring filter ordering is not implicit.&lt;/strong&gt; If Filter B depends on state created by Filter A, use &lt;code&gt;@Order&lt;/code&gt; to guarantee A runs first. Don't rely on component scan order — it varies.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check-then-act is a race condition.&lt;/strong&gt; If two threads can execute the "check" simultaneously, they'll both proceed to "act." Use optimistic concurrency (catch + retry) or pessimistic locking (SELECT FOR UPDATE).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mobile apps create concurrent requests on startup.&lt;/strong&gt; When the app opens, it often fires 3-5 API calls in parallel (user profile, notifications, config). If your user sync runs per-request, you will hit the race condition.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One bug leads to another.&lt;/strong&gt; Don't stop when you fix the surface issue. Ask: "Why did this request reach this code path in the first place?"&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;strong&gt;What's the most interconnected set of bugs you've found in production?&lt;/strong&gt; Share the debugging chain in the comments.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Building &lt;a href="https://jo4.io" rel="noopener noreferrer"&gt;jo4.io&lt;/a&gt; — a multi-tenant platform where auth bugs are never "just" auth bugs.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>java</category>
      <category>springboot</category>
      <category>security</category>
      <category>webdev</category>
    </item>
    <item>
      <title>The One-Character OAuth Bug That Broke Our API</title>
      <dc:creator>Anand Rathnas</dc:creator>
      <pubDate>Fri, 01 May 2026 01:36:58 +0000</pubDate>
      <link>https://dev.to/anand_rathnas_d5b608cc3de/the-one-character-oauth-bug-that-broke-our-api-783</link>
      <guid>https://dev.to/anand_rathnas_d5b608cc3de/the-one-character-oauth-bug-that-broke-our-api-783</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;This article was originally published on &lt;a href="https://jo4.io/blog/oauth-scope-delimiter-bug/" rel="noopener noreferrer"&gt;Jo4 Blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Our OAuth implementation worked perfectly. Every test passed. Users authorized apps, got tokens, refreshed them. Textbook OAuth 2.0.&lt;/p&gt;

&lt;p&gt;Then a Pipedream integration broke.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;A user reported that their Pipedream workflow couldn't access certain API endpoints. The token was valid, the scopes were granted — but the API returned 403 Forbidden.&lt;/p&gt;

&lt;p&gt;The error logs showed the token had zero scopes. That's impossible — we confirmed the user authorized &lt;code&gt;read:urls write:urls&lt;/code&gt; during the consent flow.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Root Cause
&lt;/h2&gt;

&lt;p&gt;OAuth 2.0 (RFC 6749) defines scopes as &lt;strong&gt;space-delimited&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="py"&gt;scope&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"read:urls write:urls"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But some OAuth clients send them &lt;strong&gt;comma-delimited&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="py"&gt;scope&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"read:urls,write:urls"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Our scope parser split on spaces. Pipedream sent commas. The parser saw &lt;code&gt;"read:urls,write:urls"&lt;/code&gt; as a single unknown scope, which mapped to zero valid scopes.&lt;/p&gt;

&lt;p&gt;One character. Comma vs space.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Before: only splits on space&lt;/span&gt;
&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;[]&lt;/span&gt; &lt;span class="n"&gt;scopes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;scopeString&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;split&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;" "&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// After: splits on comma OR space&lt;/span&gt;
&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;[]&lt;/span&gt; &lt;span class="n"&gt;scopes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;scopeString&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;split&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"[,\\s]+"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the fix. One regex character class. The rest of this post is about making sure it never happens again.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Test Suite
&lt;/h2&gt;

&lt;p&gt;We wrote a full end-to-end OAuth integration test: 1,605 lines covering the complete flow.&lt;/p&gt;

&lt;p&gt;The test covers:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Authorization code request&lt;/strong&gt; — with various scope formats&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Token exchange&lt;/strong&gt; — authorization code → access token&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Token refresh&lt;/strong&gt; — refresh token → new access token&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scope validation&lt;/strong&gt; — comma-delimited, space-delimited, mixed, duplicates&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Error cases&lt;/strong&gt; — invalid codes, expired tokens, revoked grants&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Real API calls&lt;/strong&gt; — using the token against actual protected endpoints&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The scope parsing tests specifically:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Space-delimited (RFC 6749 standard)&lt;/span&gt;
&lt;span class="n"&gt;assertScopeParsed&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"read:urls write:urls"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"read:urls"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"write:urls"&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;

&lt;span class="c1"&gt;// Comma-delimited (common in practice)&lt;/span&gt;
&lt;span class="n"&gt;assertScopeParsed&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"read:urls,write:urls"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"read:urls"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"write:urls"&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;

&lt;span class="c1"&gt;// Mixed (yes, this happens)&lt;/span&gt;
&lt;span class="n"&gt;assertScopeParsed&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"read:urls, write:urls"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"read:urls"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"write:urls"&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;

&lt;span class="c1"&gt;// Duplicates&lt;/span&gt;
&lt;span class="n"&gt;assertScopeParsed&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"read:urls read:urls"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"read:urls"&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Lessons Learned
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;RFCs are prescriptive, clients are creative&lt;/strong&gt; — the spec says space-delimited, but real clients do whatever they want. Parse generously.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;E2E tests catch what unit tests miss&lt;/strong&gt; — our unit tests for scope parsing passed because they all used space-delimited scopes. The integration path through the actual OAuth flow with a real client exposed the mismatch.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One-character bugs hide in plain sight&lt;/strong&gt; — the scope string looked correct in logs. You had to know that &lt;code&gt;read:urls,write:urls&lt;/code&gt; was one scope, not two, to spot the problem.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test the integration, not the unit&lt;/strong&gt; — for auth flows especially, the value is in testing the full chain: consent → code → token → API call. Mocking any part of that chain hides bugs like this one.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;strong&gt;Ever been bitten by a delimiter bug?&lt;/strong&gt; What's the smallest change that broke your production? Drop it in the comments.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Building &lt;a href="https://jo4.io" rel="noopener noreferrer"&gt;jo4.io&lt;/a&gt; — a URL shortener with a developer API that actually works.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>authentication</category>
      <category>java</category>
      <category>debugging</category>
      <category>webdev</category>
    </item>
    <item>
      <title>The Idempotency Bug That Spammed dev.to's API for Weeks</title>
      <dc:creator>Anand Rathnas</dc:creator>
      <pubDate>Sat, 25 Apr 2026 01:34:40 +0000</pubDate>
      <link>https://dev.to/anand_rathnas_d5b608cc3de/the-idempotency-bug-that-spammed-devtos-api-for-weeks-2a7i</link>
      <guid>https://dev.to/anand_rathnas_d5b608cc3de/the-idempotency-bug-that-spammed-devtos-api-for-weeks-2a7i</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;This article was originally published on &lt;a href="https://jo4.io/blog/idempotency-bug-devto-crosspost-automation/" rel="noopener noreferrer"&gt;Jo4 Blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;We built a small tool to keep our dev.to posts in sync with our markdown source files. Write locally, push to Git, and the tool updates dev.to if anything changed. Simple.&lt;/p&gt;

&lt;p&gt;One morning, we noticed dev.to showing "Updated 2 hours ago" on an article we hadn't touched in weeks.&lt;/p&gt;

&lt;p&gt;Then we checked the logs. Every article with an &lt;code&gt;updatedAt&lt;/code&gt; field in its frontmatter was being republished. Every. Single. Day.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the Sync Works
&lt;/h2&gt;

&lt;p&gt;The tool is straightforward:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Read markdown posts with frontmatter (&lt;code&gt;title&lt;/code&gt;, &lt;code&gt;publishAfter&lt;/code&gt;, &lt;code&gt;updatedAt&lt;/code&gt;, etc.)&lt;/li&gt;
&lt;li&gt;For each post already on dev.to, check: "Has the local version changed since last sync?"&lt;/li&gt;
&lt;li&gt;If &lt;code&gt;isUpdateNeeded()&lt;/code&gt; returns &lt;code&gt;true&lt;/code&gt;, PUT the latest content to dev.to's API&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The check logic:&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;function&lt;/span&gt; &lt;span class="nf"&gt;isUpdateNeeded&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;localPost&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;devtoArticle&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// If local content changed after dev.to publish date, update needed&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;localDate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;localPost&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;updatedAt&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;localPost&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;publishAfter&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;devtoDate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;devtoArticle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;published_at&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;localDate&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;devtoDate&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;Looks reasonable. If the local post was updated after it was published on dev.to, push the update.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Bug
&lt;/h2&gt;

&lt;p&gt;Here's a timeline of what actually happens:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Day 1:&lt;/strong&gt; Post published with &lt;code&gt;publishAfter: "2026-03-01"&lt;/code&gt;, no &lt;code&gt;updatedAt&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Day 1 sync:&lt;/strong&gt; Script creates article on dev.to. &lt;code&gt;published_at&lt;/code&gt; = &lt;code&gt;2026-03-01T01:00:00Z&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Day 5:&lt;/strong&gt; We fix a typo. Set &lt;code&gt;updatedAt: "2026-03-05"&lt;/code&gt; in frontmatter&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Day 5 sync:&lt;/strong&gt; &lt;code&gt;isUpdateNeeded()&lt;/code&gt; → &lt;code&gt;2026-03-05 &amp;gt; 2026-03-01&lt;/code&gt; → &lt;code&gt;true&lt;/code&gt;. Updates dev.to. Correct.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Day 6 sync:&lt;/strong&gt; &lt;code&gt;isUpdateNeeded()&lt;/code&gt; → &lt;code&gt;2026-03-05 &amp;gt; 2026-03-01&lt;/code&gt; → &lt;code&gt;true&lt;/code&gt;. Updates dev.to again. &lt;strong&gt;Wrong.&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Day 7 sync:&lt;/strong&gt; Same thing. And Day 8. And Day 9. Forever.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The problem: &lt;code&gt;updatedAt&lt;/code&gt; in the frontmatter is a static value. It doesn't change after Day 5. But &lt;code&gt;published_at&lt;/code&gt; on dev.to reflects the &lt;em&gt;original&lt;/em&gt; publish date, not the last update. So &lt;code&gt;updatedAt &amp;gt; published_at&lt;/code&gt; is permanently &lt;code&gt;true&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Every sync run thinks the article needs updating because the local update date is after the original publish date. It will never become false.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why This Is an Idempotency Failure
&lt;/h2&gt;

&lt;p&gt;An idempotent operation produces the same result whether you run it once or a hundred times. Our sync was &lt;em&gt;not&lt;/em&gt; idempotent because:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The comparison &lt;code&gt;updatedAt &amp;gt; published_at&lt;/code&gt; doesn't account for "has this update already been pushed?"&lt;/li&gt;
&lt;li&gt;There's no record of "we already synced this version"&lt;/li&gt;
&lt;li&gt;The trigger condition never resets&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is the classic state management trap in automation: &lt;strong&gt;comparing a static input timestamp against a fixed reference point creates a permanently true condition.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Fix
&lt;/h2&gt;

&lt;p&gt;We needed the sync to know: "Have I already pushed this update?" The answer was to compare against dev.to's &lt;code&gt;edited_at&lt;/code&gt; field (which reflects the last API update), not &lt;code&gt;published_at&lt;/code&gt;:&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;function&lt;/span&gt; &lt;span class="nf"&gt;isUpdateNeeded&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;localPost&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;devtoArticle&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;localDate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;localPost&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;updatedAt&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;localPost&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;publishAfter&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="c1"&gt;// Compare against last edit, not original publish&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;devtoDate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;devtoArticle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;edited_at&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;devtoArticle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;published_at&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;localDate&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;devtoDate&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;Now the timeline works:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Day 5 sync:&lt;/strong&gt; &lt;code&gt;2026-03-05 &amp;gt; 2026-03-01&lt;/code&gt; → &lt;code&gt;true&lt;/code&gt;. Updates dev.to. &lt;code&gt;edited_at&lt;/code&gt; = &lt;code&gt;2026-03-05T01:00:00Z&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Day 6 sync:&lt;/strong&gt; &lt;code&gt;2026-03-05 &amp;gt; 2026-03-05&lt;/code&gt; → &lt;code&gt;false&lt;/code&gt;. No update. Done.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The second piece was handling the &lt;code&gt;null&lt;/code&gt; propagation. When there's no update and we sync metadata, the script was using &lt;code&gt;Object.assign()&lt;/code&gt; to merge frontmatter. But &lt;code&gt;Object.assign&lt;/code&gt; skips &lt;code&gt;undefined&lt;/code&gt; values — so when &lt;code&gt;updatedAt&lt;/code&gt; wasn't set, the old value persisted instead of being cleared:&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;// Before: Object.assign ignores undefined, so stale updatedAt persists&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;merged&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;assign&lt;/span&gt;&lt;span class="p"&gt;({},&lt;/span&gt; &lt;span class="nx"&gt;defaults&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;frontmatter&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// After: explicitly handle null/undefined fields&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;merged&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="nx"&gt;defaults&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;frontmatter&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;frontmatter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;updatedAt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;delete&lt;/span&gt; &lt;span class="nx"&gt;merged&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;updatedAt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// Don't carry forward stale dates&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The Broader Lesson
&lt;/h2&gt;

&lt;p&gt;Every automation system that syncs state between two systems needs to answer this question: &lt;strong&gt;"How do I know this sync already happened?"&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Common patterns:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Approach&lt;/th&gt;
&lt;th&gt;Pros&lt;/th&gt;
&lt;th&gt;Cons&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Compare timestamps&lt;/strong&gt; (what we did, fixed)&lt;/td&gt;
&lt;td&gt;Simple, no extra storage&lt;/td&gt;
&lt;td&gt;Must compare correct timestamps&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Store sync hash&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Deterministic, content-based&lt;/td&gt;
&lt;td&gt;Extra storage/state to manage&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Idempotency key per sync&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Guarantees exactly-once&lt;/td&gt;
&lt;td&gt;Complex, needs key generation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Event sourcing&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Full audit trail&lt;/td&gt;
&lt;td&gt;Heavy for simple use cases&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For content crossposting, timestamp comparison is the right level of complexity. You just need to compare against the &lt;em&gt;right&lt;/em&gt; timestamp — the one that reflects "when was this last synced?" not "when was this first published?"&lt;/p&gt;

&lt;h2&gt;
  
  
  How We Caught It
&lt;/h2&gt;

&lt;p&gt;Honestly? By accident. We noticed articles on dev.to showing "Updated recently" when we knew we hadn't changed them. A quick look at the sync logs confirmed it — the same articles being pushed on every run. The fix was five lines of logic. The debugging was thirty minutes of reading logs.&lt;/p&gt;

&lt;p&gt;The embarrassment of silently spamming dev.to's API for weeks? Immeasurable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Idempotency isn't optional in automation.&lt;/strong&gt; If your sync can run twice and produce different results (or the same unnecessary result), it's broken.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test your sync with unchanged content.&lt;/strong&gt; Run your pipeline twice in a row. Does the second run do nothing? If not, you have a bug.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;published_at&lt;/code&gt; and &lt;code&gt;edited_at&lt;/code&gt; are different things.&lt;/strong&gt; Most APIs have both. Use the right one for your comparison.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Object.assign&lt;/code&gt; doesn't propagate &lt;code&gt;undefined&lt;/code&gt;.&lt;/strong&gt; If you're merging objects where "missing" is meaningful state, handle it explicitly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Monitor your automation output, not just success/failure.&lt;/strong&gt; Our script returned 200 every time. It was "succeeding" at doing unnecessary work.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;strong&gt;Have you been bitten by an idempotency bug in your automation?&lt;/strong&gt; What was the trigger? Drop it below.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Building &lt;a href="https://jo4.io" rel="noopener noreferrer"&gt;jo4.io&lt;/a&gt; — a URL shortener with analytics, bio pages, and an affiliate marketplace for creators.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>api</category>
      <category>beginners</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
