<?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>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>
    <item>
      <title>Caching API Responses at the Edge with Cloudflare Cache Rules</title>
      <dc:creator>Anand Rathnas</dc:creator>
      <pubDate>Thu, 26 Feb 2026 01:34:49 +0000</pubDate>
      <link>https://dev.to/anand_rathnas_d5b608cc3de/caching-api-responses-at-the-edge-with-cloudflare-cache-rules-4c8i</link>
      <guid>https://dev.to/anand_rathnas_d5b608cc3de/caching-api-responses-at-the-edge-with-cloudflare-cache-rules-4c8i</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;This article was originally published on &lt;a href="https://jo4.io/blog/cloudflare-api-caching/" rel="noopener noreferrer"&gt;Jo4 Blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Your API is getting hammered. Same endpoints, same responses, thousands of requests. Your origin server is sweating. Here's how I used Cloudflare Cache Rules to serve API responses from the edge—no code changes required.&lt;/p&gt;

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

&lt;p&gt;I run a URL shortener. The redirect API (&lt;code&gt;/api/v1/public/a/{shortUrl}&lt;/code&gt;) returns JSON with the destination URL. Popular links get hit thousands of times a day.&lt;/p&gt;

&lt;p&gt;The thing is—the response rarely changes. A short URL pointing to &lt;code&gt;https://example.com&lt;/code&gt; will point there for days, weeks, months. Why hit the origin every single time?&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Not Just Use Cache-Control Headers?
&lt;/h2&gt;

&lt;p&gt;You could add &lt;code&gt;Cache-Control: public, max-age=3600&lt;/code&gt; in your backend code. But:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Code changes&lt;/strong&gt; = deployments, testing, potential bugs&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cloudflare doesn't cache API responses by default&lt;/strong&gt; (even with headers)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Less flexibility&lt;/strong&gt;—TTLs are baked into code&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Cache Rules let you handle this entirely at the infrastructure layer.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Cloudflare Dashboard &amp;gt; Caching &amp;gt; Cache Rules &amp;gt; Create Rule&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  The Expression
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;(http.request.uri.path contains "/api/v1/public/a/" and not any(http.request.headers["X-Jo4-Url-Key"][*] ne ""))
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This matches:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Requests to the short URL API&lt;/li&gt;
&lt;li&gt;AND excludes requests with a password header (for protected URLs)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Why exclude password-protected URLs? If you cache them, anyone hitting that URL gets the cached response—including the destination. Security hole.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Cache Settings
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Setting&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Cache eligibility&lt;/td&gt;
&lt;td&gt;Eligible for cache&lt;/td&gt;
&lt;td&gt;Enable caching for matching requests&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Edge TTL&lt;/td&gt;
&lt;td&gt;2 hours&lt;/td&gt;
&lt;td&gt;How long Cloudflare edge holds it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Browser TTL&lt;/td&gt;
&lt;td&gt;1 hour&lt;/td&gt;
&lt;td&gt;How long the client browser holds it&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Why Staggered TTLs?
&lt;/h3&gt;

&lt;p&gt;The 1hr browser + 2hr edge combo is intentional:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;0-1hr&lt;/strong&gt;: Browser serves from local cache (instant, zero network)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;1-2hr&lt;/strong&gt;: Browser cache expired, but Cloudflare edge still has it (fast, no origin)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;2hr+&lt;/strong&gt;: Both expired, request hits origin (fresh data)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This maximizes cache hits while keeping staleness reasonable.&lt;/p&gt;

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

&lt;p&gt;Here's the tradeoff: if Cloudflare serves a cached response, your origin never sees the request. No analytics recorded.&lt;/p&gt;

&lt;p&gt;For my use case, this is fine. I care about unique visitors, not repeat hits from the same client. The first request hits origin (analytics recorded), subsequent requests get cached.&lt;/p&gt;

&lt;p&gt;If you need accurate hit counts:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Use a shorter TTL (15-30 minutes)&lt;/li&gt;
&lt;li&gt;Implement client-side analytics beacons&lt;/li&gt;
&lt;li&gt;Or just don't cache—sometimes origin load is acceptable&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Cache Invalidation
&lt;/h2&gt;

&lt;p&gt;What if a URL changes? Two options:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Wait for TTL&lt;/strong&gt; (2 hours max in my case—acceptable)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Purge via API&lt;/strong&gt;:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"https://api.cloudflare.com/client/v4/zones/{zone_id}/purge_cache"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer {api_token}"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data&lt;/span&gt; &lt;span class="s1"&gt;'{"files":["https://yourdomain.com/api/v1/public/a/abc123"]}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For most use cases, just pick a TTL you can live with and skip the invalidation complexity.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bonus: Serve Stale While Revalidating
&lt;/h2&gt;

