<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Anand Rathnas</title>
    <description>The latest articles on DEV Community by Anand Rathnas (@anand_rathnas_d5b608cc3de).</description>
    <link>https://dev.to/anand_rathnas_d5b608cc3de</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3671625%2F8642714b-af2d-4fc1-9097-c08fc07fdab5.png</url>
      <title>DEV Community: Anand Rathnas</title>
      <link>https://dev.to/anand_rathnas_d5b608cc3de</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/anand_rathnas_d5b608cc3de"/>
    <language>en</language>
    <item>
      <title>Why We Killed Hold Windows in Our Affiliate Marketplace</title>
      <dc:creator>Anand Rathnas</dc:creator>
      <pubDate>Sat, 02 May 2026 01:35:40 +0000</pubDate>
      <link>https://dev.to/anand_rathnas_d5b608cc3de/why-we-killed-hold-windows-in-our-affiliate-marketplace-1jdn</link>
      <guid>https://dev.to/anand_rathnas_d5b608cc3de/why-we-killed-hold-windows-in-our-affiliate-marketplace-1jdn</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;This article was originally published on &lt;a href="https://jo4.io/blog/affiliate-marketplace-simplification/" rel="noopener noreferrer"&gt;Jo4 Blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

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




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

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

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

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

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

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

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

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

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

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

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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@ExceptionHandler&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;HttpRequestMethodNotSupportedException&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;class&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;ResponseEntity&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ErrorResponse&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;handleMethodNotAllowed&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
        &lt;span class="nc"&gt;HttpRequestMethodNotSupportedException&lt;/span&gt; &lt;span class="n"&gt;ex&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;ResponseEntity&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;HttpStatus&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;METHOD_NOT_ALLOWED&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ErrorResponse&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Method not allowed"&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Simple. But finding it required understanding that our filter was letting garbage requests through to the controller layer.&lt;/p&gt;

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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

&lt;/div&gt;



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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Order&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;// Runs first — ensures user exists&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Auth0UserSyncFilter&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;OncePerRequestFilter&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="o"&gt;...&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="nd"&gt;@Order&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;// Runs second — can now look up the user&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ImpersonationFilter&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;OncePerRequestFilter&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="o"&gt;...&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Spring Security's filter chain doesn't guarantee ordering by default. If you register filters without explicit ordering, you're at the mercy of component scanning order, which can vary between environments.&lt;/p&gt;

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

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

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

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

&lt;/div&gt;



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

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

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

    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;createNewUser&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;auth0Sub&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;DataIntegrityViolationException&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Another thread created the user between our check and insert.&lt;/span&gt;
        &lt;span class="c1"&gt;// Just fetch the record they created.&lt;/span&gt;
        &lt;span class="nc"&gt;UserEntity&lt;/span&gt; &lt;span class="n"&gt;raced&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;userRepository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;findByAuth0Sub&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;auth0Sub&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raced&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;raced&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// Genuine constraint violation, not a race&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the optimistic concurrency pattern. Instead of acquiring a lock before the check (pessimistic), we let the race happen and recover from the loser's exception. It's cheaper under normal load (no locking overhead) and handles the edge case gracefully.&lt;/p&gt;

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

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

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

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

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




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

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

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

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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

&lt;/div&gt;



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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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




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

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

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

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

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

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

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

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

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

&lt;p&gt;The check logic:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;isUpdateNeeded&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;localPost&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;devtoArticle&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// If local content changed after dev.to publish date, update needed&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;localDate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;localPost&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;updatedAt&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;localPost&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;publishAfter&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;devtoDate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;devtoArticle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;published_at&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;localDate&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;devtoDate&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Looks reasonable. If the local post was updated after it was published on dev.to, push the update.&lt;/p&gt;

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

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

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

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

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

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

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

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

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

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

&lt;p&gt;We needed the sync to know: "Have I already pushed this update?" The answer was to compare against dev.to's &lt;code&gt;edited_at&lt;/code&gt; field (which reflects the last API update), not &lt;code&gt;published_at&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;isUpdateNeeded&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;localPost&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;devtoArticle&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;localDate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;localPost&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;updatedAt&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;localPost&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;publishAfter&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="c1"&gt;// Compare against last edit, not original publish&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;devtoDate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;devtoArticle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;edited_at&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;devtoArticle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;published_at&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;localDate&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;devtoDate&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the timeline works:&lt;/p&gt;

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

