<?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>Forem: Anand Rathnas</title>
    <description>The latest articles on Forem by Anand Rathnas (@anand_rathnas_d5b608cc3de).</description>
    <link>https://forem.com/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>Forem: Anand Rathnas</title>
      <link>https://forem.com/anand_rathnas_d5b608cc3de</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/anand_rathnas_d5b608cc3de"/>
    <language>en</language>
    <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://forem.com/anand_rathnas_d5b608cc3de/why-we-removed-ads-from-our-free-tools-and-put-them-only-on-blog-posts-2k7j</link>
      <guid>https://forem.com/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://forem.com/anand_rathnas_d5b608cc3de/the-auth0-pricing-trap-why-upgrading-to-paid-gives-you-less-3m5f</link>
      <guid>https://forem.com/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://forem.com/anand_rathnas_d5b608cc3de/publishing-an-expo-app-to-the-app-store-the-parts-nobody-warns-you-about-1ffi</link>
      <guid>https://forem.com/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://forem.com/anand_rathnas_d5b608cc3de/why-we-killed-hold-windows-in-our-affiliate-marketplace-1jdn</link>
      <guid>https://forem.com/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://forem.com/anand_rathnas_d5b608cc3de/3-auth-bugs-we-shipped-to-production-spring-auth0-nkg</link>
      <guid>https://forem.com/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://forem.com/anand_rathnas_d5b608cc3de/the-one-character-oauth-bug-that-broke-our-api-783</link>
      <guid>https://forem.com/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://forem.com/anand_rathnas_d5b608cc3de/the-idempotency-bug-that-spammed-devtos-api-for-weeks-2a7i</link>
      <guid>https://forem.com/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>
    <item>
      <title>Why Safari Said 'Link Not Found' (And Chrome Didn't)</title>
      <dc:creator>Anand Rathnas</dc:creator>
      <pubDate>Wed, 22 Apr 2026 01:35:15 +0000</pubDate>
      <link>https://forem.com/anand_rathnas_d5b608cc3de/why-safari-said-link-not-found-and-chrome-didnt-2dn5</link>
      <guid>https://forem.com/anand_rathnas_d5b608cc3de/why-safari-said-link-not-found-and-chrome-didnt-2dn5</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;This article was originally published on &lt;a href="https://jo4.io/blog/safari-link-not-found-race-condition/" rel="noopener noreferrer"&gt;Jo4 Blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If you build a URL shortener and your links show "Link Not Found" on Safari, you have approximately zero seconds before your support inbox catches fire.&lt;/p&gt;

&lt;p&gt;That's what happened to us. Chrome, Firefox, Edge — all fine. Safari on iOS and macOS — intermittent "This link could not be found or has expired." For a product whose entire job is &lt;em&gt;redirecting links&lt;/em&gt;, this was existential.&lt;/p&gt;

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

&lt;p&gt;Users would tap a short link on their iPhone. Instead of landing on the destination, they'd see our error page flash briefly — "Link Not Found" — then the correct page would load a moment later.&lt;/p&gt;

&lt;p&gt;Some users saw it every time. Some never saw it. The flash was fast enough that screenshots were hard to capture. We couldn't reproduce it consistently on desktop Safari.&lt;/p&gt;

&lt;p&gt;The only consistent signal: it never happened on Chrome.&lt;/p&gt;

&lt;h2&gt;
  
  
  Laying Breadcrumbs
&lt;/h2&gt;

&lt;p&gt;We couldn't reproduce it reliably, so we shipped debug instrumentation. A &lt;code&gt;useRef&lt;/code&gt; array that logged every function call with timestamps, persisted to &lt;code&gt;localStorage&lt;/code&gt; so it survived the page navigation that was about to happen.&lt;/p&gt;

&lt;p&gt;The debug output from a user's device told the story:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;event:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"handleRedirect called"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;timestamp:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1709683200001&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;event:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"API success, redirecting"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;timestamp:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1709683200250&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;event:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"handleRedirect called"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;timestamp:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1709683200252&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;event:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"API error: request cancelled"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;timestamp:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1709683200260&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Entry &lt;code&gt;[2]&lt;/code&gt; was the smoking gun. &lt;code&gt;handleRedirect&lt;/code&gt; was being called &lt;em&gt;twice&lt;/em&gt;. The second call happened 2 milliseconds after the first one successfully redirected.&lt;/p&gt;

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

&lt;p&gt;Here's what our &lt;code&gt;RedirectPage&lt;/code&gt; component did:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Mount → call &lt;code&gt;handleRedirect()&lt;/code&gt; via &lt;code&gt;useEffect&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;handleRedirect()&lt;/code&gt; resolves the URL via API&lt;/li&gt;
&lt;li&gt;On success, call &lt;code&gt;safeRedirect(targetUrl)&lt;/code&gt; (sets &lt;code&gt;window.location.href&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;After redirect, update state: &lt;code&gt;markVisitedInSession()&lt;/code&gt; sets &lt;code&gt;hasVisitedInSession&lt;/code&gt; from &lt;code&gt;false&lt;/code&gt; → &lt;code&gt;true&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;handleRedirect&lt;/code&gt; was wrapped in &lt;code&gt;useCallback&lt;/code&gt; with &lt;code&gt;hasVisitedInSession&lt;/code&gt; as a dependency&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Step 5 is where it breaks. When the state changes, React creates a new &lt;code&gt;handleRedirect&lt;/code&gt; callback identity. The &lt;code&gt;useEffect&lt;/code&gt; sees a new dependency and re-fires the callback.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;But wait — we already navigated away. Why does the second call matter?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Because Safari doesn't stop.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Chrome:&lt;/strong&gt; When you set &lt;code&gt;window.location.href&lt;/code&gt;, Chrome immediately halts JavaScript execution on the current page. The navigation takes over. The second &lt;code&gt;handleRedirect&lt;/code&gt; call never happens.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Safari:&lt;/strong&gt; When you set &lt;code&gt;window.location.href&lt;/code&gt;, Safari &lt;em&gt;continues executing JavaScript&lt;/em&gt; while the navigation is in progress. The second &lt;code&gt;handleRedirect&lt;/code&gt; fires, starts an API request, but the page is mid-navigation. The HTTP request gets cancelled. The &lt;code&gt;catch&lt;/code&gt; block runs. The error page renders.&lt;/p&gt;

&lt;p&gt;The user sees: flash of "Link Not Found" → destination page loads.&lt;/p&gt;

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

&lt;p&gt;Five lines:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;hasRedirected&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useRef&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;handleRedirect&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useCallback&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pwd&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nx"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;hasRedirected&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&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="nx"&gt;pwd&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// Guard&lt;/span&gt;

  &lt;span class="c1"&gt;// ... resolve URL, check previews, etc ...&lt;/span&gt;

  &lt;span class="nx"&gt;hasRedirected&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// Set before triggering navigation&lt;/span&gt;
  &lt;span class="nf"&gt;safeRedirect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;targetUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="cm"&gt;/* deps */&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A &lt;code&gt;useRef&lt;/code&gt; — not &lt;code&gt;useState&lt;/code&gt; — because we specifically don't want to trigger a re-render. The ref persists across renders, survives the callback identity change, and silently blocks the second call.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;&amp;amp;&amp;amp; !pwd&lt;/code&gt; clause allows retries for password-protected URLs where the user submits a password.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why useRef Instead of useState
&lt;/h2&gt;

&lt;p&gt;This is the subtle part. If we'd used &lt;code&gt;useState&lt;/code&gt; for the guard:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;hasRedirected&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setHasRedirected&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Setting it to &lt;code&gt;true&lt;/code&gt; would trigger &lt;em&gt;another&lt;/em&gt; re-render, which could create &lt;em&gt;another&lt;/em&gt; callback identity change, which could re-fire the effect &lt;em&gt;again&lt;/em&gt;. We'd be fighting React's render cycle with React's render cycle.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;useRef&lt;/code&gt; sidesteps the entire problem. It mutates silently. No render. No new callback. No re-fire.&lt;/p&gt;

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

&lt;p&gt;After confirming the fix worked across Safari, Chrome, and Firefox, we stripped out the debug instrumentation — the &lt;code&gt;localStorage&lt;/code&gt; logging, the debug panel on the error page. It served its purpose.&lt;/p&gt;

&lt;p&gt;But we kept the &lt;code&gt;useRef&lt;/code&gt; guard. It's three lines of defense against a browser behavior difference that no amount of testing would have caught in CI.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Browsers diverge on navigation behavior.&lt;/strong&gt; Chrome halts JS on &lt;code&gt;window.location.href&lt;/code&gt;. Safari doesn't. This isn't a bug in either browser — the spec doesn't mandate when to stop execution.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;useCallback&lt;/code&gt; + &lt;code&gt;useEffect&lt;/code&gt; dependencies can create invisible re-execution loops.&lt;/strong&gt; If your callback updates state that's in its own dependency array, you have a loop. It's just usually invisible because the page navigated away before it matters.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ship debug instrumentation to production.&lt;/strong&gt; When you can't reproduce a bug locally, instrument the code path, ship it behind a flag or with minimal overhead, and let the user's device tell you what happened.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;useRef&lt;/code&gt; is your escape hatch from React's reactivity.&lt;/strong&gt; When you need to track state that should NOT trigger renders, refs are the right tool.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;strong&gt;Ever been bitten by a Safari-specific JS behavior?&lt;/strong&gt; What was it? 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 that works on every browser. Yes, even Safari.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>safari</category>
      <category>debugging</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Referral Tracking for Indie Hackers: Skip the $300/mo Tools</title>
      <dc:creator>Anand Rathnas</dc:creator>
      <pubDate>Thu, 19 Mar 2026 01:35:44 +0000</pubDate>
      <link>https://forem.com/anand_rathnas_d5b608cc3de/referral-tracking-for-indie-hackers-skip-the-300mo-tools-1nln</link>
      <guid>https://forem.com/anand_rathnas_d5b608cc3de/referral-tracking-for-indie-hackers-skip-the-300mo-tools-1nln</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;This article was originally published on &lt;a href="https://jo4.io/blog/referral-tracking-indie-hackers/" rel="noopener noreferrer"&gt;Jo4 Blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I needed referral tracking for my SaaS. The options were depressing.&lt;/p&gt;

&lt;p&gt;When I checked (early 2026): ReferralCandy started at $59/mo, designed for e-commerce. Rewardful at $29/mo, but that's just the starting tier. FirstPromoter and PartnerStack were priced for larger teams.&lt;/p&gt;

&lt;p&gt;I'm a solo founder. My MRR is in the hundreds, not thousands. Every dollar saved is another month of runway.&lt;/p&gt;

&lt;p&gt;Here's the thing: I don't need a referral &lt;em&gt;platform&lt;/em&gt;. I need to know which users came from which invite link. That's it.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I Actually Needed
&lt;/h2&gt;

&lt;p&gt;Let me break down the "enterprise referral solution" into what matters:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Unique links per referrer&lt;/strong&gt; - So I know who sent them&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Click tracking&lt;/strong&gt; - How many people clicked&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Attribution&lt;/strong&gt; - Connect the click to a signup&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The first two are literally what URL shorteners do. The third is a query parameter and some code.&lt;/p&gt;

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

&lt;p&gt;I created invite links using &lt;a href="https://jo4.io" rel="noopener noreferrer"&gt;jo4.io&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://jo4.io/invite-sarah → myapp.com/signup?ref=sarah
https://jo4.io/invite-mike  → myapp.com/signup?ref=mike
https://jo4.io/invite-alex  → myapp.com/signup?ref=alex
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each referrer gets a memorable short link. Jo4 tracks clicks automatically. My signup form reads the &lt;code&gt;ref&lt;/code&gt; parameter and stores it.&lt;/p&gt;

&lt;p&gt;Total cost: $0 (free tier covers this easily).&lt;/p&gt;

&lt;h2&gt;
  
  
  The Analytics I Get
&lt;/h2&gt;

&lt;p&gt;For each invite link, jo4 shows me:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Total clicks&lt;/strong&gt; - How many people hit the link&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Unique visitors&lt;/strong&gt; - Deduplicated by IP&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Geographic breakdown&lt;/strong&gt; - Where clicks came from&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Device/browser&lt;/strong&gt; - Mobile vs desktop&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Referrer&lt;/strong&gt; - Where the link was shared (Twitter, email, etc.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Timeline&lt;/strong&gt; - When clicks happened&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is more data than I had with the $59/mo tool I tried last year.&lt;/p&gt;

&lt;h2&gt;
  
  
  Handling Attribution
&lt;/h2&gt;

&lt;p&gt;On my signup page:&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;// Grab the ref parameter&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URLSearchParams&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;search&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;referrer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ref&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;referrer&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Store it for the signup request&lt;/span&gt;
  &lt;span class="nx"&gt;localStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;referrer&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;referrer&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;On signup submission:&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;referrer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;localStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;referrer&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api/signup&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;password&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;referredBy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;referrer&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. Now I have a &lt;code&gt;referred_by&lt;/code&gt; column in my users table.&lt;/p&gt;

&lt;h2&gt;
  
  
  What About Rewards?
&lt;/h2&gt;

&lt;p&gt;"But what about paying out referral bonuses?"&lt;/p&gt;

&lt;p&gt;I run a report once a month:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;referred_by&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;signups&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;referred_by&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
&lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;NOW&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;INTERVAL&lt;/span&gt; &lt;span class="s1"&gt;'30 days'&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;referred_by&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;signups&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then I send PayPal/Wise payments manually. At my scale (&amp;lt; 50 referrals/month), this takes 10 minutes.&lt;/p&gt;

&lt;p&gt;When I'm at 500 referrals/month, I'll automate it. Or I'll pay for a tool. But I'll also have the revenue to justify it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Advanced: UTM Tracking
&lt;/h2&gt;

&lt;p&gt;For power referrers who share on multiple platforms, I give them UTM-tagged variants:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://jo4.io/sarah-twitter → myapp.com/signup?ref=sarah&amp;amp;utm_source=twitter
https://jo4.io/sarah-youtube → myapp.com/signup?ref=sarah&amp;amp;utm_source=youtube
https://jo4.io/sarah-newsletter → myapp.com/signup?ref=sarah&amp;amp;utm_source=newsletter
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now I know not just WHO referred them, but WHERE. Jo4's analytics show me which links perform best.&lt;/p&gt;

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

&lt;p&gt;After 3 months with this setup:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;47 referral signups&lt;/strong&gt; tracked&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;$0 spent&lt;/strong&gt; on referral tools&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;$2,820 saved&lt;/strong&gt; vs. the "affordable" option&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;10 minutes/month&lt;/strong&gt; on manual payouts&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The enterprise tools have dashboards and automation. I have a SQL query and a spreadsheet. At my scale, that's the right tradeoff.&lt;/p&gt;

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

&lt;p&gt;I'll pay for a real referral platform when:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Referral volume exceeds 100/month (manual payouts become painful)&lt;/li&gt;
&lt;li&gt;I need tiered rewards (different rates for different referrers)&lt;/li&gt;
&lt;li&gt;Compliance requires audit trails I can't build myself&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Until then, a URL shortener with analytics does 80% of the job at 0% of the cost.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Running a referral program on a budget?&lt;/strong&gt; Share your setup 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 analytics that indie hackers actually use for invite links, affiliate tracking, and campaign attribution.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>startup</category>
      <category>saas</category>
      <category>buildinpublic</category>
      <category>jo4io</category>
    </item>
    <item>
      <title>20 Free Developer Tools We Built (And Why We Gave Them Away)</title>
      <dc:creator>Anand Rathnas</dc:creator>
      <pubDate>Tue, 17 Mar 2026 01:35:06 +0000</pubDate>
      <link>https://forem.com/anand_rathnas_d5b608cc3de/20-free-developer-tools-we-built-and-why-we-gave-them-away-1lpd</link>
      <guid>https://forem.com/anand_rathnas_d5b608cc3de/20-free-developer-tools-we-built-and-why-we-gave-them-away-1lpd</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;This article was originally published on &lt;a href="https://jo4.io/blog/free-developer-tools/" rel="noopener noreferrer"&gt;Jo4 Blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;You know those random utility websites you visit once, use for 30 seconds, and never think about again?&lt;/p&gt;

&lt;p&gt;We built 20 of them. And put them all in one place at &lt;a href="https://jo4.io/u" rel="noopener noreferrer"&gt;jo4.io/u&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;No signup. No ads. No "subscribe to access." Just tools.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why Build Free Tools on a Paid Product?
&lt;/h2&gt;

&lt;p&gt;We're a URL shortener. People pay us for short links, analytics, and QR codes. So why give away free tools?&lt;/p&gt;

&lt;h3&gt;
  
  
  1. We Needed Them
&lt;/h3&gt;

&lt;p&gt;This is the honest answer. While building jo4, we constantly needed:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;JWT decoder to debug OAuth tokens&lt;/li&gt;
&lt;li&gt;Base64 encoder/decoder for API payloads&lt;/li&gt;
&lt;li&gt;JSON formatter to read webhook responses&lt;/li&gt;
&lt;li&gt;Timestamp converter to debug expiration issues&lt;/li&gt;
&lt;li&gt;Hash generator for testing HMAC signatures&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We were opening random websites, getting hit with ads and popups, and thinking "this is stupid." So we built our own.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. We're Developer-Friendly
&lt;/h3&gt;

&lt;p&gt;Our tagline is literally "Built for developers who ship." If we're going to claim that, we need to prove it.&lt;/p&gt;

&lt;p&gt;Free tools with no signup, no dark patterns, no BS—that's what developer-friendly looks like. It's not just marketing. It's our identity.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. SEO (Yes, Also This)
&lt;/h3&gt;

&lt;p&gt;Every developer searching for "base64 decoder" or "jwt decoder online" is a potential customer who might also need a URL shortener. The tools get traffic, the traffic discovers our main product.&lt;/p&gt;

&lt;p&gt;It's a legitimate marketing play. But the difference is: &lt;strong&gt;we built tools we actually use&lt;/strong&gt;. Not half-baked "sign up to see results" garbage.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Full List
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Encoding &amp;amp; Decoding
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;URL&lt;/th&gt;
&lt;th&gt;What It Does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Base64 Encoder/Decoder&lt;/td&gt;
&lt;td&gt;&lt;a href="https://jo4.io/u/b64" rel="noopener noreferrer"&gt;/u/b64&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Encode/decode Base64 strings&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;URL Encoder/Decoder&lt;/td&gt;
&lt;td&gt;&lt;a href="https://jo4.io/u/encode" rel="noopener noreferrer"&gt;/u/encode&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Encode/decode URL components&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;JWT Decoder&lt;/td&gt;
&lt;td&gt;&lt;a href="https://jo4.io/u/jwt" rel="noopener noreferrer"&gt;/u/jwt&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Decode and inspect JSON Web Tokens&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;JWT Decoder&lt;/strong&gt; is our most-used tool. Paste a token, instantly see the header, payload, and expiration time. No external requests—everything happens in your browser.&lt;/p&gt;

&lt;h3&gt;
  
  
  Generators
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;URL&lt;/th&gt;
&lt;th&gt;What It Does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Password Generator&lt;/td&gt;
&lt;td&gt;&lt;a href="https://jo4.io/u/pwd" rel="noopener noreferrer"&gt;/u/pwd&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Generate secure passwords with strength meter&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;UUID Generator&lt;/td&gt;
&lt;td&gt;&lt;a href="https://jo4.io/u/uuid" rel="noopener noreferrer"&gt;/u/uuid&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Generate v1, v4, and v7 UUIDs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Random Generator&lt;/td&gt;
&lt;td&gt;&lt;a href="https://jo4.io/u/random" rel="noopener noreferrer"&gt;/u/random&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Generate random strings, numbers, UUIDs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hash Generator&lt;/td&gt;
&lt;td&gt;&lt;a href="https://jo4.io/u/hash" rel="noopener noreferrer"&gt;/u/hash&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Generate MD5, SHA-1, SHA-256, SHA-512 hashes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lorem Ipsum Generator&lt;/td&gt;
&lt;td&gt;&lt;a href="https://jo4.io/u/lorem" rel="noopener noreferrer"&gt;/u/lorem&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Generate placeholder text&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;QR Code Generator&lt;/td&gt;
&lt;td&gt;&lt;a href="https://jo4.io/u/qr" rel="noopener noreferrer"&gt;/u/qr&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Create QR codes with custom colors and sizes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Password Generator&lt;/strong&gt; calculates actual entropy, not fake "strength bars." A 12-character password with all character types = ~79 bits of entropy. We show the math.&lt;/p&gt;

&lt;h3&gt;
  
  
  Text Tools
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;URL&lt;/th&gt;
&lt;th&gt;What It Does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;JSON Formatter&lt;/td&gt;
&lt;td&gt;&lt;a href="https://jo4.io/u/json" rel="noopener noreferrer"&gt;/u/json&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Format, validate, beautify JSON&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Markdown Preview&lt;/td&gt;
&lt;td&gt;&lt;a href="https://jo4.io/u/md" rel="noopener noreferrer"&gt;/u/md&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Write and preview markdown in real-time&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Diff Checker&lt;/td&gt;
&lt;td&gt;&lt;a href="https://jo4.io/u/diff" rel="noopener noreferrer"&gt;/u/diff&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Compare two texts, supports JSON/YAML&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Text Case Converter&lt;/td&gt;
&lt;td&gt;&lt;a href="https://jo4.io/u/case" rel="noopener noreferrer"&gt;/u/case&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Convert between UPPER, lower, camelCase, snake_case&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Word Counter&lt;/td&gt;
&lt;td&gt;&lt;a href="https://jo4.io/u/words" rel="noopener noreferrer"&gt;/u/words&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Count words, characters, sentences, reading time&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;URL Slug Generator&lt;/td&gt;
&lt;td&gt;&lt;a href="https://jo4.io/u/slug" rel="noopener noreferrer"&gt;/u/slug&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Convert text to clean, SEO-friendly slugs&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Diff Checker&lt;/strong&gt; is surprisingly powerful. It auto-detects JSON/YAML and formats before comparing, so you can paste minified JSON and still get a readable diff.&lt;/p&gt;

&lt;h3&gt;
  
  
  Converters
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;URL&lt;/th&gt;
&lt;th&gt;What It Does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Color Converter&lt;/td&gt;
&lt;td&gt;&lt;a href="https://jo4.io/u/color" rel="noopener noreferrer"&gt;/u/color&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Convert between HEX, RGB, HSL, HSV&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Unix Timestamp Converter&lt;/td&gt;
&lt;td&gt;&lt;a href="https://jo4.io/u/time" rel="noopener noreferrer"&gt;/u/time&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Convert timestamps to human dates and back&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Unix Timestamp Converter&lt;/strong&gt; handles milliseconds vs seconds automatically. No more "is this 10 digits or 13?" confusion.&lt;/p&gt;

&lt;h3&gt;
  
  
  URL &amp;amp; Marketing Tools
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;URL&lt;/th&gt;
&lt;th&gt;What It Does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;UTM Builder&lt;/td&gt;
&lt;td&gt;&lt;a href="https://jo4.io/u/utm" rel="noopener noreferrer"&gt;/u/utm&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Build URLs with UTM parameters for campaign tracking&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;URL Checker&lt;/td&gt;
&lt;td&gt;&lt;a href="https://jo4.io/u/check" rel="noopener noreferrer"&gt;/u/check&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Check DNS, SSL, redirects, and safety status&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OG Preview&lt;/td&gt;
&lt;td&gt;&lt;a href="https://jo4.io/u/og" rel="noopener noreferrer"&gt;/u/og&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Preview how URLs appear on social media&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;UTM Builder&lt;/strong&gt; ties directly into our URL shortener. Build your UTM link, optionally shorten it, track everything. Full funnel in one page.&lt;/p&gt;




&lt;h2&gt;
  
  
  Design Philosophy
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Client-Side First
&lt;/h3&gt;

&lt;p&gt;Most tools run entirely in your browser. No server requests. No data sent anywhere. When you paste a JWT, it never leaves your machine.&lt;/p&gt;

&lt;p&gt;The only tools that require API calls:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;URL Checker&lt;/strong&gt; (needs to fetch the actual URL)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;OG Preview&lt;/strong&gt; (needs to fetch metadata)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And even those are rate-limited and don't store anything.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. No Dark Patterns
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;No "sign up to see results"&lt;/li&gt;
&lt;li&gt;No "share to unlock"&lt;/li&gt;
&lt;li&gt;No interstitial ads&lt;/li&gt;
&lt;li&gt;No newsletter popups&lt;/li&gt;
&lt;li&gt;No fake urgency&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You land on the page, use the tool, leave. That's it.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Keyboard-First
&lt;/h3&gt;

&lt;p&gt;Every tool supports:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;Ctrl/Cmd + Enter&lt;/code&gt; to execute&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Ctrl/Cmd + C&lt;/code&gt; to copy result&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Esc&lt;/code&gt; to clear&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We use these tools daily. Keyboard shortcuts matter.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Mobile-Responsive
&lt;/h3&gt;

&lt;p&gt;All tools work on mobile. The text areas resize. The buttons are tap-friendly. You can decode a JWT on your phone at 2 AM when production is on fire.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Not that we've ever done that.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The Tech Stack
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;React + TypeScript + Tailwind CSS
├── Client-side crypto for hashes
├── Client-side JWT parsing
├── Client-side QR generation
├── RTK Query for API calls
└── Shared UI components (shadcn/ui)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each tool is a standalone page component. No shared state. No complex routing. Load fast, do one thing well.&lt;/p&gt;




&lt;h2&gt;
  
  
  Usage Stats (The SEO Payoff)
&lt;/h2&gt;

&lt;p&gt;After 3 months:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Monthly Visits&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;JWT Decoder&lt;/td&gt;
&lt;td&gt;~4,200&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;JSON Formatter&lt;/td&gt;
&lt;td&gt;~3,100&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Base64 Encoder&lt;/td&gt;
&lt;td&gt;~2,400&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Password Generator&lt;/td&gt;
&lt;td&gt;~1,800&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Others combined&lt;/td&gt;
&lt;td&gt;~3,500&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Total: ~15,000 monthly visitors&lt;/strong&gt; who now know jo4.io exists.&lt;/p&gt;

&lt;p&gt;Conversion to paid? Low, around 0.3%. But that's 45 paying customers who found us through free tools. At $16/month average, that's $720 MRR from SEO content that costs us nothing to maintain.&lt;/p&gt;




&lt;h2&gt;
  
  
  Tools We're Still Building
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;/u/*&lt;/code&gt; pattern works. We're adding more:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cron Expression Builder&lt;/strong&gt; - Build and explain cron expressions&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Regex Tester&lt;/strong&gt; - Test regex patterns with live matching&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;HTTP Header Inspector&lt;/strong&gt; - See request/response headers for any URL&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SVG to PNG Converter&lt;/strong&gt; - Convert SVG files to raster images&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you have suggestions, let us know.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Full Directory
&lt;/h2&gt;

&lt;p&gt;Everything lives at &lt;a href="https://jo4.io/u" rel="noopener noreferrer"&gt;jo4.io/u&lt;/a&gt;. Bookmark it. Use it. Tell your friends.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Category&lt;/th&gt;
&lt;th&gt;Tools&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Encoding&lt;/td&gt;
&lt;td&gt;Base64, URL Encode, JWT Decode&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Generators&lt;/td&gt;
&lt;td&gt;Password, UUID, Random, Hash, Lorem, QR&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Text&lt;/td&gt;
&lt;td&gt;JSON, Markdown, Diff, Case, Word Count, Slug&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Converters&lt;/td&gt;
&lt;td&gt;Color, Timestamp&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Marketing&lt;/td&gt;
&lt;td&gt;UTM Builder, URL Checker, OG Preview&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;20 tools. Zero signup. Zero cost.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;What tools do you wish existed?&lt;/strong&gt; We're always looking for ideas.&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, plus 20 free developer tools at &lt;a href="https://jo4.io/u" rel="noopener noreferrer"&gt;jo4.io/u&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>tools</category>
      <category>productivity</category>
      <category>opensource</category>
    </item>
    <item>
      <title>The Easiest Integration We've Ever Done: Two Markdown Files and a Domain Name Identity Crisis</title>
      <dc:creator>Anand Rathnas</dc:creator>
      <pubDate>Sat, 14 Mar 2026 01:34:35 +0000</pubDate>
      <link>https://forem.com/anand_rathnas_d5b608cc3de/the-easiest-integration-weve-ever-done-two-markdown-files-and-a-domain-name-identity-crisis-3jkm</link>
      <guid>https://forem.com/anand_rathnas_d5b608cc3de/the-easiest-integration-weve-ever-done-two-markdown-files-and-a-domain-name-identity-crisis-3jkm</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;This article was originally published on &lt;a href="https://jo4.io/blog/clawhub-skill-integration/" rel="noopener noreferrer"&gt;Jo4 Blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;After wrestling with &lt;a href="https://dev.to/blog/zapier-oauth-spring-boot/"&gt;Zapier OAuth&lt;/a&gt; and navigating &lt;a href="https://dev.to/blog/pipedream-integration-journey/"&gt;Pipedream's human-first process&lt;/a&gt;, we braced ourselves for ClawHub.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;"What hoops will we have to jump through this time?"&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The answer: None. Zero hoops. Two markdown files. Done.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Domain Name Identity Crisis
&lt;/h2&gt;

&lt;p&gt;Before we get into the integration, let's address the elephant in the room.&lt;/p&gt;

&lt;p&gt;This platform has had more domain names than I've had hot dinners:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;calwd.com → openclaw.ai → clawhub.ai
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I'm genuinely not sure what to call it in conversation anymore.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Hey, have you seen that AI skills platform?"&lt;br&gt;
"Which one?"&lt;br&gt;
"You know... the claw one?"&lt;br&gt;
"Calwd?"&lt;br&gt;
"No, they changed it."&lt;br&gt;
"OpenClaw?"&lt;br&gt;
"Nope, changed again."&lt;br&gt;
"ClawHub?"&lt;br&gt;
"...for now."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;At this point, I half expect to wake up tomorrow and find it's now &lt;code&gt;crabpeople.io&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;But here's the thing: &lt;strong&gt;the product is actually really good&lt;/strong&gt;. The domain name musical chairs? Just a startup finding its footing. It happens.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Integration: Two Files
&lt;/h2&gt;

&lt;p&gt;I'm not exaggerating. The entire Jo4 integration is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;smoothtalk/clawhub/
├── README.md     (33 lines)
└── SKILL.md      (190 lines)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. No OAuth dance. No webhook infrastructure. No SDK to build. Just... markdown.&lt;/p&gt;

&lt;h3&gt;
  
  
  The README.md
&lt;/h3&gt;

&lt;p&gt;A quick intro for humans:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gh"&gt;# Jo4 - URL Shortener &amp;amp; Analytics&lt;/span&gt;

🔗 &lt;span class="gs"&gt;**[jo4.io](https://jo4.io)**&lt;/span&gt; - Modern URL shortening with QR codes and detailed analytics.

&lt;span class="gu"&gt;## Features&lt;/span&gt;
&lt;span class="p"&gt;
-&lt;/span&gt; &lt;span class="gs"&gt;**Short URLs**&lt;/span&gt; - Custom aliases, branded links
&lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="gs"&gt;**QR Codes**&lt;/span&gt; - Auto-generated for every link
&lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="gs"&gt;**Analytics**&lt;/span&gt; - Clicks, geography, devices, referrers
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  The SKILL.md
&lt;/h3&gt;

&lt;p&gt;This is where the magic happens. It's a markdown file with YAML frontmatter that tells the AI how to use your API:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;jo4&lt;/span&gt;
&lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;URL shortener, QR code generator, and link analytics API&lt;/span&gt;
&lt;span class="na"&gt;homepage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://jo4.io&lt;/span&gt;
&lt;span class="na"&gt;user-invocable&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;openclaw&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;emoji&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;🔗"&lt;/span&gt;
    &lt;span class="na"&gt;primaryEnv&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;JO4_API_KEY"&lt;/span&gt;
    &lt;span class="na"&gt;requires&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;JO4_API_KEY"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then you just... document your API. In markdown. With curl examples.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gu"&gt;### Create Short URL (Authenticated)&lt;/span&gt;

&lt;span class="se"&gt;\`\`\`&lt;/span&gt;bash
curl -X POST "https://jo4-api.jo4.io/api/v1/protected/url" &lt;span class="err"&gt;\&lt;/span&gt;
  -H "X-Jo4-API-Key: $JO4_API_KEY" &lt;span class="err"&gt;\&lt;/span&gt;
  -H "Content-Type: application/json" &lt;span class="err"&gt;\&lt;/span&gt;
  -d '{"longUrl": "https://example.com", "title": "My Link"}'
&lt;span class="se"&gt;\`\`\`&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The AI reads this, understands the API structure, and can now use it. No code generation. No SDK maintenance. Just documentation that doubles as integration.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why This Works
&lt;/h2&gt;

&lt;p&gt;ClawHub's approach is beautifully simple:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Documentation IS the integration&lt;/strong&gt; - If you can document it, it's integrated&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Curl examples are universal&lt;/strong&gt; - Any AI can understand &lt;code&gt;curl -X POST&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Environment variables for auth&lt;/strong&gt; - &lt;code&gt;JO4_API_KEY&lt;/code&gt; in the metadata, done&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No deployment&lt;/strong&gt; - Push to their repo, it's live&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Compare this to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Zapier&lt;/strong&gt;: OAuth implementation, webhook infrastructure, app review, QA queue&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pipedream&lt;/strong&gt;: GitHub issue, email credentials, component code, QA queue&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ClawHub&lt;/strong&gt;: Write markdown. Push. Done.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  The Live Integration
&lt;/h2&gt;

&lt;p&gt;It's already live at &lt;a href="https://www.clawhub.ai/anandrathnas/jo4" rel="noopener noreferrer"&gt;clawhub.ai/anandrathnas/jo4&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Users can now ask their AI:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Shorten this URL: &lt;a href="https://example.com/really-long-path" rel="noopener noreferrer"&gt;https://example.com/really-long-path&lt;/a&gt;"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;And it just... works. The AI reads the SKILL.md, finds the right endpoint, makes the call.&lt;/p&gt;




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

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Section&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Authentication&lt;/td&gt;
&lt;td&gt;How to get and use API keys&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Create Short URL&lt;/td&gt;
&lt;td&gt;Main endpoint with all parameters&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Anonymous URLs&lt;/td&gt;
&lt;td&gt;Public endpoint (no auth)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Get URL Details&lt;/td&gt;
&lt;td&gt;Retrieve by slug&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Get Analytics&lt;/td&gt;
&lt;td&gt;Click stats, geo, devices&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;List URLs&lt;/td&gt;
&lt;td&gt;Pagination support&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Update/Delete&lt;/td&gt;
&lt;td&gt;CRUD operations&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;QR Codes&lt;/td&gt;
&lt;td&gt;Auto-generated URLs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rate Limits&lt;/td&gt;
&lt;td&gt;Plan-based limits&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Error Codes&lt;/td&gt;
&lt;td&gt;400, 401, 403, 404, 429&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;All in markdown. All with curl examples. Total effort: maybe 30 minutes.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Metadata That Makes It Work
&lt;/h2&gt;

&lt;p&gt;The frontmatter is the secret sauce:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;openclaw&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;emoji&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;🔗"&lt;/span&gt;
    &lt;span class="na"&gt;primaryEnv&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;JO4_API_KEY"&lt;/span&gt;
    &lt;span class="na"&gt;requires&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;JO4_API_KEY"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This tells ClawHub:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Show the 🔗 emoji in the UI&lt;/li&gt;
&lt;li&gt;The main credential is &lt;code&gt;JO4_API_KEY&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Don't let users invoke without that env var set&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;No complex OAuth scopes. No token refresh logic. Just "need this env var? yes/no."&lt;/p&gt;




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

&lt;h3&gt;
  
  
  1. Sometimes Simpler Is Better
&lt;/h3&gt;

&lt;p&gt;After OAuth flows and webhook subscriptions, a markdown file feels almost too easy. But it works. Users get value. That's what matters.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Documentation-as-Integration Is Genius
&lt;/h3&gt;

&lt;p&gt;If your docs are good enough for an AI to understand, they're probably good enough for humans too. This forces you to write clear, example-driven documentation.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Domain Names Are Just Names
&lt;/h3&gt;

&lt;p&gt;Calwd. OpenClaw. ClawHub. Who cares? The product works. The integration was painless. I'll update my bookmarks as needed.&lt;/p&gt;




&lt;h2&gt;
  
  
  Integration Complexity Comparison
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Platform&lt;/th&gt;
&lt;th&gt;Time&lt;/th&gt;
&lt;th&gt;Files/Code&lt;/th&gt;
&lt;th&gt;Auth&lt;/th&gt;
&lt;th&gt;Process&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Zapier&lt;/td&gt;
&lt;td&gt;1 week&lt;/td&gt;
&lt;td&gt;OAuth server + REST Hooks&lt;/td&gt;
&lt;td&gt;OAuth 2.0 + PKCE&lt;/td&gt;
&lt;td&gt;Review queue&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pipedream&lt;/td&gt;
&lt;td&gt;4 days&lt;/td&gt;
&lt;td&gt;OAuth + Components&lt;/td&gt;
&lt;td&gt;OAuth 2.0 + PKCE&lt;/td&gt;
&lt;td&gt;Email + QA&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ClawHub&lt;/td&gt;
&lt;td&gt;30 min&lt;/td&gt;
&lt;td&gt;2 markdown files&lt;/td&gt;
&lt;td&gt;API Key env var&lt;/td&gt;
&lt;td&gt;Push and done&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  The Future-Proofing Question
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;"What if they change the domain again?"&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Honestly? I'll update the bookmark. The integration itself won't break—it's just markdown files in a repo.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;"What if they rename the whole product?"&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Then I'll have another blog post to write. Content calendar wins either way.&lt;/p&gt;




&lt;h2&gt;
  
  
  Try It Yourself
&lt;/h2&gt;

&lt;p&gt;If you have a REST API with decent documentation, you can probably integrate with ClawHub in an afternoon:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Create a &lt;code&gt;SKILL.md&lt;/code&gt; with frontmatter&lt;/li&gt;
&lt;li&gt;Document your endpoints with curl examples&lt;/li&gt;
&lt;li&gt;Specify required env vars in metadata&lt;/li&gt;
&lt;li&gt;Push to their repo&lt;/li&gt;
&lt;li&gt;Done&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;No OAuth implementation. No webhook infrastructure. No SDK maintenance.&lt;/p&gt;

&lt;p&gt;Sometimes the best integrations are the ones that don't feel like integrations at all.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;What's the easiest integration you've ever done?&lt;/strong&gt; And have you noticed any other products with domain name identity issues?&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. Now available on &lt;a href="https://www.clawhub.ai/anandrathnas/jo4" rel="noopener noreferrer"&gt;ClawHub&lt;/a&gt;... whatever they call it next week.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>integration</category>
      <category>api</category>
      <category>buildinpublic</category>
    </item>
    <item>
      <title>Getting Your App on Pipedream: No Dashboard, Just Humans (And That's Actually Great)</title>
      <dc:creator>Anand Rathnas</dc:creator>
      <pubDate>Wed, 11 Mar 2026 01:34:43 +0000</pubDate>
      <link>https://forem.com/anand_rathnas_d5b608cc3de/getting-your-app-on-pipedream-no-dashboard-just-humans-and-thats-actually-great-3lf5</link>
      <guid>https://forem.com/anand_rathnas_d5b608cc3de/getting-your-app-on-pipedream-no-dashboard-just-humans-and-thats-actually-great-3lf5</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;This article was originally published on &lt;a href="https://jo4.io/blog/pipedream-integration-journey/" rel="noopener noreferrer"&gt;Jo4 Blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;After getting our &lt;a href="https://dev.to/blog/zapier-oauth-spring-boot/"&gt;Zapier OAuth integration working&lt;/a&gt;, we figured Pipedream would be similar. Build the OAuth endpoints, submit the app, wait for approval.&lt;/p&gt;

&lt;p&gt;We were half right.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Plot Twist: No Developer Dashboard
&lt;/h2&gt;

&lt;p&gt;Zapier has a developer platform where you:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Create an app&lt;/li&gt;
&lt;li&gt;Configure OAuth settings&lt;/li&gt;
&lt;li&gt;Upload your client ID/secret&lt;/li&gt;
&lt;li&gt;Submit for review&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Pipedream? None of that.&lt;/p&gt;

&lt;p&gt;There's no developer dashboard. No self-service portal. No "Create New App" button.&lt;/p&gt;

&lt;p&gt;Instead, you open a &lt;a href="https://github.com/PipedreamHQ/pipedream/issues/19728" rel="noopener noreferrer"&gt;GitHub issue&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;"Wait, what?"&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The Process (It's Actually Fast)
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Step 1: Open a GitHub Issue
&lt;/h3&gt;

&lt;p&gt;I created &lt;a href="https://github.com/PipedreamHQ/pipedream/issues/19728" rel="noopener noreferrer"&gt;issue #19728&lt;/a&gt; with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;App name and description&lt;/li&gt;
&lt;li&gt;Link to API documentation&lt;/li&gt;
&lt;li&gt;OAuth endpoints&lt;/li&gt;
&lt;li&gt;Triggers and actions I wanted to build&lt;/li&gt;
&lt;li&gt;Note that I had component code ready
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gs"&gt;**OAuth 2.0 Details:**&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; Authorization URL: https://jo4-api.jo4.io/oauth/authorize
&lt;span class="p"&gt;-&lt;/span&gt; Token URL: https://jo4-api.jo4.io/oauth/token
&lt;span class="p"&gt;-&lt;/span&gt; PKCE Support: Yes (required, S256)
&lt;span class="p"&gt;-&lt;/span&gt; Scopes: read, write

I have the complete component code ready and can submit PR
once OAuth App ID is provided.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 2: Human Responds (Same Day!)
&lt;/h3&gt;

&lt;p&gt;Within hours, someone from the Pipedream integrations team replied asking how they could get OAuth 2.0 credentials to start integrating.&lt;/p&gt;

&lt;p&gt;No ticket queue. No "we'll get back to you in 3-5 business days." A real person, asking a real question.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: Exchange Credentials via Email
&lt;/h3&gt;

&lt;p&gt;Here's where it gets interesting. They asked me to email the OAuth client credentials directly to a team member.&lt;/p&gt;

&lt;p&gt;No secure portal. No encrypted upload form. Just... email.&lt;/p&gt;

&lt;p&gt;Is this concerning? Maybe. But here's the thing: these credentials are specific to Pipedream's redirect URI. They can't be used anywhere else. And the speed of a direct email beats waiting for a ticket system.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tip I shared with them:&lt;/strong&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"It would be nice if you had a workflow where admins can upload credentials through a secure form that goes through validations before you review/approve. Seen this in other places—thought I'd share."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;They're probably working on it. But honestly? The current process worked fine.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4: App Submitted for QA
&lt;/h3&gt;

&lt;p&gt;Four days after opening the issue, they confirmed the Jo4 app was submitted for QA as an OAuth 2.0 app.&lt;/p&gt;

&lt;p&gt;That's it. From GitHub issue to QA queue in under a week.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Wait (Current Status)
&lt;/h2&gt;

&lt;p&gt;As of writing, the app is "awaiting QA." When I tried to access the app link, I got a 404:&lt;/p&gt;

&lt;p&gt;I asked if the 404 was expected. It was — the app isn't released until it clears QA.&lt;/p&gt;

&lt;p&gt;Fair enough. The QA process takes time. But the human interaction throughout has been stellar.&lt;/p&gt;




&lt;h2&gt;
  
  
  What We Reused from Zapier
&lt;/h2&gt;

&lt;p&gt;The beautiful part: &lt;strong&gt;we didn't write new OAuth code&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Our Zapier integration required:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;OAuth 2.0 Authorization Code flow&lt;/li&gt;
&lt;li&gt;Mandatory PKCE (S256)&lt;/li&gt;
&lt;li&gt;Token refresh support&lt;/li&gt;
&lt;li&gt;Proper error responses&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Pipedream needs... exactly the same thing.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Same endpoints:
  /oauth/authorize
  /oauth/token
  /oauth/userinfo

Same PKCE requirement:
  code_challenge_method: S256

Same token format:
  { access_token, refresh_token, expires_in, scope }
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The only difference was creating a new OAuth client with Pipedream's redirect URI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://api.pipedream.com/connect/oauth/oa_XXXXX/callback
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Everything else? Already done.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Triggers and Actions
&lt;/h2&gt;

&lt;p&gt;What we're shipping:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;Name&lt;/th&gt;
&lt;th&gt;Description&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Trigger&lt;/td&gt;
&lt;td&gt;New URL Created&lt;/td&gt;
&lt;td&gt;Webhook fires when user creates a short URL&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Trigger&lt;/td&gt;
&lt;td&gt;New Referrer Domain&lt;/td&gt;
&lt;td&gt;Webhook fires when link gets traffic from new source&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Action&lt;/td&gt;
&lt;td&gt;Create Short URL&lt;/td&gt;
&lt;td&gt;Create with optional custom slug&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Action&lt;/td&gt;
&lt;td&gt;Get URL Details&lt;/td&gt;
&lt;td&gt;Retrieve URL by slug&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Action&lt;/td&gt;
&lt;td&gt;List URLs&lt;/td&gt;
&lt;td&gt;Paginated list of all URLs&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Same as Zapier. Same webhook infrastructure. Same REST Hook pattern (subscribe/unsubscribe).&lt;/p&gt;




&lt;h2&gt;
  
  
  Why Human-First Integration Is Actually Better
&lt;/h2&gt;

&lt;p&gt;I've submitted apps to various platforms. Here's the typical experience:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Fill out 47 form fields&lt;/li&gt;
&lt;li&gt;Upload screenshots in specific dimensions&lt;/li&gt;
&lt;li&gt;Wait 2 weeks for automated rejection&lt;/li&gt;
&lt;li&gt;Resubmit with minor changes&lt;/li&gt;
&lt;li&gt;Wait another 2 weeks&lt;/li&gt;
&lt;li&gt;Repeat&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Pipedream's approach:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Open issue with relevant details&lt;/li&gt;
&lt;li&gt;Human asks clarifying questions&lt;/li&gt;
&lt;li&gt;Email credentials&lt;/li&gt;
&lt;li&gt;App in QA within a week&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;The human touch catches edge cases faster.&lt;/strong&gt; Their team noticed I mentioned API keys require an upgrade on our free tier and asked specifically about OAuth credentials. A form wouldn't have caught that nuance.&lt;/p&gt;




&lt;h2&gt;
  
  
  Timeline Summary
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Date&lt;/th&gt;
&lt;th&gt;Event&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Jan 19&lt;/td&gt;
&lt;td&gt;Opened GitHub issue&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Jan 19&lt;/td&gt;
&lt;td&gt;Pipedream team responds (same day)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Jan 21&lt;/td&gt;
&lt;td&gt;Credentials exchanged via email&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Jan 23&lt;/td&gt;
&lt;td&gt;App submitted for QA&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Jan 30&lt;/td&gt;
&lt;td&gt;Follow-up—still awaiting QA&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Now&lt;/td&gt;
&lt;td&gt;Waiting for release&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Total time from "I want to integrate with Pipedream" to "app in QA": &lt;strong&gt;4 days&lt;/strong&gt;.&lt;/p&gt;




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

&lt;h3&gt;
  
  
  1. OAuth Investment Pays Dividends
&lt;/h3&gt;

&lt;p&gt;The three days we spent getting OAuth right for Zapier? Zero additional work for Pipedream. Same endpoints, same PKCE, same token format.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Human Support &amp;gt; Automated Portals (Sometimes)
&lt;/h3&gt;

&lt;p&gt;For small-to-medium apps, direct human contact is faster. Their team answered questions I didn't know I had.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Document Everything in the Issue
&lt;/h3&gt;

&lt;p&gt;The more context you provide upfront, the fewer back-and-forth messages. I included:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Full OAuth spec&lt;/li&gt;
&lt;li&gt;Available scopes&lt;/li&gt;
&lt;li&gt;Trigger/action descriptions&lt;/li&gt;
&lt;li&gt;Link to Swagger docs&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  4. Be Patient with QA
&lt;/h3&gt;

&lt;p&gt;The integration team is fast. QA takes time. That's okay—they're protecting their users.&lt;/p&gt;




&lt;h2&gt;
  
  
  What's Next
&lt;/h2&gt;

&lt;p&gt;Once the app clears QA, we'll:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Submit the PR with component code&lt;/li&gt;
&lt;li&gt;Test the full flow end-to-end&lt;/li&gt;
&lt;li&gt;Write documentation&lt;/li&gt;
&lt;li&gt;Announce the integration&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;We'll update this post when it's live.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Have you integrated with Pipedream?&lt;/strong&gt; What was your experience with their process?&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. Soon available on Pipedream alongside Zapier.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>api</category>
      <category>oauth</category>
      <category>integration</category>
      <category>buildinpublic</category>
    </item>
  </channel>
</rss>