&lt;p&gt;Enable &lt;strong&gt;"Serve stale content while revalidating"&lt;/strong&gt; in your cache rule. When cache expires:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Cloudflare immediately serves the stale cached response&lt;/li&gt;
&lt;li&gt;Simultaneously fetches fresh content from origin&lt;/li&gt;
&lt;li&gt;Next request gets the fresh version&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Users never wait for origin. Cache stays warm.&lt;/p&gt;

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

&lt;p&gt;Before: Every request hit origin. Popular URLs caused load spikes.&lt;/p&gt;

&lt;p&gt;After: ~90% of repeat requests served from edge. Origin load dropped significantly. Response times for cached requests: &amp;lt;50ms globally (served from nearest Cloudflare POP).&lt;/p&gt;

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

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Cloudflare doesn't cache API responses by default&lt;/strong&gt;—you need Cache Rules to enable it&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Exclude sensitive requests&lt;/strong&gt;—use expressions to skip password-protected or authenticated endpoints&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stagger your TTLs&lt;/strong&gt;—browser TTL &amp;lt; edge TTL gives you layered caching&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Accept the analytics tradeoff&lt;/strong&gt;—or implement client-side tracking&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Skip invalidation if you can&lt;/strong&gt;—pick a TTL you can live with, it's simpler&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Zero code changes&lt;/strong&gt;—this is purely infrastructure config&lt;/li&gt;
&lt;/ol&gt;




&lt;p&gt;&lt;strong&gt;Have questions about edge caching?&lt;/strong&gt; Drop a comment 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; - URL shortener with analytics, bio pages, and team workspaces.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>cloudflare</category>
      <category>performance</category>
      <category>caching</category>
      <category>api</category>
    </item>
    <item>
      <title>Why Your @Async Method Ignores @Transactional (And Leaks Internal Errors)</title>
      <dc:creator>Anand Rathnas</dc:creator>
      <pubDate>Tue, 24 Feb 2026 01:35:22 +0000</pubDate>
      <link>https://dev.to/anand_rathnas_d5b608cc3de/why-your-async-method-ignores-transactional-and-leaks-internal-errors-25ea</link>
      <guid>https://dev.to/anand_rathnas_d5b608cc3de/why-your-async-method-ignores-transactional-and-leaks-internal-errors-25ea</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;This article was originally published on &lt;a href="https://jo4.io/blog/async-transactional-pitfall/" rel="noopener noreferrer"&gt;Jo4 Blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Production bug report: "Why does the webhook error say 'Executing an update/delete query'?"&lt;/p&gt;

&lt;p&gt;That's a JPA internal error. It should never reach users. But there it was, stored in the database and visible in the admin panel. Here's how an innocent-looking &lt;code&gt;@Async&lt;/code&gt; method broke everything.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Code That Looked Fine
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Slf4j&lt;/span&gt;
&lt;span class="nd"&gt;@Service&lt;/span&gt;
&lt;span class="nd"&gt;@RequiredArgsConstructor&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;WebhookService&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;WebhookRepository&lt;/span&gt; &lt;span class="n"&gt;webhookRepository&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;WebhookEventRepository&lt;/span&gt; &lt;span class="n"&gt;webhookEventRepository&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="nd"&gt;@Async&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;fireEvent&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Long&lt;/span&gt; &lt;span class="n"&gt;userId&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;event&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Object&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;WebhookEntity&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;webhooks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;webhookRepository&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;findByUserIdAndEnabledTrue&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;userId&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;WebhookEntity&lt;/span&gt; &lt;span class="n"&gt;webhook&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;webhooks&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="nc"&gt;WebhookEventEntity&lt;/span&gt; &lt;span class="n"&gt;webhookEvent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;createWebhookEvent&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;webhook&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
            &lt;span class="n"&gt;deliverWebhook&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;webhookEvent&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;webhook&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;@Transactional&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;deliverWebhook&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;WebhookEventEntity&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;WebhookEntity&lt;/span&gt; &lt;span class="n"&gt;webhook&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// ... send HTTP request ...&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;success&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setStatus&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;WebhookEventStatus&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;DELIVERED&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
            &lt;span class="n"&gt;webhookEventRepository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;save&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

            &lt;span class="c1"&gt;// Reset failure count atomically&lt;/span&gt;
            &lt;span class="n"&gt;webhookRepository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;resetFailureCount&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;webhook&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getId&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="nc"&gt;System&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;currentTimeMillis&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="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The repository method:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Modifying&lt;/span&gt;
&lt;span class="nd"&gt;@Query&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"UPDATE WebhookEntity w SET w.failureCount = 0, w.modifiedTime = :now WHERE w.id = :id"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;resetFailureCount&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nd"&gt;@Param&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"id"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="nc"&gt;Long&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nd"&gt;@Param&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"now"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="nc"&gt;Long&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Looks reasonable, right? &lt;code&gt;@Async&lt;/code&gt; for background processing, &lt;code&gt;@Transactional&lt;/code&gt; for database consistency, &lt;code&gt;@Modifying&lt;/code&gt; for atomic updates.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Error
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;javax.persistence.TransactionRequiredException:
Executing an update/delete query
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This was getting stored in the &lt;code&gt;lastError&lt;/code&gt; field of webhook events. Users could see it. Security and UX nightmare.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Root Cause: Self-Invocation Bypasses Proxies
&lt;/h2&gt;