&lt;p&gt;The second piece was handling the &lt;code&gt;null&lt;/code&gt; propagation. When there's no update and we sync metadata, the script was using &lt;code&gt;Object.assign()&lt;/code&gt; to merge frontmatter. But &lt;code&gt;Object.assign&lt;/code&gt; skips &lt;code&gt;undefined&lt;/code&gt; values — so when &lt;code&gt;updatedAt&lt;/code&gt; wasn't set, the old value persisted instead of being cleared:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Before: Object.assign ignores undefined, so stale updatedAt persists&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;merged&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;assign&lt;/span&gt;&lt;span class="p"&gt;({},&lt;/span&gt; &lt;span class="nx"&gt;defaults&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;frontmatter&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// After: explicitly handle null/undefined fields&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;merged&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;defaults&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;frontmatter&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;frontmatter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;updatedAt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;delete&lt;/span&gt; &lt;span class="nx"&gt;merged&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;updatedAt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// Don't carry forward stale dates&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

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




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

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

</description>
      <category>javascript</category>
      <category>api</category>
      <category>beginners</category>
      <category>webdev</category>
    </item>
    <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://dev.to/anand_rathnas_d5b608cc3de/why-safari-said-link-not-found-and-chrome-didnt-2dn5</link>
      <guid>https://dev.to/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://dev.to/anand_rathnas_d5b608cc3de/referral-tracking-for-indie-hackers-skip-the-300mo-tools-1nln</link>
      <guid>https://dev.to/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://dev.to/anand_rathnas_d5b608cc3de/20-free-developer-tools-we-built-and-why-we-gave-them-away-1lpd</link>
      <guid>https://dev.to/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://dev.to/anand_rathnas_d5b608cc3de/the-easiest-integration-weve-ever-done-two-markdown-files-and-a-domain-name-identity-crisis-3jkm</link>
      <guid>https://dev.to/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://dev.to/anand_rathnas_d5b608cc3de/getting-your-app-on-pipedream-no-dashboard-just-humans-and-thats-actually-great-3lf5</link>
      <guid>https://dev.to/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>
    <item>
      <title>The 5 Edge Cases That Broke Our Dev.to Auto-Crossposting (And How We Fixed Them)</title>
      <dc:creator>Anand Rathnas</dc:creator>
      <pubDate>Sun, 08 Mar 2026 01:34:38 +0000</pubDate>
      <link>https://dev.to/anand_rathnas_d5b608cc3de/the-5-edge-cases-that-broke-our-devto-auto-crossposting-and-how-we-fixed-them-81p</link>
      <guid>https://dev.to/anand_rathnas_d5b608cc3de/the-5-edge-cases-that-broke-our-devto-auto-crossposting-and-how-we-fixed-them-81p</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;This article was originally published on &lt;a href="https://jo4.io/blog/devto-crosspost-edge-cases/" rel="noopener noreferrer"&gt;Jo4 Blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;In our &lt;a href="https://dev.to/blog/automated-blog-staggering/"&gt;previous post&lt;/a&gt;, we covered the producer-consumer problem for blog scheduling. But we glossed over the crossposting part.&lt;/p&gt;

&lt;p&gt;"Just POST to the dev.to API," we said. "How hard could it be?"&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Narrator: It was hard.&lt;/em&gt;&lt;/p&gt;




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

&lt;p&gt;We have a Node.js script that runs daily via GitHub Actions:&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;// Simplified flow&lt;/span&gt;
&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt; &lt;span class="nx"&gt;Find&lt;/span&gt; &lt;span class="nx"&gt;all&lt;/span&gt; &lt;span class="nx"&gt;markdown&lt;/span&gt; &lt;span class="nx"&gt;posts&lt;/span&gt; &lt;span class="kd"&gt;with&lt;/span&gt; &lt;span class="nx"&gt;publishAfter&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;today&lt;/span&gt;
&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt; &lt;span class="nx"&gt;Check&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nx"&gt;they&lt;/span&gt; &lt;span class="nx"&gt;exist&lt;/span&gt; &lt;span class="nx"&gt;on&lt;/span&gt; &lt;span class="nx"&gt;dev&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;to&lt;/span&gt; &lt;span class="nx"&gt;already&lt;/span&gt;
&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt; &lt;span class="nx"&gt;If&lt;/span&gt; &lt;span class="nx"&gt;not&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;create&lt;/span&gt; &lt;span class="nx"&gt;them&lt;/span&gt;
&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt; &lt;span class="nx"&gt;Send&lt;/span&gt; &lt;span class="nx"&gt;Slack&lt;/span&gt; &lt;span class="nx"&gt;notification&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Sounds straightforward. Here are the edge cases that broke it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Edge Case 1: How Do You Know If a Post Already Exists?
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The Problem
&lt;/h3&gt;

&lt;p&gt;We can't just check our local database—we don't have one. The blog is a static site. So how do we avoid posting duplicates?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Naive approach:&lt;/strong&gt; Keep a &lt;code&gt;.crossposted.json&lt;/code&gt; file locally.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why it fails:&lt;/strong&gt; Someone manually posts to dev.to. Someone deletes the JSON file. Someone runs the script from a different machine. Duplicates everywhere.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Fix: dev.to Is the Source of Truth
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;fetchDevtoArticles&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;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;DEVTO_API_URL&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/me/published?per_page=100`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;api-key&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;apiKey&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;articles&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="c1"&gt;// Store by canonical_url for O(1) lookup&lt;/span&gt;
  &lt;span class="k"&gt;for &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;article&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;articles&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;article&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;canonical_url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;devtoArticles&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;article&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;canonical_url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;article&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;article&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;article&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&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="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;Before creating anything, we fetch ALL our existing dev.to articles. The &lt;code&gt;canonical_url&lt;/code&gt; field is unique—it's the original source URL. If our canonical URL already exists, skip.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bonus:&lt;/strong&gt; dev.to returns a 422 error with "canonical" in the message if you try to create a duplicate. We catch that too:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;422&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;errorText&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;canonical&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;duplicate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;title&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="nx"&gt;title&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;Belt and suspenders.&lt;/p&gt;




&lt;h2&gt;
  
  
  Edge Case 2: The 60-Day Time Bomb
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The Problem
&lt;/h3&gt;

&lt;p&gt;Our script only looks back 60 days on dev.to (performance optimization—we don't need articles from 2 years ago). But what happens to a post with &lt;code&gt;publishAfter: "2025-01-01"&lt;/code&gt; that we never crossposted?&lt;/p&gt;

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

&lt;ol&gt;
&lt;li&gt;January: Write a post, set &lt;code&gt;publishAfter: "2025-01-15"&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;January 15: Script runs, posts to dev.to ✅&lt;/li&gt;
&lt;li&gt;March 20: 65 days later, the script's dev.to lookback window no longer includes this article&lt;/li&gt;
&lt;li&gt;Some bug causes the post to be re-processed&lt;/li&gt;
&lt;li&gt;Duplicate post on dev.to ❌&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  The Fix: Auto-Update Stale Dates
&lt;/h3&gt;

&lt;p&gt;If a post has &lt;code&gt;publishAfter&lt;/code&gt; older than 60 days, we automatically update it to today:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isOlderThanDevtoMaxDays&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="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`[publish-after] "&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;" has old publishAfter (&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="s2"&gt;), updating to &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;today&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;shouldProcess&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;needsDateUpdate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;newDate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;today&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 here's the edge case's edge case—we need to &lt;strong&gt;commit this change to git&lt;/strong&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="c1"&gt;// After processing all posts&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;filesToCommit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&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="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dryRun&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;commitChanges&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;filesToCommit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;chore: auto-update old publishAfter dates to today&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;commitChanges&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;files&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;for &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;file&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;files&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;execSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`git add "&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;stdio&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pipe&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="nf"&gt;execSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`git commit -m "&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;stdio&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pipe&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why commit? Because if the script runs again before you pull, it would try to update the same posts again. The commit ensures the updated dates persist.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Slack notification:&lt;/strong&gt; "⚠️ publishAfter updated to today for "My Post" - please pull latest"&lt;/p&gt;




&lt;h2&gt;
  
  
  Edge Case 3: Accidental Content Overwrites
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The Problem
&lt;/h3&gt;

&lt;p&gt;You crosspost a blog post. A week later, you fix a typo locally. The script runs. Does it update dev.to?&lt;/p&gt;

&lt;p&gt;If yes: What if you intentionally made dev.to-specific edits? Gone.&lt;br&gt;
If no: How do you push updates when you actually want them?&lt;/p&gt;
&lt;h3&gt;
  
  
  The Fix: Explicit Update Intent
&lt;/h3&gt;

&lt;p&gt;Updates only happen when you set &lt;code&gt;updatedAt&lt;/code&gt; in frontmatter:&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;title&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;My&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Post"&lt;/span&gt;
&lt;span class="na"&gt;publishAfter&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2026-02-15"&lt;/span&gt;
&lt;span class="na"&gt;updatedAt&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2026-02-20"&lt;/span&gt;  &lt;span class="c1"&gt;# &amp;lt;-- This triggers the update&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;existingArticle&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;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="c1"&gt;// Explicit intent to update - proceed&lt;/span&gt;
    &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;updateArticle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;existingArticle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;frontmatter&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// No updatedAt = skip (don't accidentally overwrite)&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`[exists] &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="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No &lt;code&gt;updatedAt&lt;/code&gt;? No update. Simple opt-in.&lt;/p&gt;




&lt;h2&gt;
  
  
  Edge Case 4: dev.to Rate Limiting
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The Problem
&lt;/h3&gt;

&lt;p&gt;dev.to allows 10 requests per 30 seconds. Try to crosspost 15 articles at once and you'll hit 429s.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Fix: Delay + Retry with Backoff
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// After each successful post&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;resolve&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3500&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt; &lt;span class="c1"&gt;// 3.5s delay&lt;/span&gt;

&lt;span class="c1"&gt;// On rate limit (429)&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;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;429&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;retryCount&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;CONFIG&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;maxRetries&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;retryAfter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parseInt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="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;retry-after&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`[rate-limited] Waiting &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;retryAfter&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;s before retry...`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;retryAfter&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&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;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createArticle&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="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;retryCount&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;3.5 seconds between posts keeps us under the limit. If we do hit a 429, respect the &lt;code&gt;retry-after&lt;/code&gt; header and try again.&lt;/p&gt;




&lt;h2&gt;
  
  
  Edge Case 5: Partial Failures
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The Problem
&lt;/h3&gt;

&lt;p&gt;You have 5 posts to crosspost. Posts 1 and 2 succeed. Post 3 fails (network error). What happens to posts 4 and 5?&lt;/p&gt;

&lt;h3&gt;
  
  
  The Fix: Continue on Failure + Report All
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;for &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;file&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;files&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createArticle&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="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;created&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;title&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="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&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;notifySlack&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Crossposted to dev.to: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; → &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`[failed] &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="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;failed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;title&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="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&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;notifySlack&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Failed to crosspost "&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;": &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="c1"&gt;// Continue to next post - don't abort&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Exit with error code if any failures&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;failed&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every post gets attempted. Every result gets recorded. Every failure gets Slacked. The exit code tells CI whether to retry.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Complete Slack Notification System
&lt;/h2&gt;

&lt;p&gt;Here's every scenario that triggers a notification:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Event&lt;/th&gt;
&lt;th&gt;Emoji&lt;/th&gt;
&lt;th&gt;Message&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;New crosspost&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Crossposted to dev.to: {title} → {url}&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Updated post&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Updated on dev.to: {title} → {url}&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Date auto-fixed&lt;/td&gt;
&lt;td&gt;⚠️&lt;/td&gt;
&lt;td&gt;&lt;code&gt;publishAfter updated to today for "{title}" - please pull latest&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Duplicate found&lt;/td&gt;
&lt;td&gt;⚠️&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Duplicate on dev.to for "{title}" - already exists, skipping&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Failure&lt;/td&gt;
&lt;td&gt;⚠️&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Failed to crosspost "{title}": {error}&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Slack Integration
&lt;/h3&gt;

&lt;p&gt;One environment variable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;SLACK_JO4_BLOGS_WH&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"https://hooks.slack.com/services/T00/B00/XXX"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. The script handles the rest:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;notifySlack&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;isWarning&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="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;CONFIG&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;slackWebhook&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`[slack-skip] &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&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;emoji&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;isWarning&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="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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;emoji&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; *Jo4 Blog*: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="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="nx"&gt;CONFIG&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;slackWebhook&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No Slack webhook configured? It logs to console instead. Graceful degradation.&lt;/p&gt;




&lt;h2&gt;
  
  
  Configuration Knobs
&lt;/h2&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;CONFIG&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;localMaxDays&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;parseInt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;LOCAL_MAX_DAYS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;devtoMaxDays&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;parseInt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;DEVTO_MAX_DAYS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;maxRetries&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="na"&gt;slackWebhook&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;SLACK_JO4_BLOGS_WH&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;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Variable&lt;/th&gt;
&lt;th&gt;Default&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;&lt;code&gt;LOCAL_MAX_DAYS&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;30&lt;/td&gt;
&lt;td&gt;Only process posts from last X days (unless &lt;code&gt;publishAfter&lt;/code&gt; is set)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;DEVTO_MAX_DAYS&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;60&lt;/td&gt;
&lt;td&gt;How far back to check dev.to for existing articles&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;SLACK_JO4_BLOGS_WH&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;td&gt;Slack webhook URL&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;DEVTO_API_KEY&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;td&gt;Your dev.to API key (required)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




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



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1. Fetch all our articles from dev.to (last 60 days)
   → Store by canonical_url for O(1) lookup

2. For each local markdown file:
   a. Skip if draft or crosspost: false
   b. Skip if publishAfter &amp;gt; today (scheduled for future)
   c. If publishAfter is older than 60 days:
      → Update publishAfter to today
      → Queue file for git commit
      → Slack warning
   d. Check if canonical_url exists on dev.to:
      → If yes AND updatedAt is set: UPDATE
      → If yes AND no updatedAt: SKIP
      → If no: CREATE
   e. Wait 3.5 seconds (rate limiting)
   f. On 429: retry with backoff

3. Commit any auto-updated files to git

4. Report summary + exit code
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






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

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Use the destination as source of truth&lt;/strong&gt; - Don't maintain local state for external systems&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Explicit &amp;gt; implicit&lt;/strong&gt; - Updates require &lt;code&gt;updatedAt&lt;/code&gt; flag, no accidental overwrites&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Edge cases have edge cases&lt;/strong&gt; - Old dates need auto-fixing, auto-fixes need git commits&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fail gracefully&lt;/strong&gt; - Continue on error, report everything, use exit codes for CI&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Integrate Slack early&lt;/strong&gt; - One webhook URL, five notification scenarios, zero config UI&lt;/li&gt;
&lt;/ol&gt;




&lt;p&gt;&lt;strong&gt;What edge cases have you hit with crossposting?&lt;/strong&gt; Every automation has that one bug that only shows up at 3 AM.&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. Our blog auto-crossposts to dev.to using exactly this system.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>devto</category>
      <category>automation</category>
      <category>node</category>
      <category>devops</category>
    </item>
    <item>
      <title>5 Hard Lessons from Implementing Zapier OAuth in Spring Boot</title>
      <dc:creator>Anand Rathnas</dc:creator>
      <pubDate>Thu, 05 Mar 2026 01:35:06 +0000</pubDate>
      <link>https://dev.to/anand_rathnas_d5b608cc3de/5-hard-lessons-from-implementing-zapier-oauth-in-spring-boot-hfo</link>
      <guid>https://dev.to/anand_rathnas_d5b608cc3de/5-hard-lessons-from-implementing-zapier-oauth-in-spring-boot-hfo</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;This article was originally published on &lt;a href="https://jo4.io/blog/zapier-oauth-spring-boot/" rel="noopener noreferrer"&gt;Jo4 Blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Three days. That's how long it took to get a "simple" OAuth integration working with Zapier. The docs made it look easy. Reality had other plans.&lt;/p&gt;

&lt;p&gt;Here's what I learned building OAuth 2.0 with PKCE for &lt;a href="https://jo4.io" rel="noopener noreferrer"&gt;jo4.io&lt;/a&gt; - a URL shortener that now integrates with Zapier, Make.com, and n8n.&lt;/p&gt;




&lt;h2&gt;
  
  
  Lesson 1: Your OAuth Tokens Fight Your JWT Tokens
&lt;/h2&gt;

&lt;p&gt;Spring Security's filter chain doesn't know the difference between your OAuth access tokens and Auth0's JWTs. Both arrive as &lt;code&gt;Authorization: Bearer &amp;lt;token&amp;gt;&lt;/code&gt;. Chaos ensues.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Problem
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;OAuth token authentication successful: userId=2, clientId=zapier
...
BearerTokenAuthenticationFilter: Failed to authenticate: Invalid JWT
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Wait, what? We just authenticated successfully. Why is it failing?&lt;/p&gt;

&lt;p&gt;The JWT filter runs after our OAuth filter and tries to re-validate the same token as a JWT. It fails (obviously - it's not a JWT), and returns 401.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Fix
&lt;/h3&gt;

&lt;p&gt;Strip the Authorization header after successful OAuth authentication:&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;@Override&lt;/span&gt;
&lt;span class="kd"&gt;protected&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;doFilterInternal&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;HttpServletRequest&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
        &lt;span class="nc"&gt;HttpServletResponse&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;FilterChain&lt;/span&gt; &lt;span class="n"&gt;filterChain&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;extractBearerToken&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

    &lt;span class="c1"&gt;// Skip if this looks like a JWT (has 3 base64 parts with "alg" header)&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;isJwtToken&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;filterChain&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;doFilter&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;OAuthService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ValidatedToken&lt;/span&gt; &lt;span class="n"&gt;validated&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;oauthService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;validateAccessToken&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="nc"&gt;SecurityContextHolder&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getContext&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;setAuthentication&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
            &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;OAuthTokenAuthentication&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;validated&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getUser&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;validated&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getToken&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt;
        &lt;span class="o"&gt;);&lt;/span&gt;

        &lt;span class="c1"&gt;// CRITICAL: Strip header so JWT filter doesn't try to re-validate&lt;/span&gt;
        &lt;span class="n"&gt;filterChain&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;doFilter&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;AuthorizationStrippingRequestWrapper&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;),&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

    &lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Exception&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;sendErrorResponse&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;HttpStatus&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;UNAUTHORIZED&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"invalid_token"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
            &lt;span class="s"&gt;"The access token is invalid, expired, or revoked"&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;&lt;strong&gt;Key insight:&lt;/strong&gt; Filter chain order matters. Your OAuth filter runs, authenticates, then passes to the next filter. If that next filter sees a Bearer token, it'll try to process it again.&lt;/p&gt;




