<?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>We Built a Referral Program Tool Inside Our URL Shortener. Here's Why.</title>
      <dc:creator>Anand Rathnas</dc:creator>
      <pubDate>Mon, 01 Jun 2026 01:55:21 +0000</pubDate>
      <link>https://dev.to/anand_rathnas_d5b608cc3de/we-built-a-referral-program-tool-inside-our-url-shortener-heres-why-pak</link>
      <guid>https://dev.to/anand_rathnas_d5b608cc3de/we-built-a-referral-program-tool-inside-our-url-shortener-heres-why-pak</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-kit-launch-story/" rel="noopener noreferrer"&gt;Jo4 Blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Every SaaS founder eventually asks the same question: "How do I get my existing users to bring me new ones?"&lt;/p&gt;

&lt;p&gt;The answer is referral programs. The problem is the tools.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem with Referral Tools
&lt;/h2&gt;

&lt;p&gt;I spent a week evaluating referral platforms for jo4. Here's what I found:&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;Price&lt;/th&gt;
&lt;th&gt;What's Missing&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Rewardful&lt;/td&gt;
&lt;td&gt;$29-299/mo&lt;/td&gt;
&lt;td&gt;No link analytics, Stripe-only payouts&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;FirstPromoter&lt;/td&gt;
&lt;td&gt;$49-149/mo&lt;/td&gt;
&lt;td&gt;Complex setup, steep learning curve&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ReferralCandy&lt;/td&gt;
&lt;td&gt;$47-299/mo&lt;/td&gt;
&lt;td&gt;E-commerce only, no API&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every single one required a &lt;em&gt;separate&lt;/em&gt; URL shortener for tracking. Every one locked you into Stripe for payouts. And the cheapest option started at $29/mo for basic features.&lt;/p&gt;

&lt;p&gt;Meanwhile, I was already running a URL shortener with click analytics, device tracking, and geo-location built in. The referral link infrastructure was &lt;em&gt;already there&lt;/em&gt;. I just needed to add programs, referrers, and reward tracking on top.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Referral Kit Actually Does
&lt;/h2&gt;

&lt;p&gt;The pitch is simple: create a referral program in 10 minutes, not 10 days.&lt;/p&gt;

&lt;h3&gt;
  
  
  For Program Owners
&lt;/h3&gt;

&lt;p&gt;You create a program at &lt;code&gt;/rk&lt;/code&gt; with a name, reward structure, and destination URL. You choose between two reward types:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fixed Points&lt;/strong&gt; — every conversion earns the referrer the same amount. "Refer a friend, earn 100 points."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Percentage&lt;/strong&gt; — referrers earn a cut of the conversion value. "Earn 10% of every purchase your referral makes."&lt;/p&gt;

&lt;p&gt;You get a public landing page at &lt;code&gt;/rk/{your-program-slug}&lt;/code&gt; where anyone can sign up as a referrer. They pick a custom referral code (like &lt;code&gt;JOHN20&lt;/code&gt;), enter their payout email, and get a unique tracking link.&lt;/p&gt;

&lt;h3&gt;
  
  
  For Referrers
&lt;/h3&gt;

&lt;p&gt;After enrolling, you get a link like &lt;code&gt;https://jo4.io/r/{shortUrl}&lt;/code&gt;. Share it anywhere. Every click is tracked with the same analytics jo4 already provides — device, location, referrer source, timestamp.&lt;/p&gt;

&lt;p&gt;When someone converts (buys something, signs up, whatever your program defines as a conversion), you earn points. Your dashboard shows clicks, conversions, conversion rate, and pending/approved/paid points across all programs you're in.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Conversion Flow
&lt;/h3&gt;

&lt;p&gt;This is where it gets interesting. Program owners trigger conversions via API:&lt;br&gt;
&lt;/p&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 https://jo4-api.jo4.io/api/v1/public/referral/conversion &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"X-Jo4-Conversion-Key: your_secret_key"&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;-d&lt;/span&gt; &lt;span class="s1"&gt;'{
    "programSlug": "summer-referral",
    "referralCode": "JOHN20",
    "conversionValue": 99.00,
    "externalId": "order_12345"
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The API attributes the conversion to the referrer, calculates the reward (fixed or percentage), and creates a pending reward. The program owner reviews and approves. The referrer requests payout. The owner pays (offline — no Stripe lock-in) and marks it done.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Status flow:&lt;/strong&gt; &lt;code&gt;PENDING → APPROVED → PAYOUT_REQUESTED → PAID&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;No payment processor dependency. No percentage-of-a-percentage. You pay your referrers however you want — PayPal, Venmo, bank transfer, gift cards, crypto. We just track the state.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Points Instead of Cash
&lt;/h2&gt;

