<?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: MarcoBlch</title>
    <description>The latest articles on DEV Community by MarcoBlch (@marcoblch).</description>
    <link>https://dev.to/marcoblch</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.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4112464%2F71d50e82-606f-4c4a-98bc-5443c44d14ac.jpg</url>
      <title>DEV Community: MarcoBlch</title>
      <link>https://dev.to/marcoblch</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/marcoblch"/>
    <language>en</language>
    <item>
      <title>My Invite Gate Held. The Database Had a Side Door.</title>
      <dc:creator>MarcoBlch</dc:creator>
      <pubDate>Wed, 04 Nov 2026 18:54:26 +0000</pubDate>
      <link>https://dev.to/marcoblch/my-invite-gate-held-the-database-had-a-side-door-448b</link>
      <guid>https://dev.to/marcoblch/my-invite-gate-held-the-database-had-a-side-door-448b</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Originally published on &lt;a href="https://meridianbuild.dev/blog/my-invite-gate-held-the-database-had-a-side-door/" rel="noopener noreferrer"&gt;meridianbuild.dev&lt;/a&gt;, my build log for &lt;a href="https://fastpass.email" rel="noopener noreferrer"&gt;FastPass&lt;/a&gt;, a paid inbox where people pay to reach you and get refunded if you don't reply.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A few weeks ago I wrote about how &lt;a href="https://meridianbuild.dev/blog/my-invite-only-gate-lived-in-the-browser/" rel="noopener noreferrer"&gt;my invite-only gate lived in the browser&lt;/a&gt; on &lt;a href="https://fastpass.email" rel="noopener noreferrer"&gt;FastPass&lt;/a&gt;, and how I moved it to the server. The comments were kind and sharp. One of them basically asked: you locked the front door, but did you check the back door?&lt;/p&gt;

&lt;p&gt;I hadn't, not properly. The gate lived in Edge Functions, and it was tested there. But Supabase also exposes the database itself to the browser through PostgREST, and what the browser can do there is decided by Row Level Security (RLS). If RLS let a client write a value that the gate trusts, the gate is decoration.&lt;/p&gt;

&lt;p&gt;On October 27 I checked. It did. This post is about how I found out without touching production data, and what I'd do the same way on any Supabase project.&lt;/p&gt;

&lt;p&gt;I'll describe the problems at the level of their class. They're fixed, but there's no reason to publish a recipe.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trap: RLS is about rows, not columns
&lt;/h2&gt;

&lt;p&gt;This is the part I wish someone had told me on day one.&lt;/p&gt;

&lt;p&gt;An RLS policy like "a user can update their own profile" answers one question: which rows? It says nothing about which columns. Once a user is allowed to update their row, they can update every column in it, from the browser, with the public anon key and their own session.&lt;/p&gt;

&lt;p&gt;And Supabase's default privileges grant &lt;code&gt;anon&lt;/code&gt; and &lt;code&gt;authenticated&lt;/code&gt; broad rights on tables in &lt;code&gt;public&lt;/code&gt;. So unless you restrict it, "update your own row" means "update anything in your own row", including the fields your server logic trusts: a status, a flag, a role, a counter.&lt;/p&gt;

&lt;p&gt;That was my side door. The invite gate checked something that the database let the user change.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: a copy of production, without production
&lt;/h2&gt;

&lt;p&gt;I wanted to attack the real schema, not the one in my migrations folder, because those two drift. But I didn't want to point probes at production, and I didn't have (or want) the database password on the VPS.&lt;/p&gt;

&lt;p&gt;So I rebuilt the schema from read-only catalog queries through the Supabase Management API: tables, columns, functions, triggers, every policy and every grant. No rows, no user data. Then I loaded it into a throwaway Postgres in Docker, on a tmpfs, with small stubs for &lt;code&gt;auth.uid()&lt;/code&gt; and &lt;code&gt;auth.jwt()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;How do you know the copy is faithful? I took an md5 fingerprint of the policies (a hash over &lt;code&gt;pg_policies&lt;/code&gt;, ordered) on both sides. Same hash, same rules. That one number is what made me trust everything that came after.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: probes that impersonate a client
&lt;/h2&gt;

&lt;p&gt;Each probe pretends to be one kind of caller and tries one thing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;begin&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="n"&gt;savepoint&lt;/span&gt; &lt;span class="n"&gt;probe&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;set&lt;/span&gt; &lt;span class="k"&gt;local&lt;/span&gt; &lt;span class="k"&gt;role&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;set_config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'request.jwt.claims'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="s1"&gt;'{"sub":"&amp;lt;user-a-uuid&amp;gt;","role":"authenticated"}'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;update&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;some_table&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt; &lt;span class="n"&gt;some_column&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'x'&lt;/span&gt; &lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'&amp;lt;row-of-user-b&amp;gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;rollback&lt;/span&gt; &lt;span class="k"&gt;to&lt;/span&gt; &lt;span class="n"&gt;savepoint&lt;/span&gt; &lt;span class="n"&gt;probe&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;set local role&lt;/code&gt; plus fake JWT claims is exactly what PostgREST does for a real request, so the policies see the same thing they'd see in production. Each probe runs inside a savepoint that is always rolled back, so probes can't affect each other.&lt;/p&gt;

&lt;p&gt;I wrote 70 of them, for three callers: anonymous, a logged-in user, and &lt;code&gt;service_role&lt;/code&gt;. They covered both directions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Must fail&lt;/strong&gt;: reading someone else's data, writing a protected column, calling a function you shouldn't.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Must work&lt;/strong&gt;: everything the real app does. A security fix that breaks signup is not a fix.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The first run listed problems in four classes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Users could change sensitive columns on their own profile, because nothing restricted columns.&lt;/li&gt;
&lt;li&gt;Some policies meant for the server only were also open to client roles.&lt;/li&gt;
&lt;li&gt;Referral codes could be issued to accounts that shouldn't get them.&lt;/li&gt;
&lt;li&gt;Statistics views ran with their owner's rights instead of the caller's, because they were created without &lt;code&gt;security_invoker&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Step 3: the fix, with a proven way back
&lt;/h2&gt;

&lt;p&gt;The migration does what you'd expect: column-level &lt;code&gt;GRANT UPDATE (col1, col2)&lt;/code&gt; instead of table-wide rights, server-only policies restricted to &lt;code&gt;service_role&lt;/code&gt;, views switched to &lt;code&gt;security_invoker = true&lt;/code&gt;, and functions that look up trusted state in the database instead of in what the client sends.&lt;/p&gt;

&lt;p&gt;Before pushing it, I wrote the rollback and tested it on the bench: apply up, apply down, and check that the policy fingerprint is back to the production one. Only then did it go to production, the same day. After the migration, all 70 probes passed, and production REST traffic showed zero 401 or 403 responses, so the real app wasn't tripping over the new rules.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix that would have broken every email signup
&lt;/h2&gt;

&lt;p&gt;One fix deserves its own paragraph, because the obvious version was wrong.&lt;/p&gt;

&lt;p&gt;The function that redeems an invite code should only redeem it for your own account. The obvious rule is "the user in the JWT must be the account being redeemed for". But with email signups, the redeem runs right after the account is created, before the email is confirmed, so there's no session yet. The obvious rule would have rejected every email signup.&lt;/p&gt;

&lt;p&gt;The real rule has two branches. With a session, the JWT user must match. Without one, the account must have been created minutes ago and not be confirmed yet. Writing the "must work" probes first is what showed me that.&lt;/p&gt;

&lt;p&gt;I also added 27 regression tests for the gate in CI (Deno), including a replay of the OAuth bypass I fixed earlier, run against an in-memory fake of Supabase so no real Google account is needed. To check that the tests test something, I removed the ownership check from the code and watched them fail.&lt;/p&gt;

&lt;h2&gt;
  
  
  Traps on the way
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;safeupdate&lt;/code&gt; can make a probe fail for the wrong reason.&lt;/strong&gt; Supabase uses &lt;code&gt;pg-safeupdate&lt;/code&gt;, which rejects an &lt;code&gt;UPDATE&lt;/code&gt; without a &lt;code&gt;WHERE&lt;/code&gt;. A "must fail" probe that fails because of it proves nothing about RLS. Always give probes a real filter.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;My bench ignored column grants at first&lt;/strong&gt; and reported false failures. If you rebuild a schema, rebuild the privileges too, not just the policies.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;EXPLAIN&lt;/code&gt; checks privileges without running the query.&lt;/strong&gt; That's a safe way to confirm in production that a role can't touch a column: you get "permission denied" or a plan, and nothing is executed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;supabase db push&lt;/code&gt; wants the database password.&lt;/strong&gt; I applied the migration through the Management API instead.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The old logs endpoint I'd planned to use returned 410 Gone.&lt;/strong&gt; I verified with gateway logs instead.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Same bench, another project: the NULL in the lobby
&lt;/h2&gt;

&lt;p&gt;I ran the same method the same day on &lt;a href="https://marcoblch.github.io/Kidcapital" rel="noopener noreferrer"&gt;KidCapital&lt;/a&gt;, my money game for kids: around 45 probes, three callers (anonymous, a player targeting another player, a member of a game). The findings went from 18 to 5, and I accepted the 5 that remain. The normal journey (solo game, leaderboard, multiplayer create, join, play, finish, data deletion) passed 18 out of 18 before and after.&lt;/p&gt;

&lt;p&gt;Two bugs from that project are worth stealing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A privacy fix that broke game creation.&lt;/strong&gt; A few days earlier, I'd made multiplayer games visible only to their players, through a membership function. Right after, nobody could create a game. The client inserts a game and asks for the row back (&lt;code&gt;INSERT ... RETURNING&lt;/code&gt;), and the &lt;code&gt;RETURNING&lt;/code&gt; part is checked against the select policy. But the membership function can't see the row that's still being inserted, so the check fails and the whole insert is refused. The fix: let the creator see their own game directly, without the function. Fixed the same day, with an end-to-end test in production passing 8 out of 8.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A guard that said nothing.&lt;/strong&gt; A trigger compared the current user with the player whose turn it is. In the lobby, before the game starts, that player is &lt;code&gt;NULL&lt;/code&gt;. In SQL, &lt;code&gt;x &amp;lt;&amp;gt; NULL&lt;/code&gt; is not true or false. It's &lt;code&gt;NULL&lt;/code&gt;, and an &lt;code&gt;IF&lt;/code&gt; treats it as "don't fire". So the guard was silent exactly when nobody was playing yet. The fix is &lt;code&gt;IS DISTINCT FROM&lt;/code&gt;, which treats &lt;code&gt;NULL&lt;/code&gt; as a value. The bench caught it because it tests the rejection cases, not only the happy path.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell any Supabase builder
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;RLS picks rows. Use column-level grants for the columns your server trusts.&lt;/li&gt;
&lt;li&gt;Never trust &lt;code&gt;user_metadata&lt;/code&gt; for roles or permissions. The user can edit it. Use &lt;code&gt;app_metadata&lt;/code&gt; (set with &lt;code&gt;service_role&lt;/code&gt;) or your own table.&lt;/li&gt;
&lt;li&gt;Audit a copy, not production, and prove the copy is faithful with a fingerprint of &lt;code&gt;pg_policies&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Write "must work" probes next to "must fail" ones. They're what stop a security fix from breaking signup.&lt;/li&gt;
&lt;li&gt;Test your rollback before you need it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The person who asked about the back door was right. The best thing about building in public is that people ask the question you were avoiding.&lt;/p&gt;

</description>
      <category>supabase</category>
      <category>postgres</category>
      <category>security</category>
      <category>webdev</category>
    </item>
    <item>
      <title>My Black Friday Dry Run Would Have Billed 3,334 Users in the Wrong Currency</title>
      <dc:creator>MarcoBlch</dc:creator>
      <pubDate>Tue, 03 Nov 2026 18:43:09 +0000</pubDate>
      <link>https://dev.to/marcoblch/my-black-friday-dry-run-would-have-billed-3334-users-in-the-wrong-currency-809</link>
      <guid>https://dev.to/marcoblch/my-black-friday-dry-run-would-have-billed-3334-users-in-the-wrong-currency-809</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Originally published on &lt;a href="https://meridianbuild.dev/blog/black-friday-dry-run-americans-billed-in-euros/" rel="noopener noreferrer"&gt;meridianbuild.dev&lt;/a&gt;, my build log for &lt;a href="https://outfitmaker.ai" rel="noopener noreferrer"&gt;OutfitMaker&lt;/a&gt;, an AI outfit planner that works from the clothes you already own.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;On October 30 I set up the Black Friday offer for &lt;a href="https://outfitmaker.ai" rel="noopener noreferrer"&gt;OutfitMaker&lt;/a&gt;: Premium annual for 39 € / $39 / £35 for the first year, instead of 59.99 / 59.99 / 49.99. It runs from November 25 to November 30, with two emails, a banner and a one-click checkout link.&lt;/p&gt;

&lt;p&gt;Before writing any copy, I ran the email audience as a dry run. Not "send to myself". I asked the code: for every user who would receive this email, which price would they see and which currency would Stripe charge?&lt;/p&gt;

&lt;p&gt;The answer was that 3,334 of the 3,691 accounts would be priced in euros. Most of my users are in the US.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the euros came from
&lt;/h2&gt;

&lt;p&gt;OutfitMaker sells in EUR, USD and GBP. On the website, the currency comes from the request: the visitor's location and language. That part was fine and had been fine since early October.&lt;/p&gt;

&lt;p&gt;Outside a web request, there's no visitor. An email, a background job or a signed checkout link has to decide from the database. I added a &lt;code&gt;currency&lt;/code&gt; column to &lt;code&gt;users&lt;/code&gt; on October 12, set every time someone visits the site. Accounts created before that, or people who hadn't come back since, had an empty column.&lt;/p&gt;

&lt;p&gt;And empty meant the default, which is EUR. That's 3,334 people, so 90 % of the list.&lt;/p&gt;

&lt;p&gt;In practice, an American would have opened an email saying "39 €", clicked the link and landed on a Stripe Checkout in euros. Some might have paid anyway. I suspect most would have closed the tab. Nothing would have crashed, and no error would have shown up in a log. It would just have been a worse Black Friday, and I would probably have blamed the offer.&lt;/p&gt;

&lt;h2&gt;
  
  
  A currency from the time zone
&lt;/h2&gt;

&lt;p&gt;I didn't have a country for those accounts. But I had something almost as good: their time zone, which the app already stores to work out local times.&lt;/p&gt;