&lt;h2&gt;
  
  
  Lesson 2: Concurrent Token Refresh = Race Condition Hell
&lt;/h2&gt;

&lt;p&gt;Zapier's backend makes parallel requests. When a token expires, multiple workers hit your refresh endpoint simultaneously.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Problem
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Thread-1: Refresh token, create new access token A
Thread-1: Revoke old access token (standard practice, right?)
Thread-2: Refresh token, create new access token B
Thread-2: Revoke old access token... wait, that's token A that Thread-1 just created
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;User sees: "This account is expired. Please reconnect it."&lt;/p&gt;

&lt;h3&gt;
  
  
  The (Counter-Intuitive) Fix
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Don't revoke access tokens on refresh.&lt;/strong&gt; Let them expire naturally.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;TokenResponse&lt;/span&gt; &lt;span class="nf"&gt;refreshAccessToken&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;refreshToken&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;OAuthRefreshTokenEntity&lt;/span&gt; &lt;span class="n"&gt;refresh&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;validateRefreshToken&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;refreshToken&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

    &lt;span class="c1"&gt;// Create new access token&lt;/span&gt;
    &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;newAccessToken&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;generateAccessToken&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
    &lt;span class="n"&gt;saveAccessToken&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;newAccessToken&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;refresh&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getUserId&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;refresh&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getClientId&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;

    &lt;span class="c1"&gt;// NOTE: We intentionally do NOT revoke old access tokens.&lt;/span&gt;
    &lt;span class="c1"&gt;// This prevents race conditions when multiple concurrent refresh&lt;/span&gt;
    &lt;span class="c1"&gt;// requests would revoke each other's newly created tokens.&lt;/span&gt;
    &lt;span class="c1"&gt;// Access tokens have short lifetimes (1 hour) and expire naturally.&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;TokenResponse&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;newAccessToken&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;refresh&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getToken&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="mi"&gt;3600&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 standard OAuth 2.0 practice. Access tokens are short-lived by design. Revoking them on refresh is "clever" but breaks under concurrency.&lt;/p&gt;