&lt;p&gt;Spring's &lt;code&gt;@Transactional&lt;/code&gt; works through proxies. When you call a &lt;code&gt;@Transactional&lt;/code&gt; method, you're actually calling a proxy that:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Starts a transaction&lt;/li&gt;
&lt;li&gt;Calls your actual method&lt;/li&gt;
&lt;li&gt;Commits or rolls back&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;But here's the catch: &lt;strong&gt;self-invocation bypasses the proxy&lt;/strong&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Async&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;fireEvent&lt;/span&gt;&lt;span class="o"&gt;(...)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// This runs in a new thread, outside any transaction&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;WebhookEntity&lt;/span&gt; &lt;span class="n"&gt;webhook&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;webhooks&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// This calls the method DIRECTLY, not through the proxy&lt;/span&gt;
        &lt;span class="n"&gt;deliverWebhook&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;webhookEvent&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;webhook&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// @Transactional is ignored!&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;When &lt;code&gt;fireEvent()&lt;/code&gt; calls &lt;code&gt;deliverWebhook()&lt;/code&gt;, it's calling &lt;code&gt;this.deliverWebhook()&lt;/code&gt; - the actual method on the instance, not the Spring proxy. The &lt;code&gt;@Transactional&lt;/code&gt; annotation is invisible.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;@Modifying&lt;/code&gt; query requires an active transaction. No transaction = exception.&lt;/p&gt;

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

&lt;p&gt;When you can't rely on &lt;code&gt;@Transactional&lt;/code&gt;, use programmatic transaction management:&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;@Slf4j&lt;/span&gt;
&lt;span class="nd"&gt;@Service&lt;/span&gt;
&lt;span class="nd"&gt;@RequiredArgsConstructor&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;WebhookService&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;WebhookRepository&lt;/span&gt; &lt;span class="n"&gt;webhookRepository&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;WebhookEventRepository&lt;/span&gt; &lt;span class="n"&gt;webhookEventRepository&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;TransactionTemplate&lt;/span&gt; &lt;span class="n"&gt;transactionTemplate&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// Inject this&lt;/span&gt;

    &lt;span class="nd"&gt;@Async&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;fireEvent&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Long&lt;/span&gt; &lt;span class="n"&gt;userId&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;event&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Object&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;WebhookEntity&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;webhooks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;webhookRepository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;findByUserIdAndEnabledTrue&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;userId&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;WebhookEntity&lt;/span&gt; &lt;span class="n"&gt;webhook&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;webhooks&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="nc"&gt;WebhookEventEntity&lt;/span&gt; &lt;span class="n"&gt;webhookEvent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;createWebhookEvent&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;webhook&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
            &lt;span class="n"&gt;deliverWebhook&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;webhookEvent&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;webhook&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;deliverWebhook&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;WebhookEventEntity&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;WebhookEntity&lt;/span&gt; &lt;span class="n"&gt;webhook&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// ... send HTTP request ...&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;success&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="c1"&gt;// Wrap database operations in explicit transaction&lt;/span&gt;
            &lt;span class="n"&gt;transactionTemplate&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;executeWithoutResult&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setStatus&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;WebhookEventStatus&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;DELIVERED&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
                &lt;span class="n"&gt;webhookEventRepository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;save&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
                &lt;span class="n"&gt;webhookRepository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;resetFailureCount&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;webhook&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getId&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="nc"&gt;System&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;currentTimeMillis&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="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;code&gt;TransactionTemplate&lt;/code&gt; doesn't rely on proxies. It explicitly starts and commits transactions. Works everywhere, including:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Self-invoked methods&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;@Async&lt;/code&gt; methods&lt;/li&gt;
&lt;li&gt;Lambda callbacks&lt;/li&gt;
&lt;li&gt;Anywhere proxy magic fails&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Bonus: Sanitize Your Error Messages
&lt;/h2&gt;

&lt;p&gt;Even after fixing the transaction issue, you should never leak internal errors to users:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="nf"&gt;sanitizeErrorMessage&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;message&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;message&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="s"&gt;"Unknown error"&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// Detect internal errors&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;message&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;contains&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Executing an update/delete query"&lt;/span&gt;&lt;span class="o"&gt;)&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="na"&gt;contains&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"javax.persistence"&lt;/span&gt;&lt;span class="o"&gt;)&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="na"&gt;contains&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"org.hibernate"&lt;/span&gt;&lt;span class="o"&gt;)&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="na"&gt;contains&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"java.sql"&lt;/span&gt;&lt;span class="o"&gt;)&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="na"&gt;contains&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"SQLException"&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Internal error during webhook delivery: {}"&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="k"&gt;return&lt;/span&gt; &lt;span class="s"&gt;"Internal server error"&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;message&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;length&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt; &lt;span class="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="na"&gt;substring&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="mi"&gt;497&lt;/span&gt;&lt;span class="o"&gt;)&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="n"&gt;message&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;h2&gt;
  
  
  The Debugging Checklist