&lt;p&gt;A time zone isn't a country, but it's close enough to pick between three currencies:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;currency_for_time_zone&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;zone&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="n"&gt;zone&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;iana_zone&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;zone&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kp"&gt;nil&lt;/span&gt; &lt;span class="k"&gt;unless&lt;/span&gt; &lt;span class="n"&gt;zone&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;"GBP"&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="no"&gt;GBP_ZONES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;include?&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;zone&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;"EUR"&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;zone&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;start_with?&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Europe/"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="no"&gt;EURO_ZONES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;include?&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;zone&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="s2"&gt;"USD"&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;iana_zone&lt;/code&gt; does the tedious part. Rails stores friendly names like &lt;code&gt;"Eastern Time (US &amp;amp; Canada)"&lt;/code&gt;, so they get mapped back to IANA names (&lt;code&gt;America/New_York&lt;/code&gt;). Old aliases get normalized. And &lt;code&gt;UTC&lt;/code&gt;, &lt;code&gt;Etc/*&lt;/code&gt; and blank values return &lt;code&gt;nil&lt;/code&gt;, because they say nothing about where someone lives.&lt;/p&gt;

&lt;p&gt;When the time zone is useless, the language decides: English reads as an American account, which matches most of my traffic, and French, German, Spanish, Portuguese and Italian read as euros. Only when both are missing does the old default apply.&lt;/p&gt;

&lt;p&gt;The order of trust is now explicit, in one method on &lt;code&gt;User&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;effective_currency&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;currency&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="no"&gt;PriceBook&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;CURRENCIES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;include?&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="no"&gt;PriceBook&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;currency_for_time_zone&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;time_zone&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
    &lt;span class="no"&gt;PriceBook&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;currency_for_locale&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
    &lt;span class="no"&gt;PriceBook&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;DEFAULT&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;First the currency the person was actually shown on the site, then the time zone, then the language, then EUR. Every email, job and checkout link calls this, and none of them read the raw column any more.&lt;/p&gt;

&lt;h2&gt;
  
  
  Backfilling without overwriting anyone
&lt;/h2&gt;

&lt;p&gt;I also wanted the column filled in, so the site, the emails and the checkout agree from now on. The backfill is a one-off rake task, and it has three rules:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It only touches users whose currency is blank.&lt;/li&gt;
&lt;li&gt;The write checks again that the column is still blank. If someone visits the site between the read and the write, their real currency wins.&lt;/li&gt;
&lt;li&gt;It runs as a dry run by default and returns the counts per currency.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;dry_run: &lt;/span&gt;&lt;span class="kp"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="n"&gt;ids_by_currency&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Hash&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="n"&gt;scope&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:currency&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:time_zone&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:locale&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;find_each&lt;/span&gt; &lt;span class="k"&gt;do&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;ids_by_currency&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;effective_currency&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;id&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;

  &lt;span class="k"&gt;unless&lt;/span&gt; &lt;span class="n"&gt;dry_run&lt;/span&gt;
    &lt;span class="n"&gt;ids_by_currency&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;each&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ids&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;
      &lt;span class="n"&gt;ids&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;each_slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1_000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;slice&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;scope&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;id: &lt;/span&gt;&lt;span class="n"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;update_all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;currency: &lt;/span&gt;&lt;span class="n"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;

  &lt;span class="n"&gt;ids_by_currency&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;transform_values&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="ss"&gt;:size&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to_h&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;scope&lt;/code&gt; is &lt;code&gt;User.where(currency: [nil, ""])&lt;/code&gt;, and it's reapplied inside the &lt;code&gt;update_all&lt;/code&gt;. That's what makes it idempotent and safe to rerun.&lt;/p&gt;

&lt;p&gt;After the fix, the full user base splits into 2,192 USD, 1,317 EUR and 182 GBP. That looks like my traffic, which is the sanity check I care about.&lt;/p&gt;

&lt;h2&gt;
  
  
  One coupon, three exact amounts
&lt;/h2&gt;

&lt;p&gt;The second trap was on the Stripe side. My annual Premium price is a single Stripe Price in EUR with &lt;code&gt;currency_options&lt;/code&gt; for USD and GBP. I didn't want a separate Black Friday price, because then renewals would need special handling.&lt;/p&gt;

&lt;p&gt;So the discount is a coupon on the normal price. It has &lt;code&gt;duration: "once"&lt;/code&gt;, so only the first invoice is discounted and the renewal is the normal annual amount. And it uses an &lt;code&gt;amount_off&lt;/code&gt; per currency, through the coupon's own &lt;code&gt;currency_options&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;EUR: 59.99 minus 39.00 = 20.99 off (on the coupon itself)&lt;/li&gt;
&lt;li&gt;USD: 59.99 minus 39.00 = 20.99 off&lt;/li&gt;
&lt;li&gt;GBP: 49.99 minus 35.00 = 14.99 off&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A percentage coupon would have been simpler, but it would give £34.99 instead of £35 and 39.00 only by luck of rounding. With &lt;code&gt;amount_off&lt;/code&gt; per currency, the first invoice is exactly the number in the email.&lt;/p&gt;

&lt;p&gt;The code creates the coupon the first time it's needed and checks it on every use. If the amounts on Stripe ever drift from the ones in the code, because someone edited the coupon in the dashboard or because of a bad deploy, the checkout refuses to open. I'd rather lose one sale than charge a price I never announced.&lt;/p&gt;

&lt;h2&gt;
  
  
  The window is in New York time
&lt;/h2&gt;

&lt;p&gt;The last detail is the clock. The offer ends at the end of November 30 in New York, which is 05:59 on December 1 in Paris. The window is defined once, in &lt;code&gt;America/New_York&lt;/code&gt;, and the code switches itself on and off. There's nothing to deploy on the day.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;Before sending any email that shows a price, dry-run the audience and count which price each person would see. It takes a minute and it found 3,334 wrong prices.&lt;/li&gt;
&lt;li&gt;"Default currency" is a decision, not a fallback. If most of your users aren't in the default, the default is a bug waiting for an email.&lt;/li&gt;
&lt;li&gt;A time zone is a decent proxy for a country when you only need to choose between a few currencies. Treat &lt;code&gt;UTC&lt;/code&gt; and blanks as unknown, not as a location.&lt;/li&gt;
&lt;li&gt;Backfills should only fill blanks, check again at write time, and dry-run by default.&lt;/li&gt;
&lt;li&gt;For a discount in several currencies, use &lt;code&gt;amount_off&lt;/code&gt; per currency in &lt;code&gt;currency_options&lt;/code&gt; on a &lt;code&gt;once&lt;/code&gt; coupon, and refuse the checkout if the coupon drifts.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The offer starts November 25. At least now everyone will see it in their own money.&lt;/p&gt;

</description>
      <category>rails</category>
      <category>stripe</category>
      <category>saas</category>
      <category>startup</category>
    </item>
    <item>
      <title>93 Paid Clicks, Zero Signups: My Google Button Doesn't Work Inside TikTok</title>
      <dc:creator>MarcoBlch</dc:creator>
      <pubDate>Mon, 02 Nov 2026 07:56:03 +0000</pubDate>
      <link>https://dev.to/marcoblch/93-paid-clicks-zero-signups-my-google-button-doesnt-work-inside-tiktok-4i48</link>
      <guid>https://dev.to/marcoblch/93-paid-clicks-zero-signups-my-google-button-doesnt-work-inside-tiktok-4i48</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Originally published on &lt;a href="https://meridianbuild.dev/blog/93-paid-clicks-zero-signups-google-button-tiktok/" rel="noopener noreferrer"&gt;meridianbuild.dev&lt;/a&gt;, my build log for &lt;a href="https://outfitmaker.ai" rel="noopener noreferrer"&gt;OutfitMaker&lt;/a&gt;, an AI outfit planner that works from the clothes you already own.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;On October 30 I looked at the results of a small TikTok ad for &lt;a href="https://outfitmaker.ai" rel="noopener noreferrer"&gt;OutfitMaker&lt;/a&gt;. It had cost 21 € and brought 93 visits, so about 0.23 € per visit. 73 % of those visitors were in the US and 97 % were women, which is the audience I asked for.&lt;/p&gt;

&lt;p&gt;Signups from those 93 visits: zero.&lt;/p&gt;

&lt;p&gt;That would be a normal result for a bad ad. But over the same 7 days, the site as a whole turned 5,252 visits into 1,042 signups. That's 19.8 %, roughly one visitor in five. At that rate, 93 visitors should give somewhere around 18 signups. Zero is not bad luck. Something was broken, and only for those visitors.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the TikTok visitors had in common
&lt;/h2&gt;

&lt;p&gt;The ad audience was not perfect. The age targeting didn't hold: 63 % of the visitors were 18 to 24, which is my weakest age group for payments. But that age group signs up fine. It just doesn't pay. Age could explain zero sales. It could not explain zero accounts.&lt;/p&gt;

&lt;p&gt;The thing every one of those 93 people shared was the browser. When you tap a link in a TikTok ad, the page doesn't open in Safari or Chrome. It opens inside TikTok, in an embedded webview. Instagram and Facebook do the same.&lt;/p&gt;

&lt;p&gt;And the first button on my sign-up page was "Continue with Google".&lt;/p&gt;

&lt;h2&gt;
  
  
  Google says no to webviews
&lt;/h2&gt;

&lt;p&gt;Google's sign-in refuses to run inside embedded webviews. If you start a Google OAuth flow from one, you don't get the account picker. You get an error page: &lt;code&gt;Error 403: disallowed_useragent&lt;/code&gt;. Google's reason is security: an app that hosts a webview can see everything typed into it, including a password.&lt;/p&gt;

&lt;p&gt;So from inside TikTok, my funnel was:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;See the ad, tap it.&lt;/li&gt;
&lt;li&gt;Land on a page whose biggest button is Google.&lt;/li&gt;
&lt;li&gt;Tap it, get a Google error page in English talking about user agents.&lt;/li&gt;
&lt;li&gt;Leave.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The email form was right below the button, and it works fine in a webview. But a person who just hit an error page doesn't scroll down to try plan B. They close the page and go back to their feed.&lt;/p&gt;

&lt;p&gt;I can't prove that this was the cause for all 93 visits. I didn't have tracking on in-app sign-up views at the time, which is exactly the problem I fixed next. But it's the one thing that was different for those visitors, it was the first thing they'd tap, and it fails 100 % of the time in that browser. I shipped the fix the same day.&lt;/p&gt;

&lt;h2&gt;
  
  
  Detecting the in-app browser
&lt;/h2&gt;

&lt;p&gt;There's no API for "am I in a webview". The only signal is the User-Agent, and each app adds its own token. Here is the module, more or less as it is in production:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;module&lt;/span&gt; &lt;span class="nn"&gt;InAppBrowser&lt;/span&gt;
  &lt;span class="no"&gt;RULES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;:tiktok&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sr"&gt;/musical_ly|BytedanceWebview|TikTok/&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;:instagram&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sr"&gt;/Instagram/&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;:facebook&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sr"&gt;/FBAN|FBAV|FB_IAB/&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;:snapchat&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sr"&gt;/Snapchat/&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;:linkedin&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sr"&gt;/LinkedInApp/&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;:other_webview&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sr"&gt;/; wv\)/&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;freeze&lt;/span&gt;

  &lt;span class="kp"&gt;module_function&lt;/span&gt;

  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;detect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_agent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kp"&gt;nil&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;user_agent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;blank?&lt;/span&gt;

    &lt;span class="no"&gt;RULES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;each&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pattern&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;app&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;pattern&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match?&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_agent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="kp"&gt;nil&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The order matters. On Android, the Instagram and Facebook User-Agents also contain &lt;code&gt;; wv)&lt;/code&gt;, the generic Android WebView marker. If the generic rule came first, every Instagram visitor would be labelled "other webview". It goes last as a catch-all.&lt;/p&gt;

&lt;p&gt;User-Agent sniffing is fragile, and I know it. Apps change their strings. But the failure mode here is gentle. If detection misses a webview, the visitor sees the old page, which is what everyone saw before. If it wrongly flags a real browser, that visitor loses the Google button but still has a working email form. Neither mistake locks anyone out.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the page does now
&lt;/h2&gt;

&lt;p&gt;A concern exposes the result to the Devise sign-up and sign-in views:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;module&lt;/span&gt; &lt;span class="nn"&gt;InAppBrowserDetection&lt;/span&gt;
  &lt;span class="kp"&gt;extend&lt;/span&gt; &lt;span class="no"&gt;ActiveSupport&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Concern&lt;/span&gt;

  &lt;span class="n"&gt;included&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="n"&gt;helper_method&lt;/span&gt; &lt;span class="ss"&gt;:in_app_browser&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:in_app_android?&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;

  &lt;span class="kp"&gt;private&lt;/span&gt;

  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;in_app_browser&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="vi"&gt;@in_app_browser&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;defined?&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="vi"&gt;@in_app_browser&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="vi"&gt;@in_app_browser&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;InAppBrowser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;detect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;user_agent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Inside TikTok, Instagram or Facebook, the page changes in three ways:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Google is hidden.&lt;/strong&gt; A button that can only produce an error page is worse than no button.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Email goes first.&lt;/strong&gt; The form that works is the first thing you see.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A short tip explains how to get Google back&lt;/strong&gt;: open the page in your real browser. On Android, there's a one-tap link that uses a Chrome intent URL (&lt;code&gt;intent://...;package=com.android.chrome;end&lt;/code&gt;) to reopen the same page in Chrome. iOS has no reliable equivalent, so iOS visitors only get the text.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The tip can be dismissed, and the dismissal is remembered, so it doesn't nag.&lt;/p&gt;

&lt;h2&gt;
  
  
  Measuring it this time
&lt;/h2&gt;

&lt;p&gt;The other half of the fix is that I can now see this funnel. Each sign-up page view inside a webview fires an &lt;code&gt;in_app_signup_view&lt;/code&gt; event with the app name. Each signup made inside a webview carries an &lt;code&gt;in_app&lt;/code&gt; property on the existing &lt;code&gt;Signup&lt;/code&gt; event. Those two numbers give me a conversion rate for each app, which is what I was missing on October 30. I only had a total of zero.&lt;/p&gt;

&lt;p&gt;That's the lesson I'd keep even without the webview part: &lt;strong&gt;when you pay for traffic, check that the page works in the browser that traffic actually uses.&lt;/strong&gt; I tested my sign-up page in Chrome, Safari and Firefox, on desktop and on a phone. I never opened it from inside the app that I was paying to send people from.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;If your traffic comes from TikTok, Instagram or Facebook, your page opens in their webview. Test it there, by tapping your own link inside the app.&lt;/li&gt;
&lt;li&gt;Google OAuth doesn't work in those webviews. If Google is your first or only sign-in option, those visitors hit an error page.&lt;/li&gt;
&lt;li&gt;Detect the webview from the User-Agent, put the generic Android &lt;code&gt;; wv)&lt;/code&gt; rule last, and make sure a wrong guess fails gently.&lt;/li&gt;
&lt;li&gt;Measure the in-app funnel separately. A blended conversion rate of 19.8 % hid a channel that was converting at zero.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;21 € is cheap tuition. The expensive version of this story is the one where you scale the ad first.&lt;/p&gt;

</description>
      <category>rails</category>
      <category>webdev</category>
      <category>startup</category>
      <category>growth</category>
    </item>
    <item>
      <title>A QA Checklist for a Recurring AI Character</title>
      <dc:creator>MarcoBlch</dc:creator>
      <pubDate>Tue, 27 Oct 2026 17:09:17 +0000</pubDate>
      <link>https://dev.to/marcoblch/a-qa-checklist-for-a-recurring-ai-character-5423</link>
      <guid>https://dev.to/marcoblch/a-qa-checklist-for-a-recurring-ai-character-5423</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Originally published on &lt;a href="https://meridianbuild.dev/blog/qa-checklist-for-a-recurring-ai-character/" rel="noopener noreferrer"&gt;meridianbuild.dev&lt;/a&gt;, my build log for &lt;a href="https://outfitmaker.ai" rel="noopener noreferrer"&gt;OutfitMaker&lt;/a&gt;, an AI outfit planner that works from the clothes you already own.&lt;br&gt;
&lt;a href="https://outfitmaker.ai" rel="noopener noreferrer"&gt;OutfitMaker&lt;/a&gt; has a recurring character on TikTok and Instagram. Her name is Dot. She is a stylist with short cropped grey hair, gold hoops, a black fine knit turtleneck and a small terracotta brooch, and she works in a London townhouse atelier with a brass clothing rail and a velvet armchair. Every episode, a client comes in with a closet problem and leaves with three outfits.&lt;br&gt;
Dot does not exist. Every image is generated with Gemini's image model, from reference pictures and a prompt. That makes her cheap to produce and very easy to break.&lt;br&gt;
This post is the checklist I run before anything with Dot in it goes out, and what today's batch of nine images taught me about where image models drift.&lt;/p&gt;
&lt;h2&gt;
  
  
  How an image is made
&lt;/h2&gt;

&lt;p&gt;Each shot is one request with three reference photos and a prompt:&lt;br&gt;
&lt;/p&gt;
&lt;/blockquote&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="n"&gt;dot&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="ss"&gt;text: &lt;/span&gt;&lt;span class="s2"&gt;"PHOTO A: 'Dot', our recurring stylist (character sheet). Keep her EXACT face, short cropped grey hair, gold hoop earrings, small round terracotta enamel brooch."&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;room&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="ss"&gt;text: &lt;/span&gt;&lt;span class="s2"&gt;"PHOTO B: the atelier. Keep the EXACT room: Georgian London townhouse, tall sash windows, ..."&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;refs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;dot&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;enc&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"dot_sheet.png"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;room&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;enc&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"place_1.png"&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
&lt;span class="n"&gt;refs&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="ss"&gt;text: &lt;/span&gt;&lt;span class="s2"&gt;"PHOTO C: the client. Keep her EXACT face, hair, skin tone and body."&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="n"&gt;enc&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;client_photo&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Photo A is a character sheet of Dot from several angles. Photo B is the room. Photo C is the client, and the client is one of the stock models the app already uses for its own try-on previews, so the series and the product show the same people.&lt;br&gt;
The script runs inside the production container, because that is where the API key and the stock model photos already live, and it writes PNGs to &lt;code&gt;/tmp&lt;/code&gt;. Nothing touches the database. A slideshow is five slides: a hook, three looks, and an end slide with Dot alone.&lt;/p&gt;
&lt;h2&gt;
  
  
  The three rules
&lt;/h2&gt;

&lt;p&gt;All three came from episodes that went wrong before this checklist existed.&lt;br&gt;
&lt;strong&gt;1. The client must not look like Dot.&lt;/strong&gt; In one early episode the client was a light skinned woman in her fifties. The model blended her with Dot: same face shape, and her hair got shorter and greyer from one look to the next. Two women who looked like sisters, in a series where one is supposed to be the expert and the other the customer. My reaction when I saw it was one word in French: bizarre.&lt;br&gt;
&lt;strong&gt;2. The client keeps the same face and hair across all looks.&lt;/strong&gt; In another episode the second look had a different woman in it. Same outfit brief, different person. Image models do not have a notion of "the same client as in the previous request". Every request is a fresh roll, and only the reference photo holds her identity.&lt;br&gt;
&lt;strong&gt;3. Dot never wears a coat.&lt;/strong&gt; Her silhouette is the turtleneck. Once she has a camel coat on, she reads as a different character in a thumbnail, and thumbnails are all TikTok gives you.&lt;br&gt;
These go in the prompt as hard constraints:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="n"&gt;two&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"TWO DIFFERENT WOMEN: the client is the woman from PHOTO C (keep her exact face, skin tone, body and EXACTLY the same hairstyle as in PHOTO C; she looks nothing like Dot); Dot is the short-grey-haired stylist from PHOTO A. "&lt;/span&gt;
&lt;span class="n"&gt;dotlook&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;" Dot stands slightly behind on the left wearing ONLY a black fine-knit turtleneck and black slim trousers, NO coat, NO jacket, no outerwear at all, arms relaxed or adjusting a detail of the outfit, closed lips."&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Putting rules in a prompt is necessary. It is not sufficient. The model follows them most of the time, and "most of the time" across nine images means something will fail.&lt;/p&gt;

&lt;h2&gt;
  
  
  Today's batch: nine images, six rejected
&lt;/h2&gt;

&lt;p&gt;I generated two slideshows: "outfits for people with a full closet and nothing to wear" and "the white shirt you never wear, 3 ways". Eight shots with a client, plus the shared end slide. The first pass came back clean on the technical side: nine images, no API errors, no retries.&lt;br&gt;
Then the contact sheet. I tile every image of a batch into one picture with ffmpeg so I can review them side by side, because drift is invisible one image at a time:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ffmpeg &lt;span class="nt"&gt;-i&lt;/span&gt; s1_0.png &lt;span class="nt"&gt;-i&lt;/span&gt; s1_1.png ... &lt;span class="nt"&gt;-filter_complex&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"[0]scale=360:640[v0];[1]scale=360:640[v1];...;&lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="s2"&gt;
   [v0][v1]...xstack=inputs=9:layout=0_0|360_0|720_0|..."&lt;/span&gt; contact.jpg
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here is what failed.&lt;br&gt;
&lt;strong&gt;Dot wore a coat in three images.&lt;/strong&gt; Two were the opening "before" shots of each slideshow. Looking at the script, that was my bug, not the model's: the no coat sentence was appended to the three look shots and not to the opening shot, where Dot only appears as a side character. The model gave her a camel coat because nothing said otherwise. The third was the end slide, where my own prompt asked for "her long camel coat over her shoulders". I had written that before the rule existed and never reread it.&lt;br&gt;
&lt;strong&gt;The client of the first slideshow was too close to Dot.&lt;/strong&gt; The stock model I had picked has a grey bob. Next to Dot's grey crop, the two women had the same palette from head to shoulders. No blending this time, faces were distinct, but in a 360 pixel thumbnail it failed rule 1 anyway. I swapped her for a different stock model with dark skin and short natural hair. That meant regenerating all four shots of that slideshow, because rule 2 says the client cannot change halfway.&lt;br&gt;
&lt;strong&gt;The end slide had a flat beige band across the top quarter.&lt;/strong&gt; This one surprised me. Every prompt ends with a style block that includes "leave calm empty wall space in the top third of the picture", because the hook text goes there. On the shots with two people, the model reads that as composition advice and pulls the camera back. On the end slide, with Dot alone, it painted a literal empty rectangle above the room, like a letterbox. It did it twice in a row.&lt;br&gt;
&lt;strong&gt;Dot smiled with her teeth.&lt;/strong&gt; The prompt said "warm closed-lip smile". She smiled with her teeth anyway, on the end slide, both times.&lt;/p&gt;
&lt;h2&gt;
  
  
  Fixing the prompt, not the picture
&lt;/h2&gt;

&lt;p&gt;I could have cropped the band out or picked the least bad image. I would rather fix the prompt, because the same prompt will run for the next eight slideshows.&lt;br&gt;
The opening shots got the same constraint as the look shots:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="n"&gt;dotsolo&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;" Dot wears ONLY a black fine-knit turtleneck and black slim trousers, NO coat, NO jacket, no outerwear at all, closed lips."&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The end slide got its own variant of the style block. The "empty space" instruction is replaced by one that describes the room going up to the ceiling, and the smile is described physically instead of with an adjective:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nb"&gt;name&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s2"&gt;"end"&lt;/span&gt;
  &lt;span class="n"&gt;prompt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"leave calm empty wall space in the top third of the picture"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                      &lt;span class="s2"&gt;"the room continues naturally up to the ceiling cornice"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
           &lt;span class="s2"&gt;" Her mouth is fully closed: a soft gentle smile with lips pressed together, absolutely no teeth visible."&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;"Closed-lip smile" failed twice. "Lips pressed together, no teeth visible" worked on the first try. Describing what the camera should see beats naming the expression.&lt;br&gt;
I also added one line that is not about the picture at all: the script now prints the list of available stock models before generating, so picking a client is a deliberate choice from the real list and not a key I remember.&lt;/p&gt;
&lt;h2&gt;
  
  
  Regenerate only what failed
&lt;/h2&gt;

&lt;p&gt;The script takes an &lt;code&gt;ONLY&lt;/code&gt; variable, so a rerun touches just the rejected shots and leaves the approved ones alone:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="n"&gt;only&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;ENV&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"ONLY"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;to_s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;","&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;shots&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;each&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="nb"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;desc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;
  &lt;span class="k"&gt;next&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;only&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;any?&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;only&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;include?&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="c1"&gt;# ...&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Before every rerun I copy the current images to a versioned folder. Image models do not always improve on a second try, and I want to be able to go back.&lt;br&gt;
The final count for two slideshows: 9 images in the first pass, 6 regenerated, 1 regenerated again. Sixteen generations for nine images that ship. That ratio is the real cost of a recurring character, and it is still far cheaper than a photo shoot.&lt;/p&gt;

&lt;h2&gt;
  
  
  Text goes on after review
&lt;/h2&gt;

&lt;p&gt;The hook text, the look labels and the end slide are added afterwards with Remotion stills, not by the image model. Image models are getting better at text, but a typo baked into an image means regenerating the image. In Remotion it is a string.&lt;br&gt;
The same component renders both formats: 9:16 for TikTok, and 4:5 for Instagram, where the 9:16 image is fitted by height on a sand background so no head or shoe is ever cropped. Overlays sit in the wall space above the people, which is exactly what that "empty top third" instruction was for when it works.&lt;/p&gt;

&lt;h2&gt;
  
  
  The checklist
&lt;/h2&gt;

&lt;p&gt;Before anything with Dot goes out:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Build the contact sheet. Never review generated images one at a time.&lt;/li&gt;
&lt;li&gt;Client vs Dot: different face, different hair, different palette, judged at thumbnail size.&lt;/li&gt;
&lt;li&gt;Same client, same hair, in every look of a slideshow.&lt;/li&gt;
&lt;li&gt;Dot: turtleneck, no coat, no jacket, lips closed.&lt;/li&gt;
&lt;li&gt;Look for composition artifacts: blank bands, extra people, duplicated limbs, text the model invented.&lt;/li&gt;
&lt;li&gt;Reject, fix the prompt, rerun only the failures, review again.
None of this is clever. It is the same thing as reviewing a pull request: the model writes, a person reads, and the rules that matter are written down so they survive the next batch. If you want the other side of how OutfitMaker uses Gemini, &lt;a href="https://meridianbuild.dev/blog/my-ai-was-blind-in-production/" rel="noopener noreferrer"&gt;my AI was blind in production&lt;/a&gt; is what happens when nobody looks at the images at all.&lt;/li&gt;
&lt;/ol&gt;

</description>
      <category>ai</category>
      <category>testing</category>
      <category>indiehacker</category>
    </item>
    <item>
      <title>Ten Abandoned Checkouts and the One Email I Send Now</title>
      <dc:creator>MarcoBlch</dc:creator>
      <pubDate>Tue, 27 Oct 2026 17:05:14 +0000</pubDate>
      <link>https://dev.to/marcoblch/ten-abandoned-checkouts-and-the-one-email-i-send-now-4bg6</link>
      <guid>https://dev.to/marcoblch/ten-abandoned-checkouts-and-the-one-email-i-send-now-4bg6</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Originally published on &lt;a href="https://meridianbuild.dev/blog/one-email-for-an-abandoned-checkout/" rel="noopener noreferrer"&gt;meridianbuild.dev&lt;/a&gt;, my build log for &lt;a href="https://outfitmaker.ai" rel="noopener noreferrer"&gt;OutfitMaker&lt;/a&gt;, an AI outfit planner that works from the clothes you already own.&lt;br&gt;
On October 20 I counted the Stripe Checkouts that &lt;a href="https://outfitmaker.ai" rel="noopener noreferrer"&gt;OutfitMaker&lt;/a&gt; had opened over the previous 14 days. There were 11. One was paid. The other 10 people reached the Stripe page, looked at it, and left.&lt;br&gt;
Ten is a small number. But each of those people had already decided they wanted something enough to click "pay". They are the warmest leads I have, and I had an email for them. It just didn't see most of them.&lt;br&gt;
This post covers how I rebuilt that email in PR #245. It also covers a small card that shows the cheapest offer earlier, and why I can't tell you yet whether any of it works.&lt;/p&gt;
&lt;h2&gt;
  
  
  What the old email could see
&lt;/h2&gt;

&lt;p&gt;The previous version of &lt;code&gt;SendCheckoutRecoveryJob&lt;/code&gt; looked for abandoned checkouts in the subscriptions table:&lt;br&gt;
&lt;/p&gt;
&lt;/blockquote&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="no"&gt;Subscription&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;status: :incomplete&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;stripe_subscription_id: &lt;/span&gt;&lt;span class="kp"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;current_period_end: &lt;/span&gt;&lt;span class="kp"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;created_at: &lt;/span&gt;&lt;span class="mi"&gt;24&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;hours&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ago&lt;/span&gt;&lt;span class="o"&gt;..&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;hours&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ago&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That query finds people who clicked "start my trial" and never came back from Stripe. It made sense when a subscription was the only thing you could buy.&lt;br&gt;
It isn't anymore. Since then I added a 7-day pass, a one-time payment, and credit packs for Look Previews and resale photos. None of those create a &lt;code&gt;Subscription&lt;/code&gt; row. So someone who opened the pass checkout and walked away was invisible to the job. The email also linked to the pricing page, which meant the person had to find and choose their offer all over again.&lt;br&gt;
If you have read &lt;a href="https://meridianbuild.dev/blog/the-failed-payments-my-database-invented/" rel="noopener noreferrer"&gt;the failed payments my database invented&lt;/a&gt;, the &lt;code&gt;incomplete&lt;/code&gt; status will look familiar. Using a subscription status to mean "someone opened a checkout" was the same kind of shortcut. It works until the product changes shape.&lt;/p&gt;
&lt;h2&gt;
  
  
  A row for every Checkout
&lt;/h2&gt;

&lt;p&gt;The fix starts with a boring table. Every Stripe Checkout session the app opens for a user now gets a row:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="n"&gt;create_table&lt;/span&gt; &lt;span class="ss"&gt;:checkout_attempts&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;
  &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;references&lt;/span&gt; &lt;span class="ss"&gt;:user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;null: &lt;/span&gt;&lt;span class="kp"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;foreign_key: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="ss"&gt;on_delete: :cascade&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="ss"&gt;index: &lt;/span&gt;&lt;span class="kp"&gt;false&lt;/span&gt;
  &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt; &lt;span class="ss"&gt;:stripe_session_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;null: &lt;/span&gt;&lt;span class="kp"&gt;false&lt;/span&gt;
  &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt; &lt;span class="ss"&gt;:plan&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;null: &lt;/span&gt;&lt;span class="kp"&gt;false&lt;/span&gt;        &lt;span class="c1"&gt;# week_pass, monthly, yearly, or a CreditPurchase::PACKS key&lt;/span&gt;
  &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt; &lt;span class="ss"&gt;:source&lt;/span&gt;                   &lt;span class="c1"&gt;# Stripe metadata source (home_card, recovery_email...)&lt;/span&gt;
  &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt; &lt;span class="ss"&gt;:currency&lt;/span&gt;
  &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;datetime&lt;/span&gt; &lt;span class="ss"&gt;:completed_at&lt;/span&gt;           &lt;span class="c1"&gt;# checkout.session.completed webhook&lt;/span&gt;
  &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;datetime&lt;/span&gt; &lt;span class="ss"&gt;:recovery_email_sent_at&lt;/span&gt; &lt;span class="c1"&gt;# claimed by the recovery job, once&lt;/span&gt;
  &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;timestamps&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I could have asked Stripe for the list of expired sessions on every run instead. I didn't want a cron job that depends on paging through an external API to decide who gets an email. A local row is easier to query and easier to test, and it doesn't care what kind of product was in the cart.&lt;br&gt;
The recording happens in one place. All Checkouts already went through a small &lt;code&gt;StripeCheckout&lt;/code&gt; module, so I split it in two:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;create_session&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="n"&gt;session&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;open_session&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="no"&gt;CheckoutAttempt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;record!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="n"&gt;session&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One choke point means no entry point can forget to record. And &lt;code&gt;record!&lt;/code&gt; never raises. It logs a warning and returns &lt;code&gt;nil&lt;/code&gt; if the insert fails, because a recovery email is not worth losing the checkout itself.&lt;br&gt;
The &lt;code&gt;checkout.session.completed&lt;/code&gt; webhook stamps &lt;code&gt;completed_at&lt;/code&gt;. That covers paid sessions and also payment methods that are still settling. Either way, the person finished on Stripe's side and should not get a "you didn't finish" email.&lt;/p&gt;
&lt;h2&gt;
  
  
  One email, and the window
&lt;/h2&gt;

&lt;p&gt;The new job runs every 30 minutes and looks for unfinished Checkouts that were opened between 60 and 180 minutes ago:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="no"&gt;RECOVERY_AGE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;minutes&lt;/span&gt;&lt;span class="o"&gt;..&lt;/span&gt;&lt;span class="mi"&gt;180&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;minutes&lt;/span&gt;
&lt;span class="no"&gt;RECOVERY_COOLDOWN&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;days&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;recoverable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;current&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="n"&gt;unfinished&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;recovery_email_sent_at: &lt;/span&gt;&lt;span class="kp"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;created_at: &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="no"&gt;RECOVERY_AGE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;end&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;..&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="no"&gt;RECOVERY_AGE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;begin&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The old window was 3 to 24 hours. I moved it much earlier. An hour is long enough that the person has clearly left and isn't just reading the Stripe page or switching cards. It's still short enough that they remember what they were doing. The cron runs at :20 and :50, which lands about an hour after the abandon and keeps it away from other jobs that fire on the hour and half hour.&lt;br&gt;
There is exactly one email per abandoned session, and at most one per user per 7 days. No sequence. No "last chance" follow up. The subject is "You were one tap away", and the footer says plainly that this is the only reminder.&lt;br&gt;
I went with one email on purpose. These are people who signed up days or minutes ago, and they already get onboarding emails. A recovery sequence on top of that is how a small app ends up in the spam folder. I would rather lose a sale than teach Gmail that my domain sends three emails about the same unfinished payment.&lt;/p&gt;
&lt;h2&gt;
  
  
  The link reopens the same offer
&lt;/h2&gt;

&lt;p&gt;The email shows what the person was about to get and has one button. That button doesn't go to the pricing page. It reopens the same offer through a signed one-click link:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="vi"&gt;@cta_url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;renew_subscriptions_url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="no"&gt;HOST&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;token: &lt;/span&gt;&lt;span class="no"&gt;PassMailer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;renewal_token&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="ss"&gt;plan: &lt;/span&gt;&lt;span class="vi"&gt;@plan&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                                           &lt;span class="ss"&gt;source: &lt;/span&gt;&lt;span class="s2"&gt;"recovery_email"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;utm_source: &lt;/span&gt;&lt;span class="s2"&gt;"email"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                                           &lt;span class="ss"&gt;utm_medium: &lt;/span&gt;&lt;span class="s2"&gt;"checkout_recovery"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;utm_content: &lt;/span&gt;&lt;span class="vi"&gt;@plan&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The renew endpoint already existed for pass renewals. It verifies the token, so the person doesn't have to log in from their phone, and it opens a fresh Checkout for the plan they picked: the week pass, monthly, yearly, or the exact credit pack. If they were eligible for the free trial week, the email says so, and the button says "Start my free week" instead of "Get Premium".&lt;br&gt;
The new Checkout carries &lt;code&gt;source: "recovery_email"&lt;/code&gt; in its Stripe metadata. That's the only way I'll ever know if the email works.&lt;br&gt;
There is no countdown, no discount, and no deadline in the email. The offer is simply still there. I don't want to train people to abandon a checkout to get a coupon.&lt;/p&gt;
&lt;h2&gt;
  
  
  Most of the code is reasons not to send
&lt;/h2&gt;

&lt;p&gt;The query that finds candidates is three lines. The rules that stop the email take up most of the job:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;recover?&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&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;attempt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;user&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kp"&gt;false&lt;/span&gt; &lt;span class="k"&gt;unless&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;class&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;recoverable_user?&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kp"&gt;false&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;checkout_attempts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"created_at &amp;gt; ?"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;exists?&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kp"&gt;false&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="no"&gt;CheckoutAttempt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;recovery_sent_recently?&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kp"&gt;false&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;paid_since?&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="no"&gt;LifecycleEmailGuard&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sent_today?&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In plain words, no email if:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;They came back.&lt;/strong&gt; A newer Checkout means they returned on their own, so only the latest one counts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;They paid some other way.&lt;/strong&gt; That could be a pass or a pack bought in another session, or with the express payment sheet. &lt;code&gt;paid_since?&lt;/code&gt; looks for purchases created after this Checkout opened. An older purchase doesn't count.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;They are already Premium.&lt;/strong&gt; This covers a live trial, an active subscription or a pass.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The inbox isn't confirmed&lt;/strong&gt;, or they turned off lifecycle emails. The daily outfit email has its own switch, and turning that one off doesn't block this email. The recovery email is closer to marketing, so it follows the marketing switch.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It's an internal account.&lt;/strong&gt; My own account, QA accounts and demo accounts are all excluded, from both emails and conversion stats.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;They already got an email today.&lt;/strong&gt; OutfitMaker allows one lifecycle email per user per day. If the daily outfit or an activation email already went out, the recovery waits. While a recovery email is pending, the softer emails stand down for it.
Then there's the lock. Two runs of the job, or a retry, must never send twice. So the row itself is the lock:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;claim!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="no"&gt;CheckoutAttempt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;id: &lt;/span&gt;&lt;span class="n"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;recovery_email_sent_at: &lt;/span&gt;&lt;span class="kp"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                 &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update_all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;recovery_email_sent_at: &lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;updated_at: &lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Only the run that flips &lt;code&gt;recovery_email_sent_at&lt;/code&gt; from &lt;code&gt;nil&lt;/code&gt; gets to send. It's the same conditional update pattern I'd use for anything that must happen once, and it doesn't need Redis. The old version kept its "already sent" flag in the Rails cache, which a flush could erase.&lt;br&gt;
The tests read like that list: "timing: nothing before an hour, nothing after three", "only the latest checkout of a user counts", "an older purchase does not count as paying for this checkout", "skips internal and QA accounts".&lt;/p&gt;

&lt;h2&gt;
  
  
  Showing the pass earlier
&lt;/h2&gt;

&lt;p&gt;The second half of the PR is about the other end of the funnel. The cheapest offer is the 7-day pass, and almost nobody was seeing it.&lt;br&gt;
Over those same 14 days, there were about 1,700 signups. Only about 100 of them reached the paywall that appears after the first free Look Preview. That's the moment where the pass was offered. Everyone else never saw a price.&lt;br&gt;
So free users with at least 3 real pieces in their closet (sample items don't count) now see a small card on the home page: 7 days of Premium, one payment, nothing renews. One tap opens the week pass Checkout. A "Hide for a week" button sets a cookie and the card goes away for 7 days. The d1 and d3 activation emails got a one-line mention of the pass too.&lt;br&gt;
I track the card as its own experiment label, &lt;code&gt;home_pass_card&lt;/code&gt;, with a single arm. It isn't an A/B test. I only wanted its views, taps and purchases to go through the same report as the main paywall without mixing into that paywall's numbers. A view is recorded once per user, however many times the home page renders. A tap is recorded on the server, inside the checkout request, and only for a user who was shown the card.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I don't know yet
&lt;/h2&gt;

&lt;p&gt;This shipped on October 20. As I write this, it's too early to measure anything. Ten abandoned Checkouts in two weeks means I'll need weeks of data before a recovered sale means anything. I don't have a before/after number, and I'm not going to invent one.&lt;br&gt;
What I do have is the instrumentation to find out. Every Checkout now has a row with a source. A recovered sale will come back tagged &lt;code&gt;recovery_email&lt;/code&gt; and a pass bought from the card will come back tagged &lt;code&gt;home_card&lt;/code&gt;. If neither moves, that's an answer too.&lt;br&gt;
The broader lesson is the same one from &lt;a href="https://meridianbuild.dev/blog/payment-screens-promised-what-the-code-did-not-do/" rel="noopener noreferrer"&gt;the week my payment screens promised things my code didn't do&lt;/a&gt;. The product changed shape, from one subscription to a pass and packs, and the code around payments kept assuming the old shape. The recovery email didn't break. It quietly kept doing exactly what it was written for, for a product that no longer existed.&lt;/p&gt;

</description>
      <category>rails</category>
      <category>saas</category>
      <category>stripe</category>
      <category>startup</category>
    </item>
    <item>
      <title>My Invite-Only Gate Lived in the Browser</title>
      <dc:creator>MarcoBlch</dc:creator>
      <pubDate>Fri, 23 Oct 2026 11:51:41 +0000</pubDate>
      <link>https://dev.to/marcoblch/my-invite-only-gate-lived-in-the-browser-3697</link>
      <guid>https://dev.to/marcoblch/my-invite-only-gate-lived-in-the-browser-3697</guid>
      <description>&lt;p&gt;Originally published on &lt;a href="https://meridianbuild.dev/blog/my-invite-only-gate-lived-in-the-browser/" rel="noopener noreferrer"&gt;meridianbuild.dev&lt;/a&gt;, my build log for &lt;a href="https://fastpass.email" rel="noopener noreferrer"&gt;FastPass&lt;/a&gt;, a paid inbox where people pay to reach you and get refunded if you don't reply.&lt;br&gt;
&lt;a href="https://fastpass.email" rel="noopener noreferrer"&gt;FastPass&lt;/a&gt; is invite-only. That has been true in the database for months: a row in &lt;code&gt;platform_settings&lt;/code&gt; called &lt;code&gt;invite_only_mode&lt;/code&gt;, set to &lt;code&gt;enabled: true&lt;/code&gt;. Sign up without a valid invite code and you get turned away. That was the plan, anyway.&lt;br&gt;
Then I was going through recent signups and found a few accounts with no invite code attached. Nobody had flipped the setting. Nobody had leaked a code. They had simply walked through a door I thought was locked, because the lock was a React component.&lt;br&gt;
This is the story of that door, and of what I changed so that the next one fails closed.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why invite-only in the first place
&lt;/h2&gt;

&lt;p&gt;FastPass is a pay-to-reach link. A sender pays to get a message in front of you, the money sits in escrow, and you either reply before your deadline or the sender gets refunded automatically. Stripe Connect handles the payouts.&lt;br&gt;
That last part is why I keep the creator side gated. Every creator who finishes onboarding gets a public payment page and a Stripe Connect account tied to my platform. I want to know who is on the receiving end of that money. I wrote recently about &lt;a href="https://meridianbuild.dev/blog/two-sided-marketplace-cold-start-offer-not-seo/" rel="noopener noreferrer"&gt;why the cold start on that side is my real bottleneck&lt;/a&gt;, so I am not gating for scarcity theater. I am gating because a payments platform should know its payees.&lt;/p&gt;
&lt;h2&gt;
  
  
  The path in
&lt;/h2&gt;

&lt;p&gt;Here is the route those accounts took. When I traced it, it was almost boring.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Someone searches Google and lands on one of my SEO landing pages, &lt;code&gt;/get-paid-to-answer-messages&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;They click "Start earning", which goes to &lt;code&gt;/auth&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/auth&lt;/code&gt; opens on the Login tab, not the Sign up tab.&lt;/li&gt;
&lt;li&gt;They click "Continue with Google".&lt;/li&gt;
&lt;li&gt;Supabase creates the account.&lt;/li&gt;
&lt;li&gt;The OAuth callback sends them to the dashboard.
Nothing in that list is an exploit. It is the happy path of a page I built. The problem is step 3 meeting step 4.
## The bug, in four lines
This is the Google button handler from &lt;code&gt;AuthForm.tsx&lt;/code&gt;, before the fix:
&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="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;isLogin&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setIsLogin&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="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;forceSignup&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;handleGoogleSignIn&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;isLogin&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;inviteOnlyMode&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;inviteCodeValid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;toast&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Please enter a valid invite code above to continue with Google sign-up&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="c1"&gt;// ...&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;signInWithOAuth&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;google&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="cm"&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 invite check only runs when &lt;code&gt;!isLogin&lt;/code&gt;. On the Login tab it is skipped, which sounds reasonable until you remember how OAuth works. With email and password, "log in" and "sign up" are different calls. With Google, they are the same call. If the Google account has never been seen before, Supabase creates a user. There is no "log in only" mode for a person who does not exist yet.&lt;br&gt;
So my form had a login tab that could create accounts, and the only invite check lived on the other tab.&lt;br&gt;
It got worse the further I looked:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;AuthCallback.tsx&lt;/code&gt;, the page Google redirects back to, required nothing. It redeemed an invite code if one happened to be stored, showed a welcome toast, and moved on.&lt;/li&gt;
&lt;li&gt;The check for whether invite-only mode was even on came from an edge function, and the client failed open if that call failed:
&lt;/li&gt;
&lt;/ul&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Error checking invite mode:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;setInviteOnlyMode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;ul&gt;
&lt;li&gt;And &lt;code&gt;create-stripe-connect-account&lt;/code&gt;, the function that starts payout onboarding, ran with &lt;code&gt;verify_jwt = false&lt;/code&gt; and accepted whatever &lt;code&gt;userId&lt;/code&gt; the request body gave it. It did not check who was calling.
Every layer assumed some other layer had already done the checking. None of them had.
## Why it became urgent
Most of those accounts did nothing. Signed in, looked around, left. One got as far as starting Stripe Connect onboarding.
That was the moment it stopped being a backlog item. An uninvited account with a payout account attached to my platform is a compliance problem, not a UX bug. I suspended that account first, then sat down to fix the actual hole.
## The fix: one gate, on the server
The fix went out as PR #114. Four pieces.
### 1. A shared gate that fails closed
I moved the decision into a shared module for my Supabase edge functions, &lt;code&gt;_shared/invite-gate.ts&lt;/code&gt;. It is the single source of truth for "is this user allowed in":
&lt;/li&gt;
&lt;/ul&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="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;isInviteOnlyMode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;supabase&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;SupabaseClient&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;boolean&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;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;platform_settings&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;setting_value&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;setting_key&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;invite_only_mode&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;single&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;invite-gate: could not read invite_only_mode, failing closed:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="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="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;setting_value&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;enabled&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Compare that with the client version. Same question, opposite default. If the setting cannot be read, the server assumes invite-only is on.&lt;br&gt;
&lt;code&gt;checkInviteAccess&lt;/code&gt; builds on it. A user is allowed if they are an admin, if their profile has &lt;code&gt;invited_by_code&lt;/code&gt;, or if the account predates the invite system. Everyone else gets &lt;code&gt;no_invite_code&lt;/code&gt;. If there is no profile at all, that is a no too.&lt;/p&gt;
&lt;h3&gt;
  
  
  2. A server-side check after OAuth
&lt;/h3&gt;

&lt;p&gt;A new edge function, &lt;code&gt;enforce-invite-access&lt;/code&gt;, runs with &lt;code&gt;verify_jwt = true&lt;/code&gt; and resolves the caller from the token, never from the request body. &lt;code&gt;AuthCallback&lt;/code&gt; now calls it before anything else happens:&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="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;access&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;accessError&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;supabase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;functions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;invoke&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;enforce-invite-access&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;accessError&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;access&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;allowed&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;signOut&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nx"&gt;toast&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;FastPass is invite-only. Sign up with a valid invite code, or request an invite.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;navigate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/auth&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;replace&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="k"&gt;return&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 welcome toast moved below this check. Small thing, but it used to greet people before deciding whether they were allowed in.&lt;br&gt;
There is one more detail in that function I like. If the rejected account was created in the last 15 minutes, it is almost certainly a Google signup that just happened without a code. The function deletes it, so the person can come back with a code using the same Google account instead of hitting a weird "you already exist but you are not allowed" state.&lt;/p&gt;
&lt;h3&gt;
  
  
  3. The hard lock where the money moves
&lt;/h3&gt;

&lt;p&gt;This is the part that matters most. &lt;code&gt;create-stripe-connect-account&lt;/code&gt; now checks two things before it talks to Stripe:&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;caller&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getCaller&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;supabase&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;caller&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;userId&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="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Unauthorized&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;401&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="cm"&gt;/* ... */&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;access&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;checkInviteAccess&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;supabase&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;access&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;allowed&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="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;FastPass is invite-only. Please redeem an invite code before setting up payouts.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;403&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="cm"&gt;/* ... */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The caller's JWT must match the &lt;code&gt;userId&lt;/code&gt; in the body, otherwise 401. The caller must pass the invite gate, otherwise 403. No Stripe account means no public payment page, so this one check protects the thing I actually care about.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. A deliberate fail-open, one layer up
&lt;/h3&gt;