&lt;h2&gt;
  
  
  Lesson 3: PKCE Validation Must Be Explicit
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;@NotBlank&lt;/code&gt; on your DTO doesn't always trigger. Bean validation has quirks.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Problem
&lt;/h3&gt;

&lt;p&gt;Our request DTO:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="n"&gt;record&lt;/span&gt; &lt;span class="nf"&gt;AuthorizeConsentRequest&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
    &lt;span class="nd"&gt;@NotBlank&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;clientId&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
    &lt;span class="nd"&gt;@NotBlank&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;redirectUri&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;scope&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
    &lt;span class="nd"&gt;@NotBlank&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"PKCE code_challenge is required"&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;codeChallenge&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;codeChallengeMethod&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;But requests without &lt;code&gt;codeChallenge&lt;/code&gt; were getting through. The security bug: attackers could bypass PKCE entirely.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Fix
&lt;/h3&gt;

&lt;p&gt;Defense in depth - validate explicitly:&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;@PostMapping&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/authorize"&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;?&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;authorizeConsent&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nd"&gt;@Valid&lt;/span&gt; &lt;span class="nd"&gt;@RequestBody&lt;/span&gt; &lt;span class="nc"&gt;AuthorizeConsentRequest&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="c1"&gt;// SECURITY: Explicit PKCE validation (defense-in-depth)&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;request&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;codeChallenge&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;codeChallenge&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;isBlank&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="nf"&gt;errorResponse&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"invalid_request"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
            &lt;span class="s"&gt;"PKCE code_challenge is required per OAuth 2.1 security requirements"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// ... rest of authorization logic&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Rule:&lt;/strong&gt; Security validations should be explicit in code, not just annotations.&lt;/p&gt;