&lt;/h2&gt;

&lt;p&gt;When you see &lt;code&gt;TransactionRequiredException&lt;/code&gt;:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Is the method called from the same class? (self-invocation)&lt;/li&gt;
&lt;li&gt;Is the caller &lt;code&gt;@Async&lt;/code&gt;? (new thread, no transaction)&lt;/li&gt;
&lt;li&gt;Is the method private? (proxies can't intercept)&lt;/li&gt;
&lt;li&gt;Is the class final? (no proxy possible)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Any of these = &lt;code&gt;@Transactional&lt;/code&gt; won't work. Use &lt;code&gt;TransactionTemplate&lt;/code&gt;.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Ever been bitten by Spring proxy magic?&lt;/strong&gt; What's your go-to solution for transaction issues in async code?&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 actually handles webhooks reliably.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>java</category>
      <category>springboot</category>
      <category>async</category>
      <category>transactions</category>
    </item>
    <item>
      <title>Implementing Per-Seat Team Billing with Stripe</title>
      <dc:creator>Anand Rathnas</dc:creator>
      <pubDate>Sat, 21 Feb 2026 01:34:41 +0000</pubDate>
      <link>https://dev.to/anand_rathnas_d5b608cc3de/implementing-per-seat-team-billing-with-stripe-5f43</link>
      <guid>https://dev.to/anand_rathnas_d5b608cc3de/implementing-per-seat-team-billing-with-stripe-5f43</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;This article was originally published on &lt;a href="https://jo4.io/blog/stripe-team-billing/" rel="noopener noreferrer"&gt;Jo4 Blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Adding team plans to a SaaS is tricky. You need per-seat pricing, plan upgrades/downgrades, webhook handling, and graceful degradation when payments fail. Here's how I built it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Pricing Model
&lt;/h2&gt;

&lt;p&gt;We offer both individual and team plans:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Individual Plans:&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;Free:     $0/month     - 30 URLs, 2 bio links, basic analytics
Pro:      $16/month    - 500 URLs, 10 bio links, custom domains
Pro Plus: $48/month    - Unlimited everything, white-label, SSO
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Team Plans (per-seat):&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;Team Pro:      $10/seat/month  - 1,000 URLs/seat, 10 team members max
Team Business: $20/seat/month  - 2,000 URLs/seat, 50 team members, priority support
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Annual billing gets 2 months free (pay for 10, get 12).&lt;/p&gt;

&lt;h2&gt;
  
  
  Creating Checkout Sessions
&lt;/h2&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;String&lt;/span&gt; &lt;span class="nf"&gt;createCheckoutSession&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;teamSlug&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;SubscriptionTier&lt;/span&gt; &lt;span class="n"&gt;tier&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;seatCount&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;SubscriptionInterval&lt;/span&gt; &lt;span class="n"&gt;interval&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
                                    &lt;span class="nc"&gt;Long&lt;/span&gt; &lt;span class="n"&gt;userId&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;TeamEntity&lt;/span&gt; &lt;span class="n"&gt;team&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;getTeamOrThrow&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;teamSlug&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="nc"&gt;TeamMemberEntity&lt;/span&gt; &lt;span class="n"&gt;member&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;getMemberOrThrow&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;team&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getId&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;userId&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

    &lt;span class="c1"&gt;// Only owner can manage billing&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;member&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;canManageBilling&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="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;AppException&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ErrorCode&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;TEAM_BILLING_NOT_OWNER&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// Minimum seats = current active members&lt;/span&gt;
    &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;activeMembers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;countActiveFullMembers&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;team&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getId&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;effectiveSeatCount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Math&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;max&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;seatCount&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;activeMembers&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;priceId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;getPriceIdForTier&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tier&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;interval&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

    &lt;span class="c1"&gt;// Create or get Stripe customer&lt;/span&gt;
    &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;customerId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;getOrCreateStripeCustomer&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;team&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;member&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

    &lt;span class="nc"&gt;SessionCreateParams&lt;/span&gt; &lt;span class="n"&gt;params&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;SessionCreateParams&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;builder&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setCustomer&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;customerId&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setMode&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;SessionCreateParams&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;Mode&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;SUBSCRIPTION&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setSuccessUrl&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;baseUrl&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;"/teams/"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;teamSlug&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;"/settings?success=true"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setCancelUrl&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;baseUrl&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;"/teams/"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;teamSlug&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;"/settings?canceled=true"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;addLineItem&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;SessionCreateParams&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;LineItem&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;builder&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setPrice&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;priceId&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setQuantity&lt;/span&gt;&lt;span class="o"&gt;((&lt;/span&gt;&lt;span class="kt"&gt;long&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="n"&gt;effectiveSeatCount&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setSubscriptionData&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;SessionCreateParams&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;SubscriptionData&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;builder&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;putMetadata&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"teamId"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;team&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getId&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;toString&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;putMetadata&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"tier"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tier&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getValue&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;build&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;Session&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;create&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;getUrl&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;h2&gt;
  
  
  The Seat Count Problem
&lt;/h2&gt;

&lt;p&gt;Users will try to downgrade seats below their member count:&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="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;activeMembers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;countActiveFullMembers&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;team&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getId&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;effectiveSeatCount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Math&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;max&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;seatCount&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;activeMembers&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If they have 5 team members but try to buy 3 seats, we auto-correct to 5. The UI should warn them first.&lt;/p&gt;

&lt;h2&gt;
  
  
  Handling Webhooks
&lt;/h2&gt;

&lt;p&gt;Stripe webhooks are the source of truth. Never trust client-side success callbacks.&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;"/webhooks/stripe"&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;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;handleWebhook&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nd"&gt;@RequestBody&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
                                            &lt;span class="nd"&gt;@RequestHeader&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Stripe-Signature"&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;signature&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;Event&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Webhook&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;constructEvent&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;signature&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;webhookSecret&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;switch&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getType&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="s"&gt;"checkout.session.completed"&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;handleCheckoutCompleted&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="s"&gt;"customer.subscription.updated"&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;handleSubscriptionUpdated&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="s"&gt;"customer.subscription.deleted"&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;handleSubscriptionCancelled&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="s"&gt;"invoice.payment_failed"&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;handlePaymentFailed&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&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;ok&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"OK"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Payment Failed - Pause, Don't Delete
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;handlePaymentFailed&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Event&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;Invoice&lt;/span&gt; &lt;span class="n"&gt;invoice&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Invoice&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getDataObjectDeserializer&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getObject&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;orElseThrow&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;subscriptionId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;invoice&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getSubscription&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;

    &lt;span class="nc"&gt;TeamEntity&lt;/span&gt; &lt;span class="n"&gt;team&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;teamRepository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;findByStripeSubscriptionId&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;subscriptionId&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;orElse&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="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;team&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="k"&gt;return&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="c1"&gt;// Pause the team - don't delete data&lt;/span&gt;
    &lt;span class="n"&gt;team&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setSubscriptionStatus&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;SubscriptionStatus&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;PAST_DUE&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;teamRepository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;save&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;team&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

    &lt;span class="c1"&gt;// Email the owner&lt;/span&gt;
    &lt;span class="n"&gt;sendPaymentFailedEmail&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;team&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;Why pause instead of downgrade immediately? Give users time to fix payment. Nobody wants to lose features because their card expired.&lt;/p&gt;

&lt;h2&gt;
  
  
  URL Limits Based on Seats
&lt;/h2&gt;

&lt;p&gt;Team URL limits scale with seat count:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nf"&gt;calculateUrlLimit&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;tier&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;seatCount&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;switch&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tier&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;toUpperCase&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="s"&gt;"TEAM_PRO"&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;seatCount&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;      &lt;span class="c1"&gt;// 1,000 URLs per seat&lt;/span&gt;
        &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="s"&gt;"TEAM_BUSINESS"&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;seatCount&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2000&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// 2,000 URLs per seat&lt;/span&gt;
        &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="s"&gt;"PRO_PLUS"&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;Integer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;MAX_VALUE&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;     &lt;span class="c1"&gt;// Unlimited&lt;/span&gt;
        &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="no"&gt;FREE_TEAM_URL_LIMIT&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;           &lt;span class="c1"&gt;// Free tier: 100 URLs&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;h2&gt;
  
  
  Billing Portal Access
&lt;/h2&gt;

&lt;p&gt;Let users manage their subscription in Stripe's portal:&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;String&lt;/span&gt; &lt;span class="nf"&gt;createBillingPortalSession&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;teamSlug&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Long&lt;/span&gt; &lt;span class="n"&gt;userId&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;TeamEntity&lt;/span&gt; &lt;span class="n"&gt;team&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;getTeamOrThrow&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;teamSlug&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;team&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getStripeCustomerId&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="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;AppException&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ErrorCode&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;TEAM_NO_SUBSCRIPTION&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="nc"&gt;SessionCreateParams&lt;/span&gt; &lt;span class="n"&gt;params&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;SessionCreateParams&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;builder&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setCustomer&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;team&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getStripeCustomerId&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setReturnUrl&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;baseUrl&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;"/teams/"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;teamSlug&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;"/settings"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;build&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;Session&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;create&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;getUrl&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;h2&gt;
  
  
  Testing Webhooks Locally
&lt;/h2&gt;

&lt;p&gt;Use Stripe CLI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;stripe listen &lt;span class="nt"&gt;--forward-to&lt;/span&gt; localhost:8080/api/v1/public/webhooks/stripe
&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;Webhooks are truth&lt;/strong&gt; - Don't trust client-side success callbacks&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pause, don't punish&lt;/strong&gt; - Give users time to fix payment issues before downgrading&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Minimum seats = current members&lt;/strong&gt; - Prevent invalid states&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Email on failures&lt;/strong&gt; - Users need to know immediately when payment fails&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Metadata is your friend&lt;/strong&gt; - Store teamId and tier in Stripe subscription metadata&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test the cancellation flow&lt;/strong&gt; - It's the path most likely to have bugs&lt;/li&gt;
&lt;/ol&gt;




&lt;p&gt;&lt;strong&gt;Building team billing?&lt;/strong&gt; What edge cases have bitten you?&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, bio pages, and team workspaces.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>stripe</category>
      <category>saas</category>
      <category>billing</category>
      <category>java</category>
    </item>
    <item>
      <title>Why Auth0 email_verified Was Missing from My Access Token</title>
      <dc:creator>Anand Rathnas</dc:creator>
      <pubDate>Thu, 19 Feb 2026 01:35:15 +0000</pubDate>
      <link>https://dev.to/anand_rathnas_d5b608cc3de/why-auth0-emailverified-was-missing-from-my-access-token-3n75</link>
      <guid>https://dev.to/anand_rathnas_d5b608cc3de/why-auth0-emailverified-was-missing-from-my-access-token-3n75</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;This article was originally published on &lt;a href="https://jo4.io/blog/auth0-email-verified/" rel="noopener noreferrer"&gt;Jo4 Blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Spent an hour debugging why verified users were getting blocked. The culprit? Auth0 doesn't include &lt;code&gt;email_verified&lt;/code&gt; in access tokens by default.&lt;/p&gt;

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

&lt;p&gt;My Spring Boot filter checks if users have verified their email:&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;Boolean&lt;/span&gt; &lt;span class="n"&gt;emailVerified&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;jwt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getClaimAsBoolean&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"email_verified"&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;emailVerified&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="n"&gt;emailVerified&lt;/span&gt;&lt;span class="o"&gt;)&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;setStatus&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;HttpServletResponse&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;SC_FORBIDDEN&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;getWriter&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;write&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"{\"error\":\"Email not verified\"}"&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Users who had verified their email in Auth0 Dashboard (showing "VERIFIED" badge) were still getting blocked. The logs showed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User email not verified: sub=auth0|67c6a695657d0f4f7ac8736f
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But... they ARE verified. What gives?&lt;/p&gt;

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

&lt;p&gt;Auth0 includes &lt;code&gt;email_verified&lt;/code&gt; in the &lt;strong&gt;ID token&lt;/strong&gt; but NOT in the &lt;strong&gt;access token&lt;/strong&gt; by default.&lt;/p&gt;

&lt;p&gt;When you decode your access token, you might see:&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;"iss"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://your-tenant.auth0.com/"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"sub"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"auth0|67c6a695657d0f4f7ac8736f"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"aud"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"https://your-api/"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"iat"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1767421775&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"exp"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1767508175&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;"openid profile email"&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;No &lt;code&gt;email_verified&lt;/code&gt;. Even though you requested the &lt;code&gt;email&lt;/code&gt; scope.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Fix: Auth0 Action
&lt;/h2&gt;

&lt;p&gt;Create a Post-Login Action to add the claim to your access token:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Auth0 Dashboard &amp;gt; Actions &amp;gt; Flows &amp;gt; Login &amp;gt; Add Action&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="nx"&gt;exports&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onExecutePostLogin&lt;/span&gt; &lt;span class="o"&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;event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;api&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Add email_verified to access token for API validation&lt;/span&gt;
  &lt;span class="nx"&gt;api&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;accessToken&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setCustomClaim&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;email_verified&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;email_verified&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;Deploy it, drag it into your Login flow.&lt;/p&gt;

&lt;p&gt;Now your access token includes:&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;"email_verified"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"iss"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://your-tenant.auth0.com/"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"sub"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"auth0|67c6a695657d0f4f7ac8736f"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Why Auth0 Does This
&lt;/h2&gt;

&lt;p&gt;ID tokens are for the &lt;strong&gt;client&lt;/strong&gt; (your frontend) to know who the user is.&lt;/p&gt;

&lt;p&gt;Access tokens are for the &lt;strong&gt;API&lt;/strong&gt; (your backend) to authorize requests.&lt;/p&gt;

&lt;p&gt;Auth0's philosophy: access tokens should contain authorization info, not identity info. But in practice, your API often needs both.&lt;/p&gt;

&lt;h2&gt;
  
  
  Alternative: Fetch from Userinfo Endpoint
&lt;/h2&gt;

&lt;p&gt;If you can't modify Auth0 Actions (or want a fallback), fetch from the userinfo endpoint:&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;// If email_verified not in JWT, fetch from Auth0&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;emailVerified&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="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;userinfoUrl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;auth0Issuer&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;"userinfo"&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="c1"&gt;// GET with Bearer token&lt;/span&gt;
    &lt;span class="c1"&gt;// Parse response for email_verified&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But this adds latency to every request. The Action approach is better.&lt;/p&gt;

&lt;h2&gt;
  
  
  OAuth Users Don't Need This Check
&lt;/h2&gt;

&lt;p&gt;Plot twist: if you're using social login (Google, GitHub), the provider already verified the email. Auth0 sets &lt;code&gt;email_verified: true&lt;/code&gt; automatically for OAuth users.&lt;/p&gt;

&lt;p&gt;You could skip the check for non-database connections:&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;String&lt;/span&gt; &lt;span class="n"&gt;subject&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;jwt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getSubject&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
&lt;span class="kt"&gt;boolean&lt;/span&gt; &lt;span class="n"&gt;isDatabaseConnection&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;subject&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;subject&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;startsWith&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"auth0|"&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;isDatabaseConnection&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Only check email_verified for username/password users&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But I prefer keeping it simple - just add the claim via Action and check for everyone. KISS.&lt;/p&gt;

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

&lt;p&gt;Here's my full Action that also handles resending verification emails:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;exports&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onExecutePostLogin&lt;/span&gt; &lt;span class="o"&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;event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;api&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Always include email_verified in access token&lt;/span&gt;
  &lt;span class="nx"&gt;api&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;accessToken&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setCustomClaim&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;email_verified&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;email_verified&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// Auto-resend verification email for unverified users (rate limited)&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;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;email_verified&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;lastSent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user_metadata&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;verification_email_last_sent&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ONE_HOUR&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&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;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;lastSent&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;now&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;lastSent&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;ONE_HOUR&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="c1"&gt;// Trigger verification email via Management API&lt;/span&gt;
      &lt;span class="c1"&gt;// ... (see Auth0 docs for Management API setup)&lt;/span&gt;

      &lt;span class="nx"&gt;api&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setUserMetadata&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;verification_email_last_sent&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="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;h2&gt;
  
  
  Lessons Learned
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;ID token != Access token&lt;/strong&gt; - They serve different purposes and contain different claims&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test with actual tokens&lt;/strong&gt; - Decode your JWT at jwt.io to see what's really there&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Actions are powerful&lt;/strong&gt; - You can add any claim you need to the access token&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check Auth0 Dashboard carefully&lt;/strong&gt; - Just because it shows "VERIFIED" doesn't mean the token has the claim&lt;/li&gt;
&lt;/ol&gt;




&lt;p&gt;&lt;strong&gt;Ever been burned by missing JWT claims?&lt;/strong&gt; What other claims do you add via Actions?&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 white-labeling.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>auth0</category>
      <category>jwt</category>
      <category>security</category>
      <category>authentication</category>
    </item>
    <item>
      <title>Cloudflare Bot Fight Mode Breaks Zapier OAuth (And How to Fix It)</title>
      <dc:creator>Anand Rathnas</dc:creator>
      <pubDate>Tue, 17 Feb 2026 01:35:14 +0000</pubDate>
      <link>https://dev.to/anand_rathnas_d5b608cc3de/cloudflare-bot-fight-mode-breaks-zapier-oauth-and-how-to-fix-it-2429</link>
      <guid>https://dev.to/anand_rathnas_d5b608cc3de/cloudflare-bot-fight-mode-breaks-zapier-oauth-and-how-to-fix-it-2429</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;This article was originally published on &lt;a href="https://jo4.io/blog/cloudflare-bot-fight-mode/" rel="noopener noreferrer"&gt;Jo4 Blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;This is a follow-up to my previous article on implementing Zapier OAuth in Spring Boot. After solving all the code issues, I hit one more wall.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;You've implemented OAuth 2.0 with PKCE. Your E2E tests pass. You deploy to production. Zapier shows:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Oh, foo. Zapier could not connect to your account.
Try connecting again. Or go to your Zaps and try re-enabling them.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Cloudflare "Just a moment..." page strikes again.&lt;/p&gt;




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

&lt;p&gt;Zapier's OAuth callback hits your &lt;code&gt;/oauth/token&lt;/code&gt; endpoint and gets a 403 with an HTML page instead of JSON:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="cp"&gt;&amp;lt;!DOCTYPE html&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;html&lt;/span&gt; &lt;span class="na"&gt;lang=&lt;/span&gt;&lt;span class="s"&gt;"en-US"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;head&amp;gt;&amp;lt;title&amp;gt;&lt;/span&gt;Just a moment...&lt;span class="nt"&gt;&amp;lt;/title&amp;gt;&lt;/span&gt;
&lt;span class="c"&gt;&amp;lt;!-- Cloudflare challenge page --&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your logs show nothing. Because the request never reaches your application.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Root Cause: Bot Fight Mode
&lt;/h2&gt;

&lt;p&gt;If you have &lt;strong&gt;Bot Fight Mode&lt;/strong&gt; enabled in Cloudflare (Security &amp;gt; Settings &amp;gt; Bot traffic), it's challenging Zapier's automated requests. Even though Zapier is in Cloudflare's verified bots list.&lt;/p&gt;

&lt;p&gt;"Easy fix," I thought. "I'll create a WAF rule to skip Bot Fight Mode for &lt;code&gt;/oauth/*&lt;/code&gt; paths."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Wrong.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The Documented Limitation You Won't Find Easily
&lt;/h2&gt;

&lt;p&gt;From Cloudflare's official docs:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"You cannot bypass or skip Bot Fight Mode using the Skip action in WAF custom rules or using Page Rules."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;And the explanation:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Skip, Bypass, and Allow actions apply to rules or rulesets running on the Ruleset Engine. While Super Bot Fight Mode rules are implemented in the Ruleset Engine, &lt;strong&gt;Bot Fight Mode checks are not.&lt;/strong&gt;"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Translation: Bot Fight Mode (Free plan) runs outside the normal rule engine. No WAF rule, Page Rule, or IP Access Rule can bypass it for specific paths. It's all-or-nothing.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Solutions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Option 1: Disable Bot Fight Mode Entirely (Free Plan)
&lt;/h3&gt;

&lt;p&gt;If you're on Cloudflare Free:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Go to &lt;strong&gt;Security &amp;gt; Settings&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;Bot traffic&lt;/strong&gt; filter&lt;/li&gt;
&lt;li&gt;Turn off &lt;strong&gt;Bot Fight Mode&lt;/strong&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Your OAuth works. But you lose bot protection everywhere.&lt;/p&gt;

&lt;h3&gt;
  
  
  Option 2: Upgrade to Pro ($20/month) and Use Super Bot Fight Mode
&lt;/h3&gt;

&lt;p&gt;Super Bot Fight Mode &lt;em&gt;does&lt;/em&gt; run in the Ruleset Engine. You can skip it for specific paths.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 1: Enable Super Bot Fight Mode&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Upgrade to Cloudflare Pro&lt;/li&gt;
&lt;li&gt;Go to &lt;strong&gt;Security &amp;gt; Settings &amp;gt; Bot traffic&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Configure &lt;strong&gt;Super Bot Fight Mode&lt;/strong&gt;:

&lt;ul&gt;
&lt;li&gt;Definitely automated traffic: &lt;strong&gt;Block&lt;/strong&gt; or &lt;strong&gt;Managed Challenge&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Verified bots: &lt;strong&gt;Allow&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Step 2: Create Skip Rule for OAuth&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Go to &lt;strong&gt;Security &amp;gt; Security rules&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;Create rule&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Configure:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Rule name:&lt;/strong&gt; &lt;code&gt;Skip SBFM for OAuth&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Expression:&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; (starts_with(http.request.uri.path, "/oauth/token")) or
 (starts_with(http.request.uri.path, "/oauth/authorize")) or
 (starts_with(http.request.uri.path, "/oauth/userinfo"))
&lt;/code&gt;&lt;/pre&gt;

&lt;/li&gt;
&lt;/ol&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Action:&lt;/strong&gt; &lt;strong&gt;Skip&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;WAF components to skip:&lt;/strong&gt; Check &lt;strong&gt;All Super Bot Fight Mode Rules&lt;/strong&gt;

&lt;ol&gt;
&lt;li&gt;Click &lt;strong&gt;Deploy&lt;/strong&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Step 3: Test Zapier&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Retry the OAuth connection. Should work now.&lt;/p&gt;




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

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Plan&lt;/th&gt;
&lt;th&gt;Bot Protection&lt;/th&gt;
&lt;th&gt;Can Skip for APIs?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Free&lt;/td&gt;
&lt;td&gt;Bot Fight Mode&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pro ($20/mo)&lt;/td&gt;
&lt;td&gt;Super Bot Fight Mode&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If you're building integrations with Zapier, Make.com, n8n, or any other automation platform, you need the ability to exempt your OAuth endpoints from bot challenges. That requires Pro.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Cost-Benefit
&lt;/h2&gt;

&lt;p&gt;$20/month for Cloudflare Pro gives you:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Super Bot Fight Mode with path-based exceptions&lt;/li&gt;
&lt;li&gt;Better analytics&lt;/li&gt;
&lt;li&gt;Polish Rules (5 vs 0)&lt;/li&gt;
&lt;li&gt;Faster support&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For a production SaaS with third-party integrations, it's worth it. The alternative is no bot protection at all.&lt;/p&gt;




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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Bot Fight Mode (Free):&lt;/strong&gt; Cannot be bypassed for specific paths. All or nothing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Super Bot Fight Mode (Pro):&lt;/strong&gt; Can be skipped using WAF custom rules.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The fix:&lt;/strong&gt; Upgrade to Pro, enable SBFM, create Skip rule for &lt;code&gt;/oauth/*&lt;/code&gt; paths.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;OAuth integration isn't done until Cloudflare lets the requests through.&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>cloudflare</category>
      <category>oauth</category>
      <category>zapier</category>
      <category>security</category>
    </item>
  </channel>
</rss>