&lt;p&gt;You might notice that &lt;code&gt;AuthCallback&lt;/code&gt; lets the user through if &lt;code&gt;enforce-invite-access&lt;/code&gt; errors out. That is on purpose, and the comment in the function says so:&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="c1"&gt;// No verdict on errors: the hard gate is create-stripe-connect-account,&lt;/span&gt;
&lt;span class="c1"&gt;// so an outage here must not lock legitimate users out of their dashboard.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The difference from before is that this fail-open is a choice with a backstop. If the soft check breaks, an uninvited person might see an empty dashboard. They still cannot start payouts, because the Stripe function fails closed on its own. Before the fix, every layer failed open and none of them was the backstop.&lt;/p&gt;

&lt;h2&gt;
  
  
  Deploy order matters
&lt;/h2&gt;

&lt;p&gt;The frontend ships through Vercel when I merge. The edge functions ship separately, with &lt;code&gt;supabase functions deploy&lt;/code&gt;. Merge first and the new &lt;code&gt;AuthCallback&lt;/code&gt; calls an &lt;code&gt;enforce-invite-access&lt;/code&gt; function that does not exist yet. That call errors, the callback lets the user through, and I have gained nothing.&lt;br&gt;
So the order written in the PR was:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;code&gt;npx supabase functions deploy enforce-invite-access create-stripe-connect-account&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Merge the PR.
Functions first, then the client that depends on them. Obvious when written down, easy to get backwards when you are in a hurry to close a hole.
## What I have verified, and what I have not
After deploying, I called both functions without authorization. Both returned 401. The build passed, and the touched files did not add new type or lint errors.
What I have not done yet is the real end-to-end test: a fresh Google account, no invite code, through the landing page, the Login tab and the Google button, to confirm it gets signed out and deleted. That is on my to-do list, and until I have run it I will not call this fully verified. The Stripe-side lock does not depend on that flow working, which is exactly why I put it there.
## The lesson
A client-side gate is a UX hint. It tells honest users what to expect. It is not a lock, because the browser is not yours, and because the flows you build (OAuth, deep links, a landing page CTA pointing at the wrong tab) will find paths around it that nobody attacked on purpose.
Put the lock on the server, at the point where something irreversible happens. For FastPass that is the creation of a Stripe Connect account, not the signup form. Make it check identity from the token, not the request body. And make it fail closed, so that when something upstream breaks, the default answer is no.
The same mindset got me out of &lt;a href="https://meridianbuild.dev/blog/my-stripe-webhooks-were-failing-96-percent/" rel="noopener noreferrer"&gt;my Stripe webhook mess&lt;/a&gt;: the money path deserves its own checks, independent of everything in front of it. I just had to learn it twice.&lt;/li&gt;
&lt;/ol&gt;