&lt;h2&gt;
  
  
  Lesson 4: Zapier Has Specific Token Response Requirements
&lt;/h2&gt;

&lt;p&gt;The OAuth spec is flexible. Zapier is not.&lt;/p&gt;

&lt;h3&gt;
  
  
  Requirements I Discovered the Hard Way
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Requirement&lt;/th&gt;
&lt;th&gt;What I Did Wrong&lt;/th&gt;
&lt;th&gt;What Zapier Needs&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Token location&lt;/td&gt;
&lt;td&gt;Nested in &lt;code&gt;data&lt;/code&gt; object&lt;/td&gt;
&lt;td&gt;Top-level JSON keys&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Content-Type&lt;/td&gt;
&lt;td&gt;&lt;code&gt;application/json; charset=UTF-8&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;application/json&lt;/code&gt; works, but verify&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;scope&lt;/code&gt; field&lt;/td&gt;
&lt;td&gt;Omitted (optional per spec)&lt;/td&gt;
&lt;td&gt;Must be present&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;token_type&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Returned &lt;code&gt;Bearer&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Must be exactly &lt;code&gt;Bearer&lt;/code&gt; (case-sensitive)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Correct response format:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"access_token"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"jo4_at_abc123..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"token_type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Bearer"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"expires_in"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3600&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"refresh_token"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"jo4_rt_xyz789..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"scope"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"read write"&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;h2&gt;
  
  
  Lesson 5: E2E Tests Are Worth the Investment