&lt;p&gt;Most referral tools deal in dollars. We deal in points.&lt;/p&gt;

&lt;p&gt;Points are flexible. "100 points = $10" works. So does "100 points = 1 free month" or "100 points = a t-shirt." The program owner defines what points mean. We track the math.&lt;/p&gt;

&lt;p&gt;This matters for non-SaaS businesses. A nail salon running a referral program doesn't want to wire $10 to a customer's bank account. They want to give them a free manicure. Points let you do that.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Technical Bits
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Attribution Window
&lt;/h3&gt;

&lt;p&gt;Every program has a configurable attribution window (default: 30 days). If someone clicks a referral link and converts within that window, the referrer gets credit. After the window closes, the conversion is organic.&lt;/p&gt;

&lt;h3&gt;
  
  
  Event Deduplication
&lt;/h3&gt;

&lt;p&gt;The conversion API uses &lt;code&gt;externalId&lt;/code&gt; for idempotency. Send the same order ID twice, and we only credit the referrer once. This matters when your payment webhook fires multiple times (and it will).&lt;/p&gt;

&lt;h3&gt;
  
  
  Test Mode
&lt;/h3&gt;

&lt;p&gt;Prefix your &lt;code&gt;externalId&lt;/code&gt; with &lt;code&gt;test_&lt;/code&gt; and the conversion validates against your API secret but doesn't persist. Test your integration without polluting production data.&lt;/p&gt;

&lt;h3&gt;
  
  
  Webhooks
&lt;/h3&gt;

&lt;p&gt;Every state change fires a webhook if you've configured one:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;referral.signup&lt;/code&gt; — new referrer enrolled&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;referral.conversion&lt;/code&gt; — conversion attributed&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;referral.reward.approved&lt;/code&gt; — reward approved by owner&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;referral.reward.paid&lt;/code&gt; — payout completed&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Pipe these into Zapier, Slack, your CRM — whatever your workflow needs.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Got Right
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Building on existing infrastructure.&lt;/strong&gt; The URL shortener already had click tracking, device fingerprinting, and geo-location. Referral links are just URLs with attribution metadata. We didn't rebuild tracking — we extended it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Offline payouts.&lt;/strong&gt; Forcing Stripe integration would have narrowed our market to SaaS-only. The confirm-payout workflow is more manual, but it works for salons, clinics, restaurants, freelancers — anyone who pays referrers in-person or via their preferred method.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Points abstraction.&lt;/strong&gt; Cash rewards sound simpler, but they're actually harder. Currency conversion, tax implications, minimum payout thresholds — points sidestep all of that. Let the program owner decide what points are worth.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Got Wrong
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Underestimating the UI complexity.&lt;/strong&gt; The &lt;code&gt;/rk&lt;/code&gt; page serves three audiences: program owners viewing their programs, referrers viewing their enrolled programs, and new users discovering programs. Getting the information hierarchy right took more iterations than the entire backend.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The reward approval workflow.&lt;/strong&gt; My first implementation auto-approved everything. Turns out program owners want to verify conversions before committing to payouts. Fraud is real, even in referral programs. Adding the PENDING → APPROVED step was the right call, but I should have started there.&lt;/p&gt;

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