</description>
      <category>security</category>
      <category>webdev</category>
      <category>react</category>
    </item>
    <item>
      <title>My Payment Screens Promised Things My Code Didn't Do</title>
      <dc:creator>MarcoBlch</dc:creator>
      <pubDate>Fri, 23 Oct 2026 11:44:29 +0000</pubDate>
      <link>https://dev.to/marcoblch/my-payment-screens-promised-things-my-code-didnt-do-417a</link>
      <guid>https://dev.to/marcoblch/my-payment-screens-promised-things-my-code-didnt-do-417a</guid>
      <description>&lt;p&gt;Originally published on &lt;a href="https://meridianbuild.dev/blog/payment-screens-promised-what-the-code-did-not-do/" rel="noopener noreferrer"&gt;meridianbuild.dev&lt;/a&gt;, my build log for &lt;a href="//outfitmaker.ai"&gt;OutfitMaker&lt;/a&gt;, an AI outfit planner that works from the clothes you already own.&lt;/p&gt;

&lt;p&gt;On September 24 I retired the Pro plan at OutfitMaker. One paid plan, Premium, plus a free tier. I changed the checkout, I changed Stripe, and I moved on.&lt;/p&gt;

&lt;p&gt;In October I sat down and read every public page and every payment screen as if I were a new visitor. Not the code. The words. What I found was a second version of my product, living in YAML files and ERB templates, that had kept its own rules while the real ones moved.&lt;/p&gt;