&lt;/h2&gt;

&lt;p&gt;After two days of "try it and see" debugging, I wrote comprehensive E2E tests. Found three bugs in 10 minutes.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Concurrent Refresh Test That Found the Race Condition
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Test&lt;/span&gt;
&lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;step9_concurrentRefreshDoesNotCauseRaceCondition&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="kd"&gt;throws&lt;/span&gt; &lt;span class="nc"&gt;Exception&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;concurrentRequests&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="nc"&gt;ExecutorService&lt;/span&gt; &lt;span class="n"&gt;executor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Executors&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;newFixedThreadPool&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;concurrentRequests&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Future&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&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;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;futures&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ArrayList&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&amp;gt;();&lt;/span&gt;

    &lt;span class="c1"&gt;// Fire 5 refresh requests simultaneously&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;concurrentRequests&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;futures&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;add&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;executor&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;submit&lt;/span&gt;&lt;span class="o"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="nc"&gt;MultiValueMap&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&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;LinkedMultiValueMap&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&amp;gt;();&lt;/span&gt;
            &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;add&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"grant_type"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"refresh_token"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
            &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;add&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"refresh_token"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;refreshToken&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
            &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;add&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"client_id"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;testClientId&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
            &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;add&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"client_secret"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;testClientSecret&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;restTemplate&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;postForEntity&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/oauth/token"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
                &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;HttpEntity&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&amp;gt;(&lt;/span&gt;&lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;formHeaders&lt;/span&gt;&lt;span class="o"&gt;),&lt;/span&gt; &lt;span class="nc"&gt;String&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="o"&gt;}));&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// ALL must succeed&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Future&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&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;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;future&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;futures&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;ResponseEntity&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;future&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;get&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;TimeUnit&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;SECONDS&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;assertThat&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getStatusCode&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;is2xxSuccessful&lt;/span&gt;&lt;span class="o"&gt;()).&lt;/span&gt;&lt;span class="na"&gt;isTrue&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;This test would have caught the race condition immediately.&lt;/p&gt;