&lt;p&gt;The whole feature is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;1,084 lines of referral program service (Java)&lt;/li&gt;
&lt;li&gt;3 new database tables with proper indexes and constraints&lt;/li&gt;
&lt;li&gt;15+ API endpoints (public + protected)&lt;/li&gt;
&lt;li&gt;6 new frontend pages&lt;/li&gt;
&lt;li&gt;Full webhook integration&lt;/li&gt;
&lt;li&gt;Test mode for safe integration development&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And it ships with every jo4 plan. No add-on pricing. No "referral features start at Enterprise tier."&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Running a referral program or thinking about adding one to your product?&lt;/strong&gt; I'd love to hear what's working (or not) for you. Drop your experience 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, QR codes, and now referral program management. Because your links should do more than redirect.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>startup</category>
      <category>buildinpublic</category>
      <category>saas</category>
      <category>marketing</category>
    </item>
    <item>
      <title>RevenueCat Integration for Indie SaaS: The Apple Tax Nobody Prepares You For</title>
      <dc:creator>Anand Rathnas</dc:creator>
      <pubDate>Fri, 29 May 2026 01:47:54 +0000</pubDate>
      <link>https://dev.to/anand_rathnas_d5b608cc3de/revenuecat-integration-for-indie-saas-the-apple-tax-nobody-prepares-you-for-1cnk</link>
      <guid>https://dev.to/anand_rathnas_d5b608cc3de/revenuecat-integration-for-indie-saas-the-apple-tax-nobody-prepares-you-for-1cnk</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;This article was originally published on &lt;a href="https://jo4.io/blog/revenuecat-iap-integration-indie-saas/" rel="noopener noreferrer"&gt;Jo4 Blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I had Stripe working perfectly. Web dashboard payments, team billing, subscription upgrades and downgrades - all handled. Users happy. Revenue flowing.&lt;/p&gt;

&lt;p&gt;Then I submitted my React Native app to the App Store.&lt;/p&gt;

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

&lt;p&gt;Apple's App Store Review Guideline 3.1.1 is clear: if your app offers subscriptions and the user can subscribe &lt;em&gt;in&lt;/em&gt; the app, you must use Apple's In-App Purchase system. No exceptions. No "just link to your website." If you show a paywall, it goes through Apple.&lt;/p&gt;

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

&lt;ol&gt;
&lt;li&gt;Apple takes 15-30% of every transaction&lt;/li&gt;
&lt;li&gt;You can't use your existing Stripe flow on iOS&lt;/li&gt;
&lt;li&gt;You need a &lt;em&gt;second&lt;/em&gt; billing system running in parallel&lt;/li&gt;
&lt;li&gt;Both systems must keep the same user's subscription state in sync&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I'd heard of RevenueCat as the standard way to handle this. What I hadn't heard was how many moving parts are involved when you already have a working billing system.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Architecture Decision
&lt;/h2&gt;

&lt;p&gt;The core question: where does the source of truth live?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Option A:&lt;/strong&gt; RevenueCat is the source of truth, and your backend reads from it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Option B:&lt;/strong&gt; Your backend database is the source of truth, and RevenueCat sends webhooks to update it.&lt;/p&gt;

&lt;p&gt;I went with &lt;strong&gt;Option B&lt;/strong&gt;. Here's why:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;I already had &lt;code&gt;subscriptionTier&lt;/code&gt;, &lt;code&gt;subscriptionStatus&lt;/code&gt;, and &lt;code&gt;subscriptionExpiry&lt;/code&gt; on my &lt;code&gt;UserEntity&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;All my API authorization logic checked these fields&lt;/li&gt;
&lt;li&gt;Stripe webhooks already wrote to these same fields&lt;/li&gt;
&lt;li&gt;I wasn't about to rewrite every &lt;code&gt;@PreAuthorize&lt;/code&gt; check to call RevenueCat's API&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the plan: RevenueCat sends webhook events to my backend, and my backend updates the database. Same pattern as Stripe. How hard could it be?&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Step 1: The Webhook Handler (Spring Boot)
&lt;/h3&gt;

&lt;p&gt;RevenueCat sends a POST with a JSON payload containing an &lt;code&gt;event&lt;/code&gt; object. The event has a &lt;code&gt;type&lt;/code&gt; (like &lt;code&gt;INITIAL_PURCHASE&lt;/code&gt;, &lt;code&gt;RENEWAL&lt;/code&gt;, &lt;code&gt;CANCELLATION&lt;/code&gt;) and an &lt;code&gt;app_user_id&lt;/code&gt; that you set when the user logs in on mobile.&lt;/p&gt;