&lt;p&gt;This post is about that gap, the PRs it took to close it, and why I now treat copy as code that needs tests.&lt;/p&gt;

&lt;p&gt;What the pages were saying&lt;br&gt;
A partial list, all from lines I deleted.&lt;/p&gt;

&lt;p&gt;The FAQ still sold Pro. In all five languages (English, French, Spanish, German, Portuguese), the plan section listed a Pro tier with "Unlimited AI suggestions", "30 virtual try-ons per month" and "24/7 dedicated support". You could not buy any of it.&lt;/p&gt;

&lt;p&gt;Premium was described with the wrong limits. The FAQ capped Premium at "150 wardrobe items" and "5 virtual try-ons per month". In the code, wardrobe storage is unlimited on every plan and Premium includes 30 Look Previews a month.&lt;/p&gt;

&lt;p&gt;The free plan had two different quotas depending on the page. The FAQ's structured data said "3 AI outfit suggestions per day". The comparison table on the pricing page said 1/day. A help paragraph about weather said "1 for Free, 10 for Premium, Unlimited for Pro". The code gives free accounts 3.&lt;/p&gt;

&lt;p&gt;The Portuguese copy had its own prices. pt.yml hardcoded R$ 39,90 for Premium and R$ 74,90 for Pro. Made-up numbers, not what Stripe charges. Everywhere else, prices come from a variable.&lt;/p&gt;

&lt;p&gt;The pricing page showed $0 to everyone. The free tier's price was a literal string. A visitor in London or Paris saw dollars right next to a Premium price in their own currency. The upgrade modal had a hardcoded $14.99/mo for Pro, and a few other strings had a hardcoded $7.99/mo for Premium.&lt;/p&gt;

&lt;p&gt;The home page still had a Pro pricing card with a "Get started" button linking to plan=pro.&lt;/p&gt;

&lt;p&gt;A 30-day money-back guarantee sat in the pricing page footer: "All plans include 30-day money-back guarantee". I removed it. That was a founder decision, not a bug fix, but it belongs here because nothing in the code implemented it.&lt;/p&gt;

&lt;p&gt;An SEO page about "wardrobe management software" still listed Pro pricing and an "Early adopter offer, active until June 30, 2026". This was October.&lt;/p&gt;

&lt;p&gt;None of these were typos. Each one was true at some point. The code changed and the words didn't.&lt;/p&gt;

&lt;p&gt;The bugs that weren't just words&lt;br&gt;
Some of the drift went deeper than copy.&lt;/p&gt;

&lt;p&gt;A batch upload that said "your wardrobe is full"&lt;br&gt;
Batch upload accepts 10 photos at a time. If you dropped 12, the extra 2 were skipped, and the controller reported it like this:&lt;/p&gt;

&lt;p&gt;msg += " #{skipped} skipped (wardrobe limit reached)." if skipped &amp;gt; 0&lt;/p&gt;

&lt;p&gt;if skipped &amp;gt; 0 &amp;amp;&amp;amp; current_user.free_tier?&lt;br&gt;
  redirect_to wardrobe_items_path,&lt;br&gt;
    flash: { notice: msg, show_upgrade: "wardrobe_limit", analytics_event: "batch_upload_chosen" }&lt;br&gt;
show_upgrade: "wardrobe_limit" opened a modal that said "Your wardrobe is full! Free accounts can store up to 25 items. Upgrade to Premium for 150 items." Every part of that was false. Storage is unlimited, and the photos were only skipped because of the per-upload cap. This was a regression from an earlier PR that changed the batch logic.&lt;/p&gt;

&lt;p&gt;So a free user doing exactly what I want new users to do (adding a lot of clothes on day one) got told their closet was full and asked to pay. The fix is a message that says what actually happened:&lt;/p&gt;

&lt;p&gt;over_batch: "%{count} more photos were not uploaded: %{max} photos per upload. Add them in a second upload."&lt;br&gt;
No upgrade modal. The test asserts flash[:show_upgrade] is nil and that "limit reached" never appears.&lt;/p&gt;

&lt;p&gt;An upgrade modal that never opened&lt;br&gt;
While I was rewriting the modal copy, I found that the modal itself never showed up. The template set data-modal-open-on-load-value="true", but the Stimulus controller reads a value called auto-open. There was also no panel target for the fade-in. So every show_upgrade flash in the app rendered a modal that stayed invisible.&lt;/p&gt;

&lt;p&gt;So the false "wardrobe full" modal was probably less visible than I feared. Two bugs partly cancelling each other out is not a strategy.&lt;/p&gt;

&lt;p&gt;The fixed modal uses the right value and a panel target, and its button skips a page. It used to link to the pricing page with plan: 'premium_trial'. Now it posts straight to checkout:&lt;/p&gt;

&lt;p&gt;&amp;lt;%= button_to subscriptions_path,&lt;br&gt;
    params: { tier: "premium", billing: "monthly" },&lt;br&gt;
    method: :post,&lt;br&gt;
    form: { data: { turbo: false } } do %&amp;gt;&lt;br&gt;
  &amp;lt;%= trial_eligible ? cta : t("modals.upgrade.cta_no_trial") %&amp;gt;&lt;br&gt;
&amp;lt;% end %&amp;gt;&lt;br&gt;
"No credit card required"&lt;br&gt;
One modal context still said "No credit card required" under the trial button. The current trial takes a card up front. The new copy says it plainly: "7 days free, card required. Cancel before day 7 and you pay nothing. We email you the day before it ends."&lt;/p&gt;

&lt;p&gt;The worse case was promising a trial to people who wouldn't get one. Checkout already had a rule: you get the 7-day trial only if you never really subscribed (no subscription, or only abandoned incomplete checkouts). But that rule was written inline in the controller, twice, and copied again into the service that picks who gets trial emails. The pricing page just said "7-day free trial" to everyone.&lt;/p&gt;

&lt;p&gt;So I moved the rule to the model and made the copy ask the same question:&lt;/p&gt;

&lt;h1&gt;
  
  
  The 7-day Premium trial goes to anyone who never really subscribed: no
&lt;/h1&gt;

&lt;h1&gt;
  
  
  subscription, or only abandoned checkouts (incomplete records). Ex-paying
&lt;/h1&gt;

&lt;h1&gt;
  
  
  subscribers don't get a second trial. Shared by checkout and the copy that
&lt;/h1&gt;

&lt;h1&gt;
  
  
  promises the trial, so the promise never outruns the behaviour.
&lt;/h1&gt;

&lt;p&gt;def premium_trial_eligible?&lt;br&gt;
  sub = subscription&lt;br&gt;
  sub.nil? || %w[incomplete incomplete_expired].include?(sub.status.to_s)&lt;br&gt;
end&lt;br&gt;
The pricing page, the modal, the checkout and the email audience now all call premium_trial_eligible?. A former subscriber sees "Get Premium" and "cancel anytime", not a trial they can't have.&lt;/p&gt;

&lt;p&gt;A button in pounds, a charge in euros&lt;br&gt;
1-click credit packs showed a localized price on the button. The PaymentIntent behind it was created like this:&lt;/p&gt;

&lt;p&gt;amount: info[:amount_cents],&lt;br&gt;
currency: "eur",&lt;br&gt;
A UK buyer saw £4.29 on the button, got a payment sheet asking for €4.99, and left. Hosted Checkout was fine because it uses the price's currency_options. The 1-click path didn't. Now it charges in Current.currency through the same PriceBook that renders the button, and a test asserts that a visitor with en-GB gets a gbp intent for 429.&lt;/p&gt;

&lt;p&gt;"1 suggestions left today"&lt;br&gt;
The suggestion page badge could read "1 suggestions". It's now a real i18n plural in every locale, and the client-side countdown gets the localized one/other forms instead of writing a bare number.&lt;/p&gt;

&lt;p&gt;While I was in there: the money states&lt;br&gt;
The same audit found places where a payment's outcome and the app's state could disagree. Those went into their own PR:&lt;/p&gt;

&lt;p&gt;A dispute now takes back what the payment bought: unspent credits from a pack, or the subscription is cancelled. Before, a disputed payment kept its credits or kept a Premium that renewed on the disputed card.&lt;br&gt;
A full refund of a subscription payment cancels the subscription. A partial refund changes nothing.&lt;br&gt;
Checkout refuses to open over a live Stripe subscription. Two tabs (or the back button) could complete two checkouts, and the first subscription kept billing while the app only tracked the second.&lt;br&gt;
If a duplicate still slips through, the newcomer is cancelled, the original kept, and Sentry gets a warning to refund it by hand.&lt;br&gt;
An incomplete subscription (SEPA pending, 3DS abandoned) no longer unlocks Premium. It waits for subscription.updated.&lt;br&gt;
The landing page's JSON-LD still listed a Pro offer at 14.99 EUR. Removed.&lt;br&gt;
If you have read the failed payments my database invented, the incomplete status will look familiar. It keeps coming back.&lt;/p&gt;

&lt;p&gt;Why the copy drifted&lt;br&gt;
Pricing copy is a second implementation of your business rules, written in a language with no compiler. When I changed ai_suggestions_daily_limit or retired a tier, nothing failed. The YAML kept saying what it said.&lt;/p&gt;

&lt;p&gt;Five languages made it five implementations. The BRL prices are the clearest example: someone (me) once localized a string by hand, and from then on it lived outside the system.&lt;/p&gt;

&lt;p&gt;What I do now&lt;br&gt;
Every one of these PRs added tests aimed at the words, not the logic.&lt;/p&gt;

&lt;p&gt;The comparison table test reads the limits from the model:&lt;/p&gt;