&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Strip Authorization header&lt;/strong&gt; after OAuth auth succeeds - prevents JWT filter conflicts&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't revoke tokens on refresh&lt;/strong&gt; - causes race conditions under concurrent requests&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Validate PKCE explicitly&lt;/strong&gt; - don't trust annotations alone for security&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Match Zapier's exact format&lt;/strong&gt; - tokens at top level, &lt;code&gt;scope&lt;/code&gt; included&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write E2E tests first&lt;/strong&gt; - 10 minutes of tests beats 2 days of production debugging&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;OAuth looks simple in diagrams. The edge cases will humble you.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;What OAuth integration horror stories do you have?&lt;/strong&gt; I'd love to hear I'm not alone.&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 Zapier, Make.com, and n8n integrations.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>zapier</category>
      <category>springboot</category>
      <category>java</category>
      <category>webdev</category>
    </item>
    <item>
      <title>How We Solved the Producer-Consumer Problem (For Blog Posts)</title>
      <dc:creator>Anand Rathnas</dc:creator>
      <pubDate>Mon, 02 Mar 2026 01:35:15 +0000</pubDate>
      <link>https://dev.to/anand_rathnas_d5b608cc3de/how-we-solved-the-producer-consumer-problem-for-blog-posts-5dl8</link>
      <guid>https://dev.to/anand_rathnas_d5b608cc3de/how-we-solved-the-producer-consumer-problem-for-blog-posts-5dl8</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;This article was originally published on &lt;a href="https://jo4.io/blog/automated-blog-staggering/" rel="noopener noreferrer"&gt;Jo4 Blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;You know the producer-consumer problem from computer science, right? One process creates stuff, another process consumes it, and if they're not synchronized, chaos ensues.&lt;/p&gt;

&lt;p&gt;Turns out, content creation has the exact same problem.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Problem: Bursty Writers, Consistent Readers
&lt;/h2&gt;