&lt;p&gt;The key insight: &lt;strong&gt;use your database user ID as RevenueCat's &lt;code&gt;app_user_id&lt;/code&gt;&lt;/strong&gt;. This makes the webhook handler trivial - you get the user ID directly from the event, look up the user, and update their subscription.&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;// On mobile login, after getting the backend user profile:&lt;/span&gt;
&lt;span class="n"&gt;await&lt;/span&gt; &lt;span class="nc"&gt;Purchases&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;logIn&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="n"&gt;profileData&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;response&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;

&lt;span class="c1"&gt;// In the webhook handler, the app_user_id IS your database ID:&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;Long&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;parseLong&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;path&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"app_user_id"&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;asText&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
&lt;span class="nc"&gt;UserEntity&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;userRepository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;findById&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="na"&gt;orElseThrow&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No mapping tables. No secondary lookup. Clean.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Product ID Conventions
&lt;/h3&gt;

&lt;p&gt;RevenueCat doesn't tell you the subscription tier directly. You infer it from the product ID you configured in App Store Connect. I used a simple convention:&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;// Product IDs: "jo4_pro_monthly", "jo4_proplus_yearly", etc.&lt;/span&gt;
&lt;span class="nc"&gt;SubscriptionTier&lt;/span&gt; &lt;span class="nf"&gt;determineTierFromProductId&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;productId&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;lower&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;productId&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;toLowerCase&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;lower&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;"proplus"&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;SubscriptionTier&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;PRO_PLUS&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;lower&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;"pro"&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;SubscriptionTier&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;PRO&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;SubscriptionTier&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;FREE&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;Order matters - check &lt;code&gt;proplus&lt;/code&gt; before &lt;code&gt;pro&lt;/code&gt;, otherwise every Pro+ user gets classified as Pro. Ask me how I found that one.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: Event Deduplication
&lt;/h3&gt;

&lt;p&gt;RevenueCat can send the same event multiple times (retries, network issues). Without deduplication, a single purchase could reset a user's URL count twice.&lt;/p&gt;

&lt;p&gt;Redis made this straightforward:&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;boolean&lt;/span&gt; &lt;span class="nf"&gt;tryMarkEventAsProcessed&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;eventId&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;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"revenuecat:webhook:event:"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;eventId&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="nc"&gt;Boolean&lt;/span&gt; &lt;span class="n"&gt;wasSet&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;redisTemplate&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;opsForValue&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setIfAbsent&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"processed"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Duration&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ofHours&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;24&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;Boolean&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;TRUE&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;equals&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;wasSet&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;Atomic &lt;code&gt;setIfAbsent&lt;/code&gt; with a 24-hour TTL. If it returns &lt;code&gt;false&lt;/code&gt;, we've already processed this event. Done.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4: The Seven Lifecycle Events
&lt;/h3&gt;

&lt;p&gt;This is where the complexity lives. RevenueCat sends seven different event types, and each one needs different handling:&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;What Happens&lt;/th&gt;
&lt;th&gt;Tier Change?&lt;/th&gt;
&lt;th&gt;Reset URL Count?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;INITIAL_PURCHASE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;New subscriber&lt;/td&gt;
&lt;td&gt;Yes - set tier&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;RENEWAL&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Auto-renewal succeeded&lt;/td&gt;
&lt;td&gt;Yes - confirm tier&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;CANCELLATION&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;User cancelled (but still has access until expiry)&lt;/td&gt;
&lt;td&gt;No - keep tier&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;EXPIRATION&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Access period ended&lt;/td&gt;
&lt;td&gt;Yes - back to FREE&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;BILLING_ISSUE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Payment failed, grace period&lt;/td&gt;
&lt;td&gt;No - pause status&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;PRODUCT_CHANGE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Upgrade or downgrade&lt;/td&gt;
&lt;td&gt;Yes - new tier&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;UNCANCELLATION&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;User re-enabled auto-renew&lt;/td&gt;
&lt;td&gt;No - restore ACTIVE status&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The tricky one is &lt;code&gt;CANCELLATION&lt;/code&gt;. Your instinct says "downgrade them." But no - they've already &lt;em&gt;paid&lt;/em&gt; for the current period. You mark them as &lt;code&gt;CANCELLED&lt;/code&gt; but keep their tier until &lt;code&gt;EXPIRATION&lt;/code&gt; fires.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 5: The Mobile Side
&lt;/h3&gt;