&lt;p&gt;premium = build(:user, :premium)&lt;br&gt;
assert_includes I18n.t("subscriptions_page.comparison.row_suggestions_premium"),&lt;br&gt;
                premium.ai_suggestions_daily_limit.to_s&lt;br&gt;
assert_includes I18n.t("subscriptions_page.comparison.row_vto_premium"),&lt;br&gt;
                premium.try_on_monthly_limit.to_s&lt;br&gt;
If I change the limit and forget the table, CI fails.&lt;/p&gt;

&lt;p&gt;Other tests are blunt regex bans over the payment-related locale scopes in all five languages: no \bPro\b, no "5 virtual try-ons" or "5 Look Previews" and their translations, no "unlimited suggestions" in English, no money-back guarantee in any language, no R$ 39,90 back in pt.yml, no plan=pro on the home page, no $0 for a euro or pound visitor. Labels describing an existing grandfathered Pro account are exempt by key.&lt;/p&gt;

&lt;p&gt;They are not elegant. They are cheap, they catch exactly the kind of thing I shipped, and they stop the same strings from coming back.&lt;/p&gt;

&lt;p&gt;One process note. The first PR in this series was supposed to rewrite the wardrobe software page, but it only shipped the deletion of the old view, so the page returned a 204 until the follow-up merged six minutes later. The rewrite uses the same SeoLanding template as my other intent pages, with copy that matches the free plan: unlimited pieces, 3 suggestions a day. Fixing promises is also a good way to break a page.&lt;/p&gt;

&lt;p&gt;The takeaway&lt;br&gt;
When you change a rule in code, grep the copy for it. All the languages. The landing page, the FAQ, the modals, the emails, the structured data.&lt;/p&gt;

&lt;p&gt;Better, make the copy depend on the code. A view that calls premium_trial_eligible? can't promise a trial the checkout won't give. A price that goes through PriceBook can't be in the wrong currency. Anything you can't wire up that way deserves a test that reads the real value and compares.&lt;/p&gt;

&lt;p&gt;My users don't read my controllers. They read the button. For a while the button and the controller disagreed, and the button was the one people believed.&lt;/p&gt;

</description>
      <category>rails</category>
      <category>ruby</category>
      <category>stripe</category>
    </item>
    <item>
      <title>My Bottleneck Was the Offer, Not SEO: A Two-Sided Marketplace Cold Start</title>
      <dc:creator>MarcoBlch</dc:creator>
      <pubDate>Fri, 16 Oct 2026 13:15:55 +0000</pubDate>
      <link>https://dev.to/marcoblch/my-bottleneck-was-the-offer-not-seo-a-two-sided-marketplace-cold-start-58d2</link>
      <guid>https://dev.to/marcoblch/my-bottleneck-was-the-offer-not-seo-a-two-sided-marketplace-cold-start-58d2</guid>
      <description>&lt;p&gt;Originally published on meridianbuild.dev, my build log for FastPass, a paid inbox where people pay to reach you and get refunded if you don't reply.&lt;/p&gt;

&lt;p&gt;For most of this year I treated FastPass like an SEO problem. Landing pages, structured data, prerendering, a sitemap fix that took it from 11 URLs to 56. All of it shipped, all of it is live. Then I ran a few queries against my own database and realized I had been solving the wrong problem. FastPass is a two-sided marketplace, and it was stuck in the oldest trap there is: the cold start. The supply side was too thin for that traffic to land on anything.&lt;/p&gt;

&lt;p&gt;This is the honest version of that diagnosis, and what I am doing instead.&lt;/p&gt;

&lt;p&gt;What FastPass is, in one paragraph&lt;br&gt;
FastPass is a pay-to-reach link. Someone who wants your attention pays to send you a message, the money is held in escrow, and you either reply before your deadline (and keep 75 percent) or the sender is automatically refunded. Stripe Connect moves the money, and I have already written about the webhook bug that nearly froze all of it. Two sides: creators who receive messages, senders who pay to reach them. Neither side is worth anything without the other.&lt;/p&gt;

&lt;p&gt;The query that changed my mind&lt;br&gt;
I had two tables I thought of as "the supply side". profiles holds the people who can actually receive paid messages. directory_requests powers a public directory where visitors can signal demand for someone who is not on FastPass yet ("I would pay $X to reach this person"). The idea was a flywheel: enough demand on a directory page, we invite that person, they claim their profile.&lt;/p&gt;

&lt;p&gt;Here is what I found when I looked. Every entry in the directory was a celebrity or a big-name influencer, people I had seeded as a wishlist. None of them had a FastPass profile behind it. And on the profile side, the accounts that existed were mostly early signups and tests, not creators with a public page and an audience.&lt;/p&gt;

&lt;p&gt;So the directory was a page of famous people who did not know FastPass existed, and the creator side was not ready to catch anyone. Every SEO win I shipped was sending visitors to a storefront with very little on the shelves.&lt;/p&gt;

&lt;p&gt;The build told me first, and I did not listen&lt;br&gt;
The funny part is that my CI had already said this out loud. I opened a pull request to make each creator's payment page the canonical URL and prerender it for every directory slug. It looked like a clean SEO improvement: one strong page per person instead of two weak ones.&lt;/p&gt;

&lt;p&gt;The build broke. My prerender pipeline ends with a smoke test, scripts/verify-prerender.ts, which checks that each snapshotted page contains real content and not a loading skeleton. For every directory slug, the payment page asked the backend for a profile, got "Profile not found", rendered an error, and the smoke test exited with code 1. I checked the database: none of the directory slugs had a profile behind them.&lt;/p&gt;

&lt;p&gt;I closed the PR. The test was right. I was trying to make Google index payment pages for people who could not be paid.&lt;/p&gt;

&lt;p&gt;Why SEO cannot fix a marketplace cold start&lt;br&gt;
The chicken and egg problem in a marketplace is usually framed as "which side do you build first". For FastPass the answer is unambiguous: the creator side. A sender with nobody to pay is a bounce. A creator with nobody paying still gets something (more on that below).&lt;/p&gt;

&lt;p&gt;My old plan had two loops, and both depended on things I did not have:&lt;/p&gt;

&lt;p&gt;Top-down celebrity outreach. Ask famous people to join a product they have never heard of. Their incentive is close to nonexistent, and their inbox is the exact problem FastPass solves, so my email lands in the pile.&lt;br&gt;
Demand-driven invites. Let visitors vote on directory pages until the demand is big enough to impress the target. That needs real traffic first. I did not have real traffic, and the traffic I did have was landing on pages for people who were not there.&lt;br&gt;
SEO compounds an offer that exists. It does not create one. I had it backwards.&lt;/p&gt;

&lt;p&gt;The one data point I trust from that period (from my old Plausible setup, before I moved to Cloudflare Web Analytics) is that the US was my largest country at 38 percent of visits, and that the /pay funnel lost 86 percent of the people who entered it. That leak is real, but fixing it now would mean optimizing a funnel before the right people are at the top of it. It goes on the list for later.&lt;/p&gt;

&lt;p&gt;The new plan: the creator is the distribution channel&lt;br&gt;
The reframe that unlocked things: a creator is not just supply. A creator who puts a FastPass link in their bio brings their own audience. One real creator solves both sides at once, which is the only cheap way out of a two-sided cold start I know of.&lt;/p&gt;

&lt;p&gt;So the target changed from "famous" to "reachable, and already in pain":&lt;/p&gt;

&lt;p&gt;English-speaking, because the US is where my visitors already are.&lt;br&gt;
Small to mid audience, roughly 1k to 50k followers, so a cold message from me actually gets read.&lt;br&gt;
Already drowning in "can I pick your brain?" requests. The pitch is a filter for unpaid advice first and income second. That value exists before the first paid message ever arrives.&lt;br&gt;
The first category I picked is career and job-search coaches. They get the most free-advice requests, most already sell their time, and they live on LinkedIn. I went through about 84 coach profiles and kept 25 verified targets. I have a dedicated page for this angle, a paid inbox for coaches, and another for the broader pain of giving free advice online.&lt;/p&gt;

&lt;p&gt;Concierge onboarding, and the step I cannot do for anyone&lt;br&gt;
The plan is concierge onboarding. FastPass profiles already start in a draft state, so I can prepare a coach's whole page myself: name, photo, bio, price. When they say yes, they get a link that is ready to go.&lt;/p&gt;

&lt;p&gt;Except for one step. Stripe Connect requires the creator to verify their own identity and enter their own bank details. That is KYC, and it cannot be done on someone else's behalf, nor should it be. Until it is done, their public page does not exist. So my first outreach draft, which promised "no effort at all", was wrong. I rewrote it to say upfront: I set up everything, you spend a few minutes connecting your payout account with Stripe, then you put the link in your bio. Announcing that friction early is better than a creator discovering it halfway through and feeling tricked.&lt;/p&gt;

&lt;p&gt;What winning looks like (and where I am)&lt;br&gt;
I set a deliberately small first target: three creators sharing their link, and one real paid message from a stranger. That would be a repeatable playbook and a first case study, which is worth more to me right now than any ranking.&lt;/p&gt;

&lt;p&gt;It is too early for results. The target list is ready and outreach is starting. I am writing this before the outcome on purpose, so I cannot quietly rewrite the hypothesis afterwards.&lt;/p&gt;

&lt;p&gt;The takeaway&lt;br&gt;
If you are building a two-sided marketplace and your growth work feels like it is not compounding, check the supply side in your own database before you ship another landing page. Count the real, active sellers. Check whether the pages you promote point at anyone who can actually transact. My CI caught the gap before I did. Your own tables will probably tell you too, if you ask them the blunt question.&lt;/p&gt;

&lt;p&gt;I will report back with the numbers, whatever they turn out to be. In the meantime, the rest of my SEO notes are in what actually worked for SEO as an indie hacker.&lt;/p&gt;

</description>
      <category>buildinpublic</category>
      <category>saas</category>
      <category>startup</category>
    </item>
    <item>
      <title>The Failed Payments My Database Invented</title>
      <dc:creator>MarcoBlch</dc:creator>
      <pubDate>Thu, 01 Oct 2026 16:50:54 +0000</pubDate>
      <link>https://dev.to/marcoblch/the-failed-payments-my-database-invented-58kd</link>
      <guid>https://dev.to/marcoblch/the-failed-payments-my-database-invented-58kd</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;📖 &lt;em&gt;Originally published on &lt;a href="https://meridianbuild.dev/blog/the-failed-payments-my-database-invented/" rel="noopener noreferrer"&gt;meridianbuild.dev&lt;/a&gt; my engineering blog documenting the real bugs and decisions behind &lt;a href="https://outfitmaker.ai" rel="noopener noreferrer"&gt;OutfitMaker&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I opened the subscriptions table on &lt;a href="https://outfitmaker.ai" rel="noopener noreferrer"&gt;OutfitMaker&lt;/a&gt; one evening and felt my stomach drop. A pile of rows sitting at &lt;code&gt;status: incomplete&lt;/code&gt;, no billing period, no active subscription. To a founder's eye that reads as one thing: &lt;strong&gt;failed payments&lt;/strong&gt;. People tried to pay me and something broke. My webhook must be dropping events. I'm losing money and I don't even know how much.&lt;/p&gt;

&lt;p&gt;I spent the first ten minutes composing the incident in my head which Stripe webhook, which signature check, how far back does it go. Then I did the one thing that turned a panic into a five-line cleanup: I checked the source of truth before I touched anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rows were real. The failures were not.
&lt;/h2&gt;

&lt;p&gt;Here's what those &lt;code&gt;incomplete&lt;/code&gt; rows actually were. When a user clicks "subscribe," &lt;code&gt;SubscriptionsController#find_or_create_stripe_customer&lt;/code&gt; inserts a local &lt;code&gt;Subscription&lt;/code&gt; row &lt;strong&gt;immediately&lt;/strong&gt; before any payment just to hold the Stripe customer id it's about to need. The row starts life as &lt;code&gt;incomplete&lt;/code&gt;. If the user completes checkout, the &lt;code&gt;checkout.session.completed&lt;/code&gt; webhook flips it to &lt;code&gt;active&lt;/code&gt;. If they don't, the row just... stays. Forever. &lt;code&gt;incomplete&lt;/code&gt;, no &lt;code&gt;stripe_subscription_id&lt;/code&gt;, no billing period.&lt;/p&gt;

&lt;p&gt;So every person who clicked "subscribe," saw the Stripe checkout page, and then closed the tab left a permanent artifact in my database that is &lt;strong&gt;indistinguishable, at a glance, from a payment that failed&lt;/strong&gt;. My schema was conflating two completely different events "started a checkout" and "tried to pay and failed" — into one status.&lt;/p&gt;

&lt;p&gt;Before writing a single line of fix, I queried the Stripe API read-only for each of those customer ids. Every one of them: &lt;strong&gt;zero subscriptions, zero paid invoices.&lt;/strong&gt; Nobody was charged. Nobody's payment failed. There was no webhook bug. Nothing was lost. The scary number was pre-payment abandonment wearing the costume of failed revenue.&lt;/p&gt;

&lt;p&gt;The lesson I'd tattoo on the inside of my eyelids: &lt;strong&gt;when your own dashboard tells you money is broken, check the payment processor before you check your code.&lt;/strong&gt; Stripe is the source of truth for whether money moved. My database is, at best, a hopeful cache of that truth and in this case it was actively lying to me.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cleanup, and why it's paranoid on purpose
&lt;/h2&gt;

&lt;p&gt;Once I knew the rows were noise, I wanted them gone but a &lt;code&gt;delete_all&lt;/code&gt; on a subscriptions table is exactly the kind of thing that looks safe at 11pm and isn't. So the scope is deliberately narrow. A row only counts as an abandoned checkout if it is &lt;em&gt;all four&lt;/em&gt; of these:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="no"&gt;ABANDONED_CHECKOUT_GRACE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;day&lt;/span&gt;

&lt;span class="n"&gt;scope&lt;/span&gt; &lt;span class="ss"&gt;:abandoned_checkouts&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="n"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;status: :incomplete&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;stripe_subscription_id: &lt;/span&gt;&lt;span class="kp"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;current_period_end: &lt;/span&gt;&lt;span class="kp"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"subscriptions.created_at &amp;lt; ?"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;ABANDONED_CHECKOUT_GRACE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ago&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 clause is a guardrail:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;status: :incomplete&lt;/code&gt; never touch an active or trialing subscriber.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;stripe_subscription_id: nil&lt;/code&gt; if Stripe knows about a subscription, this row is real; leave it.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;current_period_end: nil&lt;/code&gt; no billing period was ever set, so nothing was ever charged.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;created_at older than one day&lt;/code&gt; &lt;strong&gt;this is the important one.&lt;/strong&gt; Stripe Checkout sessions expire after 24 hours. A row created twenty minutes ago might be a checkout still in flight the user is on the payment page right now. The grace window guarantees I only ever delete checkouts that Stripe itself has already given up on.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And it ships behind a dry run, because I don't trust myself with &lt;code&gt;delete_all&lt;/code&gt; and neither should you:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="n"&gt;task&lt;/span&gt; &lt;span class="ss"&gt;purge_abandoned_checkouts: :environment&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="n"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Subscription&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abandoned_checkouts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to_a&lt;/span&gt;
  &lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;each&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="nb"&gt;puts&lt;/span&gt; &lt;span class="s2"&gt;"  - #&lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; user_id=&lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;user_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; created=&lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;utc&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="no"&gt;ENV&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"DRY_RUN"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;present?&lt;/span&gt;
    &lt;span class="nb"&gt;puts&lt;/span&gt; &lt;span class="s2"&gt;"DRY_RUN set nothing deleted."&lt;/span&gt;
  &lt;span class="k"&gt;else&lt;/span&gt;
    &lt;span class="nb"&gt;puts&lt;/span&gt; &lt;span class="s2"&gt;"Deleted &lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="no"&gt;Subscription&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;purge_abandoned_checkouts!&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; row(s)."&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I ran it with &lt;code&gt;DRY_RUN=1&lt;/code&gt; first, read the list of rows it &lt;em&gt;would&lt;/em&gt; delete, confirmed each one against Stripe, and only then ran it for real. It was a handful of rows seven, in my case. Not a revenue leak. A rounding error with good production values.&lt;/p&gt;