&lt;p&gt;Here's how blog writing actually works:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Producer (Me):&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Monday: Write 4 posts in a caffeine-fueled frenzy
Tuesday-Sunday: *crickets*
Next Monday: Panic, write 3 more posts
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Consumer (Readers &amp;amp; SEO):&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Expectation: Consistent 3x/week publishing
Reality: 4 posts on Monday, nothing for 2 weeks, then 7 posts on a random Tuesday
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Google hates inconsistency. Readers forget you exist. Your newsletter goes from "weekly insights" to "occasional ramblings from someone who may or may not still be alive."&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Classic producer-consumer mismatch.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The Traditional Solutions (That Don't Work)
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. "Just Be Consistent"
&lt;/h3&gt;

&lt;p&gt;Ah yes, the "just don't have ADHD" approach. Revolutionary.&lt;/p&gt;

&lt;p&gt;The problem isn't motivation—it's that creative energy comes in bursts. Some days you write 3,000 words before breakfast. Other days, you stare at a blank screen and contemplate becoming a goat farmer.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Use a CMS with Scheduling
&lt;/h3&gt;

&lt;p&gt;WordPress, Ghost, and others have scheduling. But they require:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Manual date picking for each post&lt;/li&gt;
&lt;li&gt;Remembering what you've already scheduled&lt;/li&gt;
&lt;li&gt;A calendar that doesn't sync with your actual life&lt;/li&gt;
&lt;li&gt;Discipline (see point #1)&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  3. Hire a Content Manager
&lt;/h3&gt;

&lt;p&gt;&lt;em&gt;laughs in solo developer budget&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Our Solution: Stock-Based Scheduling
&lt;/h2&gt;

&lt;p&gt;We built a simple system with one core idea: &lt;strong&gt;treat blog posts like inventory&lt;/strong&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Stock File
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"_articleStockWouldLastUpto"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-02-26"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"getting-started-with-jo4"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-01-31"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"api-first-url-shortener"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-02-03"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"claude-code-hooks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-02-05"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"elasticache-vs-memorydb"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-02-07"&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;Each post gets a &lt;code&gt;publishAfter&lt;/code&gt; date in its frontmatter. The &lt;code&gt;stock.json&lt;/code&gt; file tracks everything:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;What's scheduled&lt;/li&gt;
&lt;li&gt;When each post goes live&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;When you'll run out of content&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last field—&lt;code&gt;_articleStockWouldLastUpto&lt;/code&gt;—is the magic. It's your inventory level.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Workflow
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;When I write (producer):&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Create post with content&lt;/li&gt;
&lt;li&gt;Set &lt;code&gt;publishAfter&lt;/code&gt; to the next available slot&lt;/li&gt;
&lt;li&gt;Update &lt;code&gt;stock.json&lt;/code&gt; with the new entry&lt;/li&gt;
&lt;li&gt;Update &lt;code&gt;_articleStockWouldLastUpto&lt;/code&gt; if this is the latest&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;When GitHub Actions runs (consumer):&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Check which posts have &lt;code&gt;publishAfter &amp;lt;= today&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Build and publish those posts&lt;/li&gt;
&lt;li&gt;Crosspost to dev.to&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Check stock levels&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  The Low Stock Warning
&lt;/h3&gt;

&lt;p&gt;Here's the clever bit. Every day at 1 AM UTC, our CI pipeline checks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;STOCK_DATE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'._articleStockWouldLastUpto'&lt;/span&gt; stock.json&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;DAYS_LEFT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$((&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;STOCK_EPOCH &lt;span class="o"&gt;-&lt;/span&gt; TODAY_EPOCH&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="m"&gt;86400&lt;/span&gt; &lt;span class="k"&gt;))&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DAYS_LEFT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-lt&lt;/span&gt; 3 &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"LOW STOCK! Only &lt;/span&gt;&lt;span class="nv"&gt;$DAYS_LEFT&lt;/span&gt;&lt;span class="s2"&gt; days remaining!"&lt;/span&gt;
  &lt;span class="c"&gt;# Send Slack notification&lt;/span&gt;
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If I have less than 3 days of content queued up, I get a Slack message:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;⚠️ LOW ARTICLE STOCK! Only 2 days of scheduled articles remaining. Time to write more blog posts!&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It's like a grocery store inventory system, but for words.&lt;/p&gt;




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

&lt;h3&gt;
  
  
  1. Decouples Writing from Publishing
&lt;/h3&gt;

&lt;p&gt;I can write 5 posts on a Sunday afternoon and not worry about when they'll go live. The system handles the "when," I handle the "what."&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Visualizes the Buffer
&lt;/h3&gt;

&lt;p&gt;Seeing &lt;code&gt;_articleStockWouldLastUpto: "2026-02-26"&lt;/code&gt; is motivating. It's concrete. "I have 25 days of content" is way more actionable than "I should probably write more."&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Prevents Feast-or-Famine
&lt;/h3&gt;

&lt;p&gt;The warning system catches problems before they become crises. No more "oh no, I haven't posted in 3 weeks" panic.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Works with Bursts
&lt;/h3&gt;

&lt;p&gt;Write 10 posts in a weekend? Great, you just bought yourself a month. The system doesn't care &lt;em&gt;when&lt;/em&gt; you write, just that you eventually do.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Implementation
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Post Frontmatter
&lt;/h3&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;title&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;My&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Post&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Title"&lt;/span&gt;
&lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SEO-friendly&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;description"&lt;/span&gt;
&lt;span class="na"&gt;blogPath&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;my-post-slug&lt;/span&gt;
&lt;span class="na"&gt;publishAfter&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2026-03-02"&lt;/span&gt;
&lt;span class="na"&gt;author&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Jo4 Team&lt;/span&gt;
&lt;span class="na"&gt;tags&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;tag1&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;tag2&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;tag3&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;tag4&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;publishAfter&lt;/code&gt; field is the key. Posts with future dates exist in the repo but aren't built into the public site.&lt;/p&gt;

&lt;h3&gt;
  
  
  Build Logic
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// In 11ty config&lt;/span&gt;
&lt;span class="nx"&gt;eleventyConfig&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addCollection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;posts&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;collection&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;today&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;Date&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;T&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;collection&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getFilteredByGlob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;posts/**/index.md&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="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;post&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;publishAfter&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;today&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;publishAfter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;localeCompare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&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="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Future posts are filtered out at build time. Simple.&lt;/p&gt;

&lt;h3&gt;
  
  
  GitHub Actions Cron
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;schedule&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;cron&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;1&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*"&lt;/span&gt;  &lt;span class="c1"&gt;# Daily at 1 AM UTC&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The pipeline runs daily, checks for newly-eligible posts, builds, deploys, and crossposts.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Meta Irony
&lt;/h2&gt;

&lt;p&gt;Yes, I'm writing a blog post about automating blog posts.&lt;/p&gt;

&lt;p&gt;Yes, this post will be scheduled using the system I'm describing.&lt;/p&gt;

&lt;p&gt;Yes, I wrote this during a burst of productivity and it's scheduled 3 weeks out.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The system works.&lt;/em&gt;&lt;/p&gt;




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

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Treat creative work like inventory&lt;/strong&gt; - Buffers smooth out inconsistency&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Automate the boring parts&lt;/strong&gt; - Date calculation, crossposting, warnings&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make the invisible visible&lt;/strong&gt; - &lt;code&gt;_articleStockWouldLastUpto&lt;/code&gt; turns abstract anxiety into concrete numbers&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Design for how you actually work&lt;/strong&gt; - Bursts are fine if the system handles them&lt;/li&gt;
&lt;/ol&gt;




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

&lt;p&gt;The full system is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;11ty for static site generation&lt;/li&gt;
&lt;li&gt;GitHub Actions for scheduling and deployment&lt;/li&gt;
&lt;li&gt;A simple JSON file for inventory tracking&lt;/li&gt;
&lt;li&gt;Slack webhook for low-stock alerts&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Total lines of custom code: ~50.&lt;/p&gt;

&lt;p&gt;Sometimes the best solutions aren't frameworks or SaaS products. Sometimes it's just a JSON file and a cron job.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;How do you handle content scheduling?&lt;/strong&gt; Built your own system, or using something off-the-shelf?&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. Yes, we practice what we preach with consistent publishing.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>automation</category>
      <category>productivity</category>
      <category>devops</category>
      <category>buildinpublic</category>
    </item>
  </channel>
</rss>