&lt;p&gt;On the React Native side, RevenueCat's SDK handles the App Store / Play Store payment sheets. You wrap it in a hook:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;useSubscription&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nx"&gt;SubscriptionState&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;offerings&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setOfferings&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;customerInfo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setCustomerInfo&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// Derived state from entitlements&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;isPro&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;customerInfo&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;entitlements&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;active&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pro&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;undefined&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;isProPlus&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;customerInfo&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;entitlements&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;active&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pro_plus&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;undefined&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;purchasePackage&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;pkg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;customerInfo&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;Purchases&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;purchasePackage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pkg&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nf"&gt;setCustomerInfo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;customerInfo&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The subscription screen needs three states:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Web subscriber&lt;/strong&gt; - they subscribed via Stripe on the web. Show "Managed on Web" with a link to the dashboard.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Active IAP subscriber&lt;/strong&gt; - they subscribed through the app. Show "Manage in App Store/Play Store."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Free user&lt;/strong&gt; - show the paywall with Pro and Pro+ packages.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Getting state 1 right was the part I almost missed. If a user has a Pro tier in the backend but &lt;em&gt;no&lt;/em&gt; active RevenueCat entitlements, they're a web subscriber. Don't show them a paywall. Don't let them accidentally buy a duplicate subscription through Apple.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Security Gotchas
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Webhook Authentication
&lt;/h3&gt;

&lt;p&gt;RevenueCat lets you set an authorization token in their dashboard. Your webhook endpoint verifies it:&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="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;webhookAuthToken&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;webhookAuthToken&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="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;"SECURITY: webhook auth token not configured"&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="no"&gt;UNAUTHORIZED&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"Webhook processing unavailable"&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;Fail closed.&lt;/strong&gt; If the auth token isn't configured (deployment mistake, missing env var), reject everything. Don't silently accept unverified webhooks.&lt;/p&gt;

&lt;h3&gt;
  
  
  Return 200 for Unknown Events
&lt;/h3&gt;

&lt;p&gt;RevenueCat retries on non-200 responses. If you throw a 500 for an event type you don't handle, RevenueCat will retry it forever. Return 200 and log it:&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="k"&gt;default&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;info&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Unhandled event type: {}"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;eventType&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="c1"&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="na"&gt;build&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;The webhook auth token is a secret. I stored it in GitHub Secrets and injected it at deploy time through the workflow:&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="c1"&gt;# In the deployment workflow .env template&lt;/span&gt;
&lt;span class="s"&gt;REVENUECAT_WEBHOOK_AUTH_TOKEN=${{ secrets.REVENUECAT_WEBHOOK_AUTH_TOKEN }}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I initially had it in GitHub &lt;em&gt;variables&lt;/em&gt; (not secrets) because "it's just a webhook token, not a database password." Nope. Any token that gates financial transactions is a secret. Period.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd Do Differently
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Start with the webhook handler first.&lt;/strong&gt; I built the mobile paywall UI first, which meant I had a "Buy" button that worked but no backend to receive the purchase event. Webhook-first lets you test with RevenueCat's webhook tester before touching mobile code.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Use the same product ID naming convention across platforms.&lt;/strong&gt; I initially had different Android and iOS product IDs. The &lt;code&gt;determineTierFromProductId&lt;/code&gt; logic doesn't care about platform, so matching conventions saves headaches.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Test cancellation and expiration separately.&lt;/strong&gt; These are different events with different behaviors. My first implementation treated &lt;code&gt;CANCELLATION&lt;/code&gt; as &lt;code&gt;EXPIRATION&lt;/code&gt; and immediately downgraded users. They were not thrilled.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

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

&lt;p&gt;Two billing systems (Stripe for web, RevenueCat for mobile) writing to the same user record. The user doesn't know or care which one is active. They just see their subscription tier reflected consistently across web, iOS, and Android.&lt;/p&gt;