&lt;h2&gt;
  
  
  The real leak was somewhere else entirely
&lt;/h2&gt;

&lt;p&gt;This is the part that actually mattered, and it's why I'm writing about a seven-row cleanup at all.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;incomplete&lt;/code&gt; rows had grabbed my attention precisely because they looked like a problem. But they were a &lt;strong&gt;vanity metric in reverse&lt;/strong&gt; a number that looked like failure but measured nothing about money. Chasing them would have been hours spent hardening a webhook that was working fine.&lt;/p&gt;

&lt;p&gt;The actual leak in the business isn't at the top of the funnel where people click "subscribe" and wander off. Some tab-closing is normal and always will be. The leak is &lt;strong&gt;downstream&lt;/strong&gt;: the checkout-completion rate for people who genuinely intend to pay, and week-one retention for the ones who do. That's where real money is decided, and my schema had been drawing my eye to the wrong end of the pipe.&lt;/p&gt;

&lt;p&gt;So the cleanup did two useful things, and only one of them was deleting rows. It removed the noise, and — more importantly it forced me to define, in code, the difference between "started a checkout" and "became a customer." Once those are distinct, the funnel can finally tell the truth: how many people &lt;em&gt;start&lt;/em&gt; checkout, how many &lt;em&gt;finish&lt;/em&gt;, and how many are still around a week later. Those are the numbers worth an incident. &lt;code&gt;incomplete&lt;/code&gt; never was.&lt;/p&gt;

&lt;p&gt;If you're staring at a scary count in your own database right now, ask two questions before you write any code. Does my payment processor agree that something is actually broken? And is this number measuring money, or just measuring a click I happened to write a row for? Half the "bugs" a solo founder finds at 11pm are the schema describing normal human behavior in alarming language.&lt;/p&gt;

&lt;p&gt;I've been burned by the mirror-image of this too &lt;a href="https://meridianbuild.dev/blog/why-every-funnel-event-read-zero/" rel="noopener noreferrer"&gt;a dashboard that read a flat zero when everything was fine&lt;/a&gt; and I wrote about &lt;a href="https://meridianbuild.dev/blog/from-zero-to-first-paying-customer/" rel="noopener noreferrer"&gt;the messy road to the first customer who actually paid&lt;/a&gt;. The through-line: trust the money, instrument the money, and be very suspicious of any other number that makes you feel something.&lt;/p&gt;

</description>
      <category>rails</category>
      <category>growth</category>
      <category>indiehacker</category>
    </item>
    <item>
      <title>The Soft 404 That Hid My Multi-Tenant SaaS From Google</title>
      <dc:creator>MarcoBlch</dc:creator>
      <pubDate>Thu, 01 Oct 2026 16:40:19 +0000</pubDate>
      <link>https://dev.to/marcoblch/the-soft-404-that-hid-my-multi-tenant-saas-from-google-5f83</link>
      <guid>https://dev.to/marcoblch/the-soft-404-that-hid-my-multi-tenant-saas-from-google-5f83</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Originally published on &lt;a href="https://meridianbuild.dev/blog/soft-404-hid-my-multi-tenant-saas/" rel="noopener noreferrer"&gt;meridianbuild.dev&lt;/a&gt;, my engineering blog where I write up the real bugs and decisions behind the products I build.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Google Search Console has a category that sounds harmless and is not: "Soft 404." It means Google fetched a page, got an HTTP 200, and then decided the content looks like an error anyway. So it quietly refuses to index it. I opened GSC one morning for &lt;a href="https://egliseenligne.fr" rel="noopener noreferrer"&gt;Eglise en Ligne&lt;/a&gt;, my church-website SaaS, and found the report filling up with them across all three of my domains.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup: one codebase, many faces
&lt;/h2&gt;

&lt;p&gt;Eglise en Ligne is multi-tenant and multi-domain. Each church gets its own public site on a subdomain (&lt;code&gt;demo.egliseenligne.fr&lt;/code&gt;), and the marketing site runs on three country domains: &lt;a href="https://egliseenligne.fr" rel="noopener noreferrer"&gt;egliseenligne.fr&lt;/a&gt; for France, &lt;a href="https://iglesiaenlinea.es" rel="noopener noreferrer"&gt;iglesiaenlinea.es&lt;/a&gt; for Spain, and &lt;a href="https://igrejaemlinha.pt" rel="noopener noreferrer"&gt;igrejaemlinha.pt&lt;/a&gt; for Portugal. It is a Next.js App Router project, and a middleware decides, per request, whether a hostname is a tenant church or a marketing domain, injecting &lt;code&gt;x-church-*&lt;/code&gt; headers when it is a tenant.&lt;/p&gt;

&lt;p&gt;The tenant public pages live under a &lt;code&gt;[locale]&lt;/code&gt; route group. On a real church subdomain the middleware sets a church context, and the page renders that church. So far so good.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug: a 200 that means "nothing here"
&lt;/h2&gt;

&lt;p&gt;The problem showed up when Google crawled tenant-shaped URLs on the marketing domain, like &lt;code&gt;egliseenligne.fr/fr&lt;/code&gt; or &lt;code&gt;egliseenligne.fr/blog&lt;/code&gt;. Those paths match the &lt;code&gt;[locale]&lt;/code&gt; route, but there is no church context on a marketing domain. Here is what the page did in that case:&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;church&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getChurchContext&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;church&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="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Church not found&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read that closely. When there is no church, it returns a div. A div is a successful render, so the response is HTTP 200. To a visitor it says "Church not found." To Googlebot it says "200 OK, here is your content," and the content is a bare error message. That is the textbook recipe for a Soft 404, and it was happening on every locale path, on all three ccTLDs at once.&lt;/p&gt;

&lt;p&gt;Worse, these pages carried &lt;code&gt;robots: index, follow&lt;/code&gt;, so I was actively inviting Google to crawl pages that were pure noise. Crawl budget I did not have to spare was going to &lt;code&gt;/fr&lt;/code&gt;, &lt;code&gt;/es&lt;/code&gt;, &lt;code&gt;/blog&lt;/code&gt;, &lt;code&gt;/fr/events&lt;/code&gt;, and friends.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: return a real 404
&lt;/h2&gt;

&lt;p&gt;The honest signal for "this page does not exist" is a 404 status, not a 200 with sad text. Next.js gives you exactly that with &lt;code&gt;notFound()&lt;/code&gt; from &lt;code&gt;next/navigation&lt;/code&gt;. The trick was choosing where to put it. Instead of patching every tenant page, I put one guard in the &lt;code&gt;[locale]&lt;/code&gt; layout, which wraps all of them:&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="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;notFound&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;next/navigation&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;church&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getChurchContext&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="c1"&gt;// No church context means a tenant route was hit on the marketing domain.&lt;/span&gt;
&lt;span class="c1"&gt;// Return a real 404 instead of a 200 "not found" page.&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;church&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;notFound&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One line, one file, every tenant sub-page covered at once. Real church subdomains are untouched, because the middleware still injects their church context, so &lt;code&gt;getChurchContext()&lt;/code&gt; returns a church and the guard never fires. After deploy I checked all three domains, and &lt;code&gt;/fr&lt;/code&gt; now returns a clean 404 on each, while the marketing pages keep their 200. This is the same "fix it at the layer that owns the concern" instinct I wrote about in &lt;a href="https://meridianbuild.dev/blog/why-every-funnel-event-read-zero/" rel="noopener noreferrer"&gt;why every funnel event read zero&lt;/a&gt;: the bug repeated across many files, so the fix belonged above all of them, not inside each one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;A 200 is a promise. It tells crawlers "this is a real page worth keeping." If your code can render an empty or error state, make sure that state carries the status code that matches the truth. On a multi-tenant app the trap is sneaky, because the exact same route is a real page on one hostname and nothing on another. Guard it once, at the boundary, and let the framework send the right signal.&lt;/p&gt;

&lt;p&gt;Two days after shipping this, the Soft 404 count in Search Console started draining on its own. The pages were finally telling Google the truth.&lt;/p&gt;

</description>
      <category>frontend</category>
      <category>architecture</category>
    </item>
    <item>
      <title>My Stripe Webhooks Were Failing 96% of the Time</title>
      <dc:creator>MarcoBlch</dc:creator>
      <pubDate>Tue, 22 Sep 2026 19:05:54 +0000</pubDate>
      <link>https://dev.to/marcoblch/my-stripe-webhooks-were-failing-96-of-the-time-2pdo</link>
      <guid>https://dev.to/marcoblch/my-stripe-webhooks-were-failing-96-of-the-time-2pdo</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Originally published on &lt;a href="https://meridianbuild.dev/blog/my-stripe-webhooks-were-failing-96-percent/" rel="noopener noreferrer"&gt;meridianbuild.dev&lt;/a&gt;, my engineering blog where I write up the real bugs and decisions behind the products I build.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;FastPass only works if money moves correctly. A sender pays, the money sits in escrow, and a Stripe webhook is the thing that tells my backend the payment actually happened. So when I finally opened the webhook logs one evening, my stomach dropped. Around 96 percent of them were failing. Money was landing in Stripe and my app had no idea.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://fastpass.email" rel="noopener noreferrer"&gt;FastPass&lt;/a&gt; is a pay to reach platform. Someone pays to guarantee a reply from a busy person, the money is held in escrow, and it gets released or refunded based on what happens next. Stripe and Stripe Connect handle the money and the 75/25 split between the recipient and the platform. Every state change, a payment captured, a transfer made, a refund issued, arrives as a Stripe webhook. If the webhooks do not land, the whole machine freezes.&lt;/p&gt;

&lt;p&gt;The webhook lives in a Supabase Edge Function, which runs on Deno, not Node. Hold that thought.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug
&lt;/h2&gt;

&lt;p&gt;Every webhook starts by verifying Stripe's signature, so I know the call is really from Stripe and not a forged request. The textbook line looks like this:&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;webhooks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;constructEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;signature&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;webhookSecret&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That line was fine on my laptop. In production it threw on every real event. Verification failed, so I rejected the event, so the payment update never ran. The dashboard showed green deploys, Stripe showed successful charges, and my database quietly fell behind reality.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trap
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;constructEvent&lt;/code&gt; verifies the signature with an HMAC. In Node, that HMAC is computed synchronously. But Deno, and every edge runtime (Vercel Edge, Cloudflare Workers, Supabase Functions), only gives you the Web Crypto API, and Web Crypto is asynchronous. The synchronous helper has no synchronous crypto to call, so it simply cannot do the work. It does not warn you. It throws, and you are left blaming your webhook secret.&lt;/p&gt;

&lt;p&gt;So "96 percent of my webhooks are invalid" was never a Stripe problem and never a security problem. It was me calling a synchronous function in a place where crypto only exists in async form.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix is one word
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;event&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;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;webhooks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;constructEventAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;signature&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;webhookSecret&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;constructEventAsync&lt;/code&gt; reaches for the Web Crypto API, awaits it, and returns the verified event. Same inputs, same security, one &lt;code&gt;await&lt;/code&gt;. The moment it shipped, signature verification went green and the backlog of stuck payments started clearing.&lt;/p&gt;

&lt;p&gt;Two things I added while I was in there:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Idempotency.&lt;/strong&gt; Stripe retries webhooks, and now that they were actually being accepted, I did not want the same event processed twice. A double payout is a very direct way to lose money. Before doing anything, I check a &lt;code&gt;webhook_events&lt;/code&gt; table for the event id and skip if I have seen it:&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="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;seen&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;supabase&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;webhook_events&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;event_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;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;single&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;skipped&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;already_processed&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;&lt;strong&gt;Return 200 even on a rejected signature.&lt;/strong&gt; Stripe retries anything that is not a 2xx, aggressively. During the incident that meant retry storms piling onto a broken endpoint. Now a bad signature is logged and answered with 200, so Stripe stops hammering while I investigate. It is one of the rare cases where a 200 on a failure is correct, because the alternative is a self inflicted denial of service.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;If you move a webhook, or any signature check, into an edge or Deno runtime and it starts failing for no obvious reason, suspect the crypto before you suspect your secret. Edge runtimes expose Web Crypto, which is async only. SDKs that grew up on Node ship synchronous helpers that quietly do not work there. The fix is almost always the same shape: find the &lt;code&gt;...Async&lt;/code&gt; variant and await it. Stripe has &lt;code&gt;constructEventAsync&lt;/code&gt;, and most libraries have an equivalent once you go looking.&lt;/p&gt;

&lt;p&gt;The scariest bugs are the ones where nothing crashes. My app was up, Stripe was happy, money was moving, and the only symptom was a number in a log I had not thought to read. I hit the mirror image of this on the same project, &lt;a href="https://meridianbuild.dev/blog/sitemap-advertised-11-pages-site-had-56/" rel="noopener noreferrer"&gt;a sitemap that quietly advertised the wrong pages&lt;/a&gt;, where everything returned 200 and was still wrong.&lt;/p&gt;

&lt;p&gt;If you want to see what it powers: FastPass lets you &lt;a href="https://fastpass.email/get-paid-to-answer-messages" rel="noopener noreferrer"&gt;get paid to answer messages&lt;/a&gt; instead of drowning in a free inbox.&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>testing</category>
    </item>
    <item>
      <title>Why Every Funnel Event in My Analytics Read Zero</title>
      <dc:creator>MarcoBlch</dc:creator>
      <pubDate>Tue, 22 Sep 2026 12:35:00 +0000</pubDate>
      <link>https://dev.to/marcoblch/why-every-funnel-event-in-my-analytics-read-zero-1ed0</link>
      <guid>https://dev.to/marcoblch/why-every-funnel-event-in-my-analytics-read-zero-1ed0</guid>
      <description>&lt;p&gt;📖 &lt;em&gt;Originally published on &lt;a href="https://meridianbuild.dev/blog/why-every-funnel-event-read-zero/" rel="noopener noreferrer"&gt;meridianbuild.dev&lt;/a&gt; my engineering blog documenting the real bugs and decisions behind &lt;a href="https://outfitmaker.ai" rel="noopener noreferrer"&gt;OutfitMaker&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The worst kind of broken instrumentation is the kind that reports a number. A blank dashboard makes you check the wiring. A dashboard full of zeros makes you think your product is just quiet.&lt;/p&gt;

&lt;p&gt;For as long as I'd had funnel tracking in &lt;a href="https://outfitmaker.ai" rel="noopener noreferrer"&gt;OutfitMaker&lt;/a&gt;, every custom event I cared about read zero. Signup: 0. Activation: 0. First Item Uploaded: 0. The onboarding-choice events: 0. Meanwhile pageviews ticked up normally and real people were clearly signing up, uploading clothes, and getting outfit suggestions. I'd half-convinced myself the funnel was just thin. It wasn't thin. It was lying.&lt;/p&gt;

&lt;p&gt;Here's the teardown. It took two PRs and turned out to be four separate bugs stacked on top of each other.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the events were supposed to fire
&lt;/h2&gt;