&lt;p&gt;Total implementation: ~250 lines of Java backend, ~120 lines of React Native hooks, ~350 lines of paywall UI. Plus 580 lines of tests, because the one thing worse than two billing systems is two &lt;em&gt;untested&lt;/em&gt; billing systems.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Have you dealt with the Apple IAP mandate on an existing SaaS?&lt;/strong&gt; I'd love to hear how you handled the dual-billing complexity. Drop your war stories in the comments.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Building &lt;a href="https://jo4.io" rel="noopener noreferrer"&gt;jo4.io&lt;/a&gt; - a URL shortener with analytics for developers who'd rather not give Apple 30% but don't have a choice.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>ios</category>
      <category>mobile</category>
      <category>saas</category>
    </item>
    <item>
      <title>Why Our Android Build Was Signed with the Wrong Key (A Regex Cautionary Tale)</title>
      <dc:creator>Anand Rathnas</dc:creator>
      <pubDate>Fri, 22 May 2026 01:47:58 +0000</pubDate>
      <link>https://dev.to/anand_rathnas_d5b608cc3de/why-our-android-build-was-signed-with-the-wrong-key-a-regex-cautionary-tale-281g</link>
      <guid>https://dev.to/anand_rathnas_d5b608cc3de/why-our-android-build-was-signed-with-the-wrong-key-a-regex-cautionary-tale-281g</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;This article was originally published on &lt;a href="https://jo4.io/blog/expo-prebuild-android-signing-regex-bug/" rel="noopener noreferrer"&gt;Jo4 Blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

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

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

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

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

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

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

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

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

&lt;p&gt;After &lt;code&gt;release.sh&lt;/code&gt; ran, we expected &lt;code&gt;build.gradle&lt;/code&gt; to look like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight groovy"&gt;&lt;code&gt;&lt;span class="n"&gt;buildTypes&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;debug&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;signingConfig&lt;/span&gt; &lt;span class="n"&gt;signingConfigs&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;debug&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;release&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;signingConfig&lt;/span&gt; &lt;span class="n"&gt;signingConfigs&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;release&lt;/span&gt;  &lt;span class="c1"&gt;// patched&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Instead, it looked like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight groovy"&gt;&lt;code&gt;&lt;span class="n"&gt;buildTypes&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;debug&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;signingConfig&lt;/span&gt; &lt;span class="n"&gt;signingConfigs&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;release&lt;/span&gt;  &lt;span class="c1"&gt;// WRONG&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;release&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;signingConfig&lt;/span&gt; &lt;span class="n"&gt;signingConfigs&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;debug&lt;/span&gt;    &lt;span class="c1"&gt;// WRONG&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The configs were &lt;strong&gt;swapped&lt;/strong&gt;. Debug was using the release keystore. Release was using the debug keystore. The build succeeded (both keystores are valid), but the release AAB was signed with the debug key.&lt;/p&gt;

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

&lt;p&gt;Here's the regex our script used:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;patched&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;buildGradle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="sr"&gt;/release &lt;/span&gt;&lt;span class="se"&gt;\{[\s\S]&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt;&lt;span class="se"&gt;?&lt;/span&gt;&lt;span class="sr"&gt;signingConfig signingConfigs&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="sr"&gt;debug/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;match&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;match&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;signingConfig signingConfigs.debug&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;signingConfig signingConfigs.release&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The intent: find &lt;code&gt;release {&lt;/code&gt; followed (lazily) by &lt;code&gt;signingConfig signingConfigs.debug&lt;/code&gt;, then replace that &lt;code&gt;debug&lt;/code&gt; with &lt;code&gt;release&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The problem: &lt;code&gt;release {&lt;/code&gt; appears &lt;strong&gt;twice&lt;/strong&gt; in the generated &lt;code&gt;build.gradle&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight groovy"&gt;&lt;code&gt;&lt;span class="n"&gt;signingConfigs&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;debug&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// ...&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;release&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;        &lt;span class="c1"&gt;// &amp;lt;-- FIRST occurrence of "release {"&lt;/span&gt;
        &lt;span class="n"&gt;storeFile&lt;/span&gt; &lt;span class="nf"&gt;file&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"release.keystore"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;storePassword&lt;/span&gt; &lt;span class="s2"&gt;"..."&lt;/span&gt;
        &lt;span class="n"&gt;keyAlias&lt;/span&gt; &lt;span class="s2"&gt;"..."&lt;/span&gt;
        &lt;span class="n"&gt;keyPassword&lt;/span&gt; &lt;span class="s2"&gt;"..."&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="n"&gt;buildTypes&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;debug&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;signingConfig&lt;/span&gt; &lt;span class="n"&gt;signingConfigs&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;debug&lt;/span&gt;   &lt;span class="c1"&gt;// &amp;lt;-- target line&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;release&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;        &lt;span class="c1"&gt;// &amp;lt;-- SECOND occurrence of "release {"&lt;/span&gt;
        &lt;span class="n"&gt;signingConfig&lt;/span&gt; &lt;span class="n"&gt;signingConfigs&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;debug&lt;/span&gt;   &lt;span class="c1"&gt;// &amp;lt;-- intended target&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The lazy quantifier &lt;code&gt;[\s\S]*?&lt;/code&gt; matched the &lt;strong&gt;first&lt;/strong&gt; &lt;code&gt;release {&lt;/code&gt; (inside &lt;code&gt;signingConfigs&lt;/code&gt;), then expanded minimally until it found &lt;code&gt;signingConfig signingConfigs.debug&lt;/code&gt;. The first &lt;code&gt;signingConfig signingConfigs.debug&lt;/code&gt; it encountered was inside the &lt;strong&gt;debug buildType&lt;/strong&gt;. So the regex matched from &lt;code&gt;signingConfigs.release {&lt;/code&gt; all the way down to the debug buildType's signing config -- and replaced it.&lt;/p&gt;

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

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

&lt;p&gt;Anchor the regex to the &lt;code&gt;buildTypes&lt;/code&gt; section:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;patched&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;buildGradle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;buildTypes&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt;&lt;span class="se"&gt;\{[\s\S]&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt;&lt;span class="se"&gt;?&lt;/span&gt;&lt;span class="sr"&gt;release&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt;&lt;span class="se"&gt;\{[\s\S]&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt;&lt;span class="se"&gt;?)&lt;/span&gt;&lt;span class="sr"&gt;signingConfig signingConfigs&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="sr"&gt;debug/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;$1signingConfig signingConfigs.release&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;By requiring &lt;code&gt;buildTypes {&lt;/code&gt; before &lt;code&gt;release {&lt;/code&gt;, the regex skips the &lt;code&gt;signingConfigs.release&lt;/code&gt; block entirely. The capture group grabs everything from &lt;code&gt;buildTypes {&lt;/code&gt; through &lt;code&gt;release {&lt;/code&gt; and any content before the signing config line. Then we replace just the &lt;code&gt;signingConfig&lt;/code&gt; reference while preserving the surrounding structure.&lt;/p&gt;

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

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

&lt;/div&gt;



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

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

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

&lt;p&gt;Our fix: auto-generate &lt;code&gt;versionCode&lt;/code&gt; from epoch minutes in the release script:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;VERSION_CODE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$((&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%s&lt;span class="si"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="m"&gt;60&lt;/span&gt;&lt;span class="k"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;The Node.js injection then patches this into &lt;code&gt;build.gradle&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;buildGradle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;buildGradle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="sr"&gt;/versionCode &lt;/span&gt;&lt;span class="se"&gt;\d&lt;/span&gt;&lt;span class="sr"&gt;+/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="s2"&gt;`versionCode &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;VERSION_CODE&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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

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

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

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

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

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

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

&lt;p&gt;A one-line check would have saved hours:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Verify the AAB is signed with the correct key&lt;/span&gt;
jarsigner &lt;span class="nt"&gt;-verify&lt;/span&gt; &lt;span class="nt"&gt;-verbose&lt;/span&gt; &lt;span class="nt"&gt;-certs&lt;/span&gt; app-release.aab | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s2"&gt;"SHA1:"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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

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

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




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

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

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

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

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

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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

    &lt;span class="c1"&gt;// Cloudflare API allows max 30 URLs per purge request&lt;/span&gt;
    &lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;batches&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;partition&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;urls&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;batch&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;batches&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;purgeUrlBatch&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;batch&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;purgeUrlBatch&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;urls&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;endpoint&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;CLOUDFLARE_API_BASE&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;"/zones/"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;zoneId&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;"/purge_cache"&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;HttpHeaders&lt;/span&gt; &lt;span class="n"&gt;headers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;createHeaders&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
        &lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Object&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"files"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;urls&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="nc"&gt;HttpEntity&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Object&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;HttpEntity&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&amp;gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

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

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

&lt;/div&gt;



&lt;p&gt;Two design decisions here:&lt;/p&gt;

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

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

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

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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;addCacheableUrls&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;urls&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;shortUrl&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;customDomain&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// UI page (HTML with OG tags, served via Cloudflare CDN)&lt;/span&gt;
    &lt;span class="n"&gt;urls&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;add&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;uiHost&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;"/a/"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;shortUrl&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="c1"&gt;// API endpoint (JSON, also cached by Cloudflare)&lt;/span&gt;
    &lt;span class="n"&gt;urls&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;add&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;apiHost&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;"/api/v1/public/a/"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;shortUrl&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="c1"&gt;// Custom domain URL (if configured)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;StringUtils&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isNotBlank&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;customDomain&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;urls&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;add&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"https://"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;customDomain&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;"/a/"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;shortUrl&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For updates where the short URL or custom domain itself changed, we purge both old and new URLs:&lt;br&gt;
&lt;/p&gt;

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

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

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(!&lt;/span&gt;&lt;span class="n"&gt;urlsToPurge&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isEmpty&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;cloudflareService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;purgeUrls&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;urlsToPurge&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 3: Wire Into Every Mutation Path
&lt;/h3&gt;

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

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

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

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

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

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

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

    &lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;urlsToPurge&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ArrayList&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&amp;gt;();&lt;/span&gt;
    &lt;span class="n"&gt;urlsToPurge&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;add&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;uiHost&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;"/a/"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getShortUrl&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
    &lt;span class="n"&gt;urlsToPurge&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;add&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;apiHost&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;"/api/v1/public/a/"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getShortUrl&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;StringUtils&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isNotBlank&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getCustomDomain&lt;/span&gt;&lt;span class="o"&gt;()))&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;urlsToPurge&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;add&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"https://"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getCustomDomain&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;"/a/"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getShortUrl&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;cloudflareService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;purgeUrls&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;urlsToPurge&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One method. Both cache layers. Called from every admin mutation.&lt;/p&gt;

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

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

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

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

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

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

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

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

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

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




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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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




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

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

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

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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

&lt;/div&gt;



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

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

&lt;p&gt;"Easy," I thought. "Just resize the phone screenshots for iPad."&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# DON'T DO THIS&lt;/span&gt;
sips &lt;span class="nt"&gt;-z&lt;/span&gt; 2732 2048 phone-screenshot.png &lt;span class="nt"&gt;--out&lt;/span&gt; ipad-screenshot.png
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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

&lt;p&gt;Boot an actual iPad simulator and take native screenshots:&lt;br&gt;
&lt;/p&gt;

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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



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

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

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

&lt;/div&gt;



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



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

&lt;/div&gt;



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



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

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

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

&lt;/div&gt;



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

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

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

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




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

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

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

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

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

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

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

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

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

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

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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Before: Ad on every utility page&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;UtilityPageLayout&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;children&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;main&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;AdSlot&lt;/span&gt; &lt;span class="na"&gt;position&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"top"&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;children&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;AdSlot&lt;/span&gt; &lt;span class="na"&gt;position&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"bottom"&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;main&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every tool page now had ads at the top and bottom.&lt;/p&gt;

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// After: No ads in UtilityPageLayout&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;UtilityPageLayout&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;children&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;main&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;children&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;main&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And kept them only on blog posts:&lt;br&gt;
&lt;/p&gt;

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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




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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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




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

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

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

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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

&lt;/div&gt;



&lt;p&gt;The app.config.js was missing one line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;ios&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// ... other config&lt;/span&gt;
  &lt;span class="nl"&gt;usesAppleSignIn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;  &lt;span class="c1"&gt;// This one. This is the line.&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;ios&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;bundleIdentifier&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;your.bundle.id&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;usesAppleSignIn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;entitlements&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;com.apple.security.application-groups&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;group.your.bundle.id&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Auth0
&lt;/h3&gt;

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

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

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

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

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




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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

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




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

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

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

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

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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

&lt;/div&gt;



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

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

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

&lt;/div&gt;



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

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

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

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

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

&lt;/div&gt;



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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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




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

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

</description>
      <category>java</category>
      <category>springboot</category>
      <category>security</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