&lt;p&gt;OutfitMaker runs &lt;a href="https://outfitmaker.ai" rel="noopener noreferrer"&gt;Kaunta&lt;/a&gt;, a self-hosted, privacy-friendly analytics script, loaded once per page. Automatic pageviews work out of the box. For funnel milestones I used a server-driven pattern: a controller sets &lt;code&gt;flash[:analytics_event] = "Signup"&lt;/code&gt;, and the layout turns that flash into a &lt;code&gt;kaunta.track()&lt;/code&gt; call on the next page.&lt;/p&gt;

&lt;p&gt;The layout snippet looked like this and it had been copy-pasted into three layouts:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight erb"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;script &lt;/span&gt;&lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="cp"&gt;&amp;lt;%=&lt;/span&gt; &lt;span class="n"&gt;kaunta_script_url&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;
  &lt;span class="na"&gt;data-website-id=&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="cp"&gt;&amp;lt;%=&lt;/span&gt; &lt;span class="n"&gt;kaunta_website_id&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;
  &lt;span class="na"&gt;async&lt;/span&gt; &lt;span class="na"&gt;defer&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/script&amp;gt;&lt;/span&gt;

&lt;span class="cp"&gt;&amp;lt;%&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;flash&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;:analytics_event&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;present?&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;script&amp;gt;&lt;/span&gt;
  &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;DOMContentLoaded&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;kaunta&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;kaunta&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;track&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="cp"&gt;&amp;lt;%=&lt;/span&gt; &lt;span class="n"&gt;flash&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;:analytics_event&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="cp"&gt;&amp;lt;%=&lt;/span&gt; &lt;span class="no"&gt;I18n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;locale&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;span class="cp"&gt;&amp;lt;%&lt;/span&gt; &lt;span class="k"&gt;end&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It reads fine. It is also wrong in three independent ways, each one enough on its own to zero out the event.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 1: Turbo never re-fires DOMContentLoaded
&lt;/h2&gt;

&lt;p&gt;Every one of these milestones arrives on a page reached by a &lt;code&gt;redirect_to&lt;/code&gt; after a form POST. You submit the signup form, the server creates the user, sets the flash, and redirects you to the next page. In a &lt;a href="https://meridianbuild.dev/blog/hotwire-over-react-for-a-solo-dev/" rel="noopener noreferrer"&gt;Hotwire app&lt;/a&gt; that redirect is a &lt;strong&gt;Turbo visit&lt;/strong&gt;, not a full document load.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;DOMContentLoaded&lt;/code&gt; fires exactly once, on a cold load. Turbo swaps the &lt;code&gt;&amp;lt;body&amp;gt;&lt;/code&gt; and does &lt;strong&gt;not&lt;/strong&gt; re-evaluate &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt; scripts on navigation. So on the page that actually carried the flash, the listener was being registered after the event it was waiting for had already passed — and then never ran again. The &lt;code&gt;track()&lt;/code&gt; call was real, reachable code that simply never executed.&lt;/p&gt;

&lt;p&gt;This is the same family of silent Turbo failure I &lt;a href="https://meridianbuild.dev/blog/duplicate-turbo-frame-bug-broke-three-features/" rel="noopener noreferrer"&gt;wrote about with duplicate frame ids&lt;/a&gt;: nothing throws, the HTTP response is a clean 200, and the gap is a layer of intent the framework doesn't share with you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 2: the async race
&lt;/h2&gt;

&lt;p&gt;Suppose the page &lt;em&gt;did&lt;/em&gt; cold-load a direct visit, say. The Kaunta loader is &lt;code&gt;async defer&lt;/code&gt;, so &lt;code&gt;window.kaunta&lt;/code&gt; is usually still undefined when &lt;code&gt;DOMContentLoaded&lt;/code&gt; runs. The guard:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;kaunta&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* track */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;is a coin flip you lose most of the time. When the script wasn't ready, the event was dropped on the floor with no retry. No error, no queue, nothing. So even the rare cold-load path was unreliable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 3: the layout the funnel actually lands on
&lt;/h2&gt;

&lt;p&gt;The snippet lived in the &lt;code&gt;application&lt;/code&gt;, &lt;code&gt;landing&lt;/code&gt;, and &lt;code&gt;blog&lt;/code&gt; layouts. But a successful signup redirects to &lt;code&gt;onboarding_path&lt;/code&gt;, which renders under the &lt;strong&gt;&lt;code&gt;onboarding&lt;/code&gt; layout&lt;/strong&gt; and that layout had no Kaunta at all. Not the loader, not the trigger.&lt;/p&gt;

&lt;p&gt;So for the single most important event in the whole funnel, &lt;code&gt;Signup&lt;/code&gt;, all three bugs were academic. The page it landed on couldn't have tracked anything even if the script and the timing had been perfect. &lt;code&gt;Signup&lt;/code&gt; wasn't unreliable. It was structurally impossible.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: fire from Stimulus, share one partial
&lt;/h2&gt;

&lt;p&gt;The repair (&lt;code&gt;9dec828&lt;/code&gt;, PR #85) moves the trigger out of the &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt; and into a Stimulus controller that runs from the &lt;code&gt;&amp;lt;body&amp;gt;&lt;/code&gt;. Stimulus &lt;code&gt;connect()&lt;/code&gt; runs on &lt;strong&gt;every&lt;/strong&gt; Turbo navigation, which kills Bug 1. And instead of a one-shot guard, it polls for &lt;code&gt;window.kaunta&lt;/code&gt;, which kills Bug 2:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Controller&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@hotwired/stimulus&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;extends&lt;/span&gt; &lt;span class="nx"&gt;Controller&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="nx"&gt;values&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;props&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;default&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;nameValue&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;
    &lt;span class="c1"&gt;// Skip Turbo's cached *preview* render so a snapshot that still&lt;/span&gt;
    &lt;span class="c1"&gt;// contains this element doesn't double-fire on the preview pass.&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;documentElement&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;hasAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;data-turbo-preview&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fired&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;waitForKaunta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;25&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// ~5s max (25 × 200ms)&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nf"&gt;waitForKaunta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;attemptsLeft&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fired&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;kaunta&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;kaunta&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;track&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;function&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fired&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
      &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;kaunta&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;track&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;nameValue&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;propsValue&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;attemptsLeft&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pollTimer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;waitForKaunta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;attemptsLeft&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;data-turbo-preview&lt;/code&gt; check is the kind of detail you only learn by getting it wrong: Turbo renders a cached preview of a page before the fresh version arrives, and if your tracking element is in that snapshot it'll fire on the preview too. Bail on the preview pass; fire on the real render.&lt;/p&gt;

&lt;p&gt;The flash is surfaced by a tiny partial that just mounts the controller:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight erb"&gt;&lt;code&gt;&lt;span class="cp"&gt;&amp;lt;%&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;flash&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;:analytics_event&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;present?&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;hidden&lt;/span&gt;
       &lt;span class="na"&gt;data-controller=&lt;/span&gt;&lt;span class="s"&gt;"kaunta-event"&lt;/span&gt;
       &lt;span class="na"&gt;data-kaunta-event-name-value=&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="cp"&gt;&amp;lt;%=&lt;/span&gt; &lt;span class="n"&gt;flash&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;:analytics_event&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;
       &lt;span class="na"&gt;data-kaunta-event-props-value=&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="cp"&gt;&amp;lt;%=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="ss"&gt;locale: &lt;/span&gt;&lt;span class="no"&gt;I18n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;locale&lt;/span&gt; &lt;span class="p"&gt;}.&lt;/span&gt;&lt;span class="nf"&gt;to_json&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;span class="cp"&gt;&amp;lt;%&lt;/span&gt; &lt;span class="k"&gt;end&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Bug 3 gets fixed by construction: the loader and the trigger become two shared partials (&lt;code&gt;shared/_kaunta_head&lt;/code&gt; and &lt;code&gt;shared/_kaunta_event&lt;/code&gt;), rendered in &lt;strong&gt;all four&lt;/strong&gt; user-facing layouts &lt;code&gt;application&lt;/code&gt;, &lt;code&gt;landing&lt;/code&gt;, &lt;code&gt;blog&lt;/code&gt;, and the previously-naked &lt;code&gt;onboarding&lt;/code&gt;. That deletes the three-way copy-paste and closes the gap in the same move. Three layouts each lost ~30 lines.&lt;/p&gt;

&lt;p&gt;I can't run the suite on the box I write these from (no Ruby, no Node it &lt;a href="https://meridianbuild.dev/blog/rails-8-migration-on-a-live-product/" rel="noopener noreferrer"&gt;validates on deploy&lt;/a&gt;), so I locked the server side of the contract with an integration test instead of trusting the browser behaviour to a comment:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="nb"&gt;test&lt;/span&gt; &lt;span class="s2"&gt;"first item upload emits the kaunta-event trigger with the event name"&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="n"&gt;post&lt;/span&gt; &lt;span class="n"&gt;wardrobe_items_path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;params: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="ss"&gt;wardrobe_item: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="ss"&gt;image: &lt;/span&gt;&lt;span class="n"&gt;image&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="n"&gt;follow_redirect!&lt;/span&gt;

  &lt;span class="n"&gt;assert_select&lt;/span&gt; &lt;span class="s2"&gt;"[data-controller='kaunta-event']"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;count: &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;
  &lt;span class="n"&gt;assert_select&lt;/span&gt; &lt;span class="s2"&gt;"[data-kaunta-event-name-value='First Item Uploaded']"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;count: &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;

  &lt;span class="c1"&gt;# Regression guard: the old, broken inline tracker must be gone.&lt;/span&gt;
  &lt;span class="n"&gt;assert_no_match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/addEventListener\(['"]DOMContentLoaded['"]/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s2"&gt;"flash analytics event must not be tracked via a DOMContentLoaded script"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;assert_no_match&lt;/code&gt; is the part I care about most. It's not testing what the page does; it's testing that the broken pattern can never come back through a careless copy-paste.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 4: Signup was still zero
&lt;/h2&gt;

&lt;p&gt;I deployed, watched &lt;code&gt;First Item Uploaded&lt;/code&gt; and the onboarding-choice events start landing events that had &lt;strong&gt;never&lt;/strong&gt; been anything but zero and felt good about it. Then I looked again the next day. &lt;code&gt;Signup&lt;/code&gt; was still zero.&lt;/p&gt;

&lt;p&gt;The first three bugs were dead. This was a fourth one wearing their clothes.&lt;/p&gt;

&lt;p&gt;The cause: &lt;strong&gt;100% of OutfitMaker's signups come through Google OAuth.&lt;/strong&gt; Nobody uses the email/password form. And the &lt;code&gt;Signup&lt;/code&gt; event was only ever set in &lt;code&gt;RegistrationsController#create&lt;/code&gt; the email/password path. The OAuth path, &lt;code&gt;OmniauthCallbacksController&lt;/code&gt;, set no event and, it turned out, sent no admin notification either. Which also explained a separate mystery: I never got an email when someone signed up. I'd been blind to my own new users because I'd only instrumented the door nobody walks through.&lt;/p&gt;

&lt;p&gt;The fix (&lt;code&gt;dca116a&lt;/code&gt;, PR #88) pulls the side effects into a shared concern both controllers include:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;module&lt;/span&gt; &lt;span class="nn"&gt;SignupSideEffects&lt;/span&gt;
  &lt;span class="kp"&gt;extend&lt;/span&gt; &lt;span class="no"&gt;ActiveSupport&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Concern&lt;/span&gt;

  &lt;span class="kp"&gt;private&lt;/span&gt;

  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;handle_new_signup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update_column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:locale&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;I18n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to_s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;flash&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;:analytics_event&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Signup"&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="no"&gt;Rails&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test?&lt;/span&gt;

    &lt;span class="no"&gt;UserMailer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;welcome_email&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;deliver_later&lt;/span&gt;
    &lt;span class="no"&gt;AdminMailer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new_user_signup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;deliver_later&lt;/span&gt; &lt;span class="k"&gt;unless&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;admin?&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two subtleties in the OAuth side made this more than a one-liner.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Telling a new signup from a returning login.&lt;/strong&gt; &lt;code&gt;from_omniauth&lt;/code&gt; both creates new users and logs in existing ones, and it'll also link OAuth to an existing email account. Only a genuinely new record should fire &lt;code&gt;Signup&lt;/code&gt;. Rails 7's &lt;code&gt;previously_new_record?&lt;/code&gt; is exactly that signal true only on the request that created the row:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="vi"&gt;@user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;previously_new_record?&lt;/span&gt;
  &lt;span class="n"&gt;handle_new_signup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="vi"&gt;@user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="n"&gt;sign_in&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="vi"&gt;@user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;event: :authentication&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="n"&gt;redirect_to&lt;/span&gt; &lt;span class="n"&gt;onboarding_path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;step: &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;else&lt;/span&gt;
  &lt;span class="n"&gt;flash&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;:notice&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"flash.omniauth.success"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;provider: &lt;/span&gt;&lt;span class="n"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="n"&gt;sign_in_and_redirect&lt;/span&gt; &lt;span class="vi"&gt;@user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;event: :authentication&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Flash survives exactly one redirect.&lt;/strong&gt; Devise's &lt;code&gt;sign_in_and_redirect&lt;/code&gt; would send a new user to root, which then redirects to onboarding two hops. &lt;code&gt;flash[:analytics_event]&lt;/code&gt; only survives one. The second redirect would silently eat it, and I'd be right back to a zeroed &lt;code&gt;Signup&lt;/code&gt; for a subtly different reason. So the new-signup branch redirects &lt;strong&gt;straight&lt;/strong&gt; to onboarding in a single hop, and the flash lives long enough for the Stimulus trigger to fire.&lt;/p&gt;

&lt;p&gt;I covered the new-vs-returning logic at the model level too, because it's the load-bearing assumption a brand-new user, a returning user, and an OAuth-linked existing account each get their own test asserting whether &lt;code&gt;previously_new_record?&lt;/code&gt; is set.&lt;/p&gt;

&lt;h2&gt;
  
  
  The result
&lt;/h2&gt;

&lt;p&gt;After the second deploy, Kaunta started showing real signups where there had only ever been a flat line and the rest of the chain (Activation, the post-wow upgrade prompts) lit up with it. OutfitMaker is small enough that these are modest counts, but the point isn't the magnitude. It's that the dashboard finally describes the product instead of contradicting it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;Instrumentation that silently reports zero is worse than none, because you act on it. I spent real time wondering why my "funnel was thin" when the funnel was fine and the funnel &lt;em&gt;meter&lt;/em&gt; was unplugged. Two rules I'd hand my past self:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;In a Turbo app, never fire analytics from a &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt; &lt;code&gt;DOMContentLoaded&lt;/code&gt; script.&lt;/strong&gt; Your most important events arrive on Turbo visits, where that listener is already too late. Fire from a Stimulus &lt;code&gt;connect()&lt;/code&gt; and wait for your script to be ready instead of guarding once and giving up.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A funnel's top must cover every path into it.&lt;/strong&gt; I instrumented the signup flow I wrote first and assumed it was &lt;em&gt;the&lt;/em&gt; signup flow. The one my users actually used was untouched. Count the doors before you trust the turnstile.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The quiet bugs are the expensive ones. A crash gets fixed the same day. A zero that should be a number can sit there for as long as you're willing to believe your product is just quiet.&lt;/p&gt;

</description>
      <category>rails</category>
      <category>frontend</category>
      <category>bugs</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
