<?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: phi_blankslate</title>
    <description>The latest articles on DEV Community by phi_blankslate (@phi_blankslate).</description>
    <link>https://dev.to/phi_blankslate</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%2F4131100%2F1bcb0386-de4c-41f9-ab33-bb348444cd2d.png</url>
      <title>DEV Community: phi_blankslate</title>
      <link>https://dev.to/phi_blankslate</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/phi_blankslate"/>
    <language>en</language>
    <item>
      <title>My Stripe idempotency key was correct. It just stopped working after 24 hours.</title>
      <dc:creator>phi_blankslate</dc:creator>
      <pubDate>Wed, 07 Oct 2026 04:24:05 +0000</pubDate>
      <link>https://dev.to/phi_blankslate/my-stripe-idempotency-key-was-correct-it-just-stopped-working-after-24-hours-2h35</link>
      <guid>https://dev.to/phi_blankslate/my-stripe-idempotency-key-was-correct-it-just-stopped-working-after-24-hours-2h35</guid>
      <description>&lt;p&gt;I added a reward feature that pays out through Stripe's customer balance, and I did what every payments guide tells you to do: I passed an idempotency key on the write that moves money. A security review pass then found a double-payment path anyway, and the key was not wrong. The key was correct, unique, and deterministic. It simply had an expiry date that my retry design ignored.&lt;/p&gt;

&lt;p&gt;This post is about the gap between "I used an idempotency key" and "this operation is idempotent." They are not the same claim, and the distance between them is a time window.&lt;/p&gt;

&lt;p&gt;The stack is Next.js, Drizzle, Neon and the Stripe Node SDK, but the lesson has nothing to do with any of those.&lt;/p&gt;

&lt;h2&gt;
  
  
  The feature
&lt;/h2&gt;

&lt;p&gt;When a referred user's paid subscription goes through, the referrer gets a credit on their Stripe customer balance. In Stripe terms that is a negative customer balance transaction, which Stripe automatically applies to the customer's next invoice.&lt;/p&gt;

&lt;p&gt;The reward logic lives in one function, &lt;code&gt;rewardReferralIfEligible&lt;/code&gt;, and it can be called from three places: a webhook for &lt;code&gt;customer.subscription.updated&lt;/code&gt;, a webhook for &lt;code&gt;invoice.paid&lt;/code&gt;, and a manual sweep script I run to catch anything the webhooks missed. Because the same function can be reached from several triggers, it has to be safe to call repeatedly. That requirement is where the story starts.&lt;/p&gt;

&lt;p&gt;There is a &lt;code&gt;referrals&lt;/code&gt; table with a &lt;code&gt;status&lt;/code&gt; column: &lt;code&gt;pending&lt;/code&gt; or &lt;code&gt;rewarded&lt;/code&gt;. The function's job is to move a row from &lt;code&gt;pending&lt;/code&gt; to &lt;code&gt;rewarded&lt;/code&gt; exactly once and, as a side effect of that single transition, create exactly one balance transaction on the referrer's Stripe customer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer one: make the database the gate
&lt;/h2&gt;

&lt;p&gt;The first thing I wrote was a compare-and-swap. Before touching Stripe, claim the row:&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;updated&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;db&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;referrals&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;rewarded&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;rewardedAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="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="nf"&gt;and&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="nx"&gt;referrals&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;referral&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;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;referrals&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pending&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;returning&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;updated&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;WHERE status = 'pending'&lt;/code&gt; clause is the important part. If two webhook deliveries race, both run this UPDATE, but only one of them gets a row back from &lt;code&gt;.returning()&lt;/code&gt;. The loser sees an empty array and exits. Postgres serializes the two updates on the same row, so exactly one caller proceeds. This also means the "already processed" check and the "claim" are one atomic statement instead of a read followed by a write, which is the classic time-of-check-to-time-of-use hole.&lt;/p&gt;

&lt;p&gt;So far so good. The winner now owns the row and goes on to talk to Stripe.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer two: the idempotency key
&lt;/h2&gt;

&lt;p&gt;The winner then creates the balance transaction. This is the call that actually moves money, so this is where I put the key:&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;tx&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;customers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createBalanceTransaction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;referrerCustomerId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;REFERRAL_REWARD_CENTS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;usd&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Referral reward — referred user converted to Pro Annual &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;marker&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;idempotencyKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`referral-reward-&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;referral&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="s2"&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 key is derived from &lt;code&gt;referral.id&lt;/code&gt;, which is a UUID assigned once when the referral row is created and never changes. That is the right shape for a key: the same logical operation always produces the same key, and different operations never collide. If the HTTP response is lost and the call is retried, Stripe recognizes the key and returns the original result instead of creating a second transaction.&lt;/p&gt;

&lt;p&gt;I stopped here at first and considered the problem solved. Database CAS stops concurrent callers. The idempotency key stops duplicate writes to Stripe. Two layers.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rollback that made it dangerous
&lt;/h2&gt;

&lt;p&gt;There is a third piece of code, and it is the one I wrote for a good reason. The CAS flips the row to &lt;code&gt;rewarded&lt;/code&gt; &lt;em&gt;before&lt;/em&gt; the Stripe call. That ordering is deliberate: it is what makes concurrent callers safe. But it means that if the Stripe call then fails, the database says "rewarded" while no credit exists. That is a silent loss for the referrer, and the row is now permanently skipped because it is no longer &lt;code&gt;pending&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;So the error path reverts the row (trimmed to the relevant lines):&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="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;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;referrals&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pending&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;rewardedAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
      &lt;span class="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="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;referrals&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;referral&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="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;rollbackErr&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="s2"&gt;`[referral] CRITICAL: rollback to pending also failed for referral &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;referral&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="s2"&gt;. `&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
        &lt;span class="s2"&gt;`Row is stuck as "rewarded" without an actual credit. Manual reconciliation required.`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;originalError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;rollbackError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;rollbackErr&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nx"&gt;err&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;Rolling back to &lt;code&gt;pending&lt;/code&gt; means the next trigger, whether it is a later webhook or my sweep script, will pick the row up again and retry. That is what I want for a clean failure like a network error before Stripe received anything.&lt;/p&gt;

&lt;p&gt;Now put the two mechanisms next to each other and ask what happens in the ambiguous case.&lt;/p&gt;

&lt;p&gt;An ambiguous failure is when the request reaches Stripe, Stripe creates the balance transaction, and the response never makes it back to me. A timeout, a dropped connection, a function that gets killed mid-flight. From my code's point of view the call threw. From Stripe's point of view the credit exists.&lt;/p&gt;

&lt;p&gt;My catch block cannot tell these two cases apart. It sees an exception and rolls the row back to &lt;code&gt;pending&lt;/code&gt;. Now the database says "not rewarded" and Stripe says "rewarded", and the row is waiting for a retry.&lt;/p&gt;

&lt;p&gt;If the retry happens within 24 hours, the idempotency key saves me: Stripe sees the same key, returns the original transaction, and nothing is duplicated.&lt;/p&gt;

&lt;p&gt;If the retry happens later, it does not.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 24-hour window
&lt;/h2&gt;

&lt;p&gt;Stripe's documentation says that keys can be removed from the system automatically once they are at least 24 hours old, and that Stripe generates a new request if a key is reused after the original was pruned. That is the whole mechanism. After the window, the same key means "new request," and a new request creates a new balance transaction.&lt;/p&gt;

&lt;p&gt;I had read that sentence before. I had even written a comment in the code saying the key protects retries "within 24h." What I had not done is ask who controls how long a row can stay in &lt;code&gt;pending&lt;/code&gt; after a rollback.&lt;/p&gt;

&lt;p&gt;The answer was: not me, at least not tightly. A row reverted to &lt;code&gt;pending&lt;/code&gt; waits for the next trigger. The webhook triggers are driven by Stripe's event delivery, which I do not control. The sweep script runs when I run it. If nothing touches that row for a day and a bit, and then the next trigger arrives, the function runs the whole sequence again with an expired key. The sequence passes every check I had written: the row is &lt;code&gt;pending&lt;/code&gt;, the subscription is eligible, the CAS succeeds, and Stripe happily creates a second credit.&lt;/p&gt;

&lt;p&gt;The reviewer's finding, stated plainly: a rollback-and-retry design plus a 24-hour key means the retry safety net has a hole that opens exactly when the system has been quiet the longest. And "quiet for a day" is not an exotic condition. It is what a weekend looks like.&lt;/p&gt;

&lt;p&gt;This is the part worth sitting with. Every individual piece of the code was defensible. The CAS was correct. The key was correct. The rollback was correct. The bug lived in the interaction between the rollback's retry delay, which is unbounded, and the key's lifetime, which is bounded.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: check Stripe's state, not your own memory
&lt;/h2&gt;

&lt;p&gt;An idempotency key is a cache of "I already did this" held by someone else, with a TTL. When the question is "did this money already move," the authoritative answer is the ledger, not the cache. So before creating the balance transaction, I now look at what is actually on the referrer's customer balance:&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;marker&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`(referral &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;referral&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="s2"&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;allTxs&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;customers&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;listBalanceTransactions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;referrerCustomerId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;autoPagingToArray&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;allTxs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s2"&gt;`referrer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;referrerCustomerId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; has 1000+ balance transactions; manual reconciliation required`&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;existing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;allTxs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;tx&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="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;marker&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;existing&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;warn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s2"&gt;`[referral] credit already present on Stripe for referral &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;referral&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="s2"&gt;; marked rewarded without a new credit`&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;recordBalanceTransactionId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;referral&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;existing&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="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 marker is a string embedded in the &lt;code&gt;description&lt;/code&gt; of every credit I create, containing the referral's own ID. If a credit with that marker already exists on the customer, the function does not create another one. It records the existing transaction's ID against the row and finishes. The state of the world is read from Stripe at the moment of the decision, so it does not matter how long the row sat in &lt;code&gt;pending&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I kept the idempotency key. It is still the cheapest protection against the common case, a retry seconds after a flaky response, and it costs nothing. But it is now the first line of defense, not the only one. The ledger lookup is what is left when the key has expired.&lt;/p&gt;

&lt;p&gt;Two things I want to be honest about, because this fix is not free of tradeoffs:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Matching on a description string is a convention, not a constraint.&lt;/strong&gt; It works because I control every writer of these credits. If someone adds a credit by hand in the dashboard with a different description, it is invisible to the check, and if someone edits a description, the check can miss it. I treat it as a second line of defense, not a proof.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The scan is bounded.&lt;/strong&gt; Listing every balance transaction for a customer is fine for a customer with a handful of them and not fine for one with thousands. I cap the scan at 1,000 entries, and if the cap is hit I throw instead of guessing, which sends the row to manual reconciliation. Failing closed seemed better than silently assuming "not found" on a list I did not finish reading.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  A stronger anchor: record the transaction ID
&lt;/h2&gt;

&lt;p&gt;The description marker is a search. A search can be wrong in ways a direct lookup cannot, so I added a column to the &lt;code&gt;referrals&lt;/code&gt; table that stores the balance transaction ID returned by 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="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;recordBalanceTransactionId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;referralId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;txId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&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="k"&gt;void&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;try&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;db&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;referrals&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;stripeBalanceTransactionId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;txId&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="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;referrals&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;referralId&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="s2"&gt;`[referral] CRITICAL: credit &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;txId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; granted but failed to record on referral &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;referralId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;. `&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
        &lt;span class="s2"&gt;`Row will show as rewardedWithoutCredit in KPI; reconcile manually.`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nx"&gt;err&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;and the top of the function checks it before doing anything else:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;referral&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stripeBalanceTransactionId&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;db&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;referrals&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;rewarded&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;rewardedAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;referral&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rewardedAt&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="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="nf"&gt;and&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="nx"&gt;referrals&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;referral&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;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;referrals&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pending&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the transaction ID is already recorded, the credit exists by definition, and the function just repairs the status without calling Stripe at all.&lt;/p&gt;

&lt;p&gt;Notice the comment I left in &lt;code&gt;recordBalanceTransactionId&lt;/code&gt;: it deliberately does not rethrow. The credit has already been granted at that point. If recording the ID failed and I threw, the outer catch would roll the row back to &lt;code&gt;pending&lt;/code&gt;, and I would have built a brand-new route to the exact double-credit I had just closed. The error handling for "the money moved but my bookkeeping failed" has to be different from the error handling for "the money did not move." Mixing them is how a safety mechanism turns into a hazard.&lt;/p&gt;

&lt;h2&gt;
  
  
  The related trap I found one pass later
&lt;/h2&gt;

&lt;p&gt;While the same function was under review, a second finding turned up that has the same flavor: an assumption about what a Stripe field means.&lt;/p&gt;

&lt;p&gt;The reward was originally gated on the subscription's &lt;code&gt;status&lt;/code&gt; being &lt;code&gt;active&lt;/code&gt;. That sounds like "the customer paid." It is not. When a trial ends, Stripe moves the subscription to &lt;code&gt;active&lt;/code&gt; before the payment for the first invoice has been collected. For a short window the subscription is active and nothing has been paid, which means a status check alone can hand out a reward for money that never arrived.&lt;/p&gt;

&lt;p&gt;The gate now looks at the subscription's latest invoice:&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;sub&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;subscriptions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;retrieve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;stripeSubscriptionId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;expand&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;latest_invoice&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;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;active&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="c1"&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;latestInvoice&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;latestInvoice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;paid&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;latestInvoice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;amount_paid&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&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;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;An invoice that is &lt;code&gt;paid&lt;/code&gt; with &lt;code&gt;amount_paid &amp;gt; 0&lt;/code&gt; is evidence that money moved. A subscription status is a state machine label. I also added &lt;code&gt;invoice.paid&lt;/code&gt; as a trigger for the function so that the payment itself, not a status change, wakes it up. Because the function re-derives eligibility from Stripe on every call and returns quietly when the answer is "not yet," it is safe to trigger from any event, and a trigger that arrives too early costs nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would tell myself before writing this
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;An idempotency key is a time-limited promise from someone else.&lt;/strong&gt; Before relying on one, write down its TTL next to the longest delay your own system can introduce between an attempt and its retry. If the second number can exceed the first, the key is not your idempotency story. Mine could, because a rolled-back row waits for an external trigger I do not control.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rollback plus retry is where idempotency actually gets tested.&lt;/strong&gt; Straight-line code with a key looks airtight. The risk concentrates wherever you convert an &lt;em&gt;ambiguous&lt;/em&gt; failure into a "try again later." An ambiguous failure means you do not know whether the side effect happened, and "later" is unbounded unless you bound it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ask the ledger, not the cache.&lt;/strong&gt; When the side effect lives in someone else's system, the safest idempotency check reads that system's current state at the moment of the decision. It costs an extra API call. It also stays correct no matter how long the row sat, how many times it was retried, or whether a key was pruned in between.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Order your layers by what they protect against.&lt;/strong&gt; In this function the database CAS stops concurrent callers, the idempotency key stops fast duplicate retries, the ledger lookup stops slow ones, and the stored transaction ID makes the repair path cheap. Each one covers a failure the others do not. Removing any single layer reintroduces a specific bug, so it helps to be able to say which one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Treat "the money moved but my bookkeeping failed" as its own case.&lt;/strong&gt; It must not share an error path with "the money did not move." Rolling back after a successful side effect is how you manufacture the duplicate you were trying to prevent.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Status fields are not receipts.&lt;/strong&gt; If a decision depends on "was it paid," find the object that records payment, an invoice or a charge, and check that. A status can be reached by paths you did not anticipate.&lt;/p&gt;

&lt;h2&gt;
  
  
  The uncomfortable part
&lt;/h2&gt;

&lt;p&gt;I want to be clear about how this was found: not by a test and not by production. A review pass that was specifically told to attack the money path read the rollback code and the key's lifetime side by side and asked how long a row could stay pending. I had the 24-hour fact in a code comment, one screen away from the bug, and still did not connect the two.&lt;/p&gt;

&lt;p&gt;My tests would not have caught it either, because reproducing it needs an ambiguous failure followed by a gap of more than a day, and nobody writes a test with a 25-hour sleep. If a bug only exists across a time window longer than your test suite's patience, the fix has to come from reasoning about the window, not from waiting for a failure to show it to you.&lt;/p&gt;

&lt;p&gt;The fix is a handful of extra lines. The expensive part was learning to read &lt;code&gt;idempotencyKey&lt;/code&gt; as a claim with an expiry date instead of as a property of the operation.&lt;/p&gt;

</description>
      <category>stripe</category>
      <category>typescript</category>
      <category>webdev</category>
      <category>api</category>
    </item>
    <item>
      <title>drizzle-kit generated a DROP TABLE for a table that was still in production. drizzle-kit wasn't the problem.</title>
      <dc:creator>phi_blankslate</dc:creator>
      <pubDate>Fri, 02 Oct 2026 12:27:09 +0000</pubDate>
      <link>https://dev.to/phi_blankslate/drizzle-kit-generated-a-drop-table-for-a-table-that-was-still-in-production-drizzle-kit-wasnt-the-4o</link>
      <guid>https://dev.to/phi_blankslate/drizzle-kit-generated-a-drop-table-for-a-table-that-was-still-in-production-drizzle-kit-wasnt-the-4o</guid>
      <description>&lt;p&gt;I asked drizzle-kit for a migration that added two tables. The SQL it generated also contained a &lt;code&gt;DROP TABLE ... CASCADE&lt;/code&gt; for a table that was still sitting in my production database, and an &lt;code&gt;ADD COLUMN&lt;/code&gt; for a column that production already had.&lt;/p&gt;

&lt;p&gt;Neither change had anything to do with the feature I was building. If I had applied the file as it came out, the first statement would have deleted a live table along with anything that depended on it. The second would have failed with "column already exists" and taken the rest of the migration with it, or left it half-applied, depending on how I ran it.&lt;/p&gt;

&lt;p&gt;drizzle-kit did nothing wrong. It did exactly what it is designed to do. The problem was that I had three different ideas of "the current schema" in my project and assumed they were the same thing. This post explains how they drifted apart, why the diff came out the way it did, and the checks I now run before any generated migration goes near production.&lt;/p&gt;

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

&lt;p&gt;The app is Next.js with Drizzle ORM on Postgres (Neon). The schema lives in one TypeScript file, &lt;code&gt;lib/db/schema.ts&lt;/code&gt;. Migrations are generated with &lt;code&gt;drizzle-kit generate&lt;/code&gt; into &lt;code&gt;drizzle/migrations/&lt;/code&gt;, and drizzle-kit keeps a JSON snapshot of the schema next to each migration in &lt;code&gt;drizzle/migrations/meta/&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I don't let tooling apply migrations to production. I don't use &lt;code&gt;drizzle-kit push&lt;/code&gt;, and the header of my migration files literally says not to use &lt;code&gt;drizzle-kit migrate&lt;/code&gt; either. Production changes are applied by hand, inside a transaction, after I've read the SQL. That rule is the only reason this story ends with a cleanup instead of a restore.&lt;/p&gt;

&lt;p&gt;There is one more habit in the background that matters. For small additive changes I sometimes skipped the generator and wrote a one-off script that runs a single idempotent DDL statement. That habit is half of the bug.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the drift happened
&lt;/h2&gt;

&lt;p&gt;The baseline migration (&lt;code&gt;0000&lt;/code&gt;) was generated at the end of July. At that point the schema had a &lt;code&gt;waitlist_entries&lt;/code&gt; table for a pre-launch email list, and the &lt;code&gt;reviews&lt;/code&gt; table did not have a &lt;code&gt;suppressed_count&lt;/code&gt; column yet.&lt;/p&gt;

&lt;p&gt;The next day I made two schema changes in one commit, and neither went through &lt;code&gt;drizzle-kit generate&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Change 1: I deleted the waitlist feature.&lt;/strong&gt; We had decided to launch directly instead of collecting a waitlist, so I removed the route, the form, the email-sending module, and the table definition from &lt;code&gt;schema.ts&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt; // ウェイトリスト登録（プレローンチ用リード収集）
&lt;span class="gd"&gt;-export const waitlistEntries = pgTable(
-  "waitlist_entries",
-  {
-    id: uuid("id").defaultRandom().primaryKey(),
-    email: text("email").notNull().unique(),
-    source: text("source"), // UTM等の登録元。任意
-    createdAt: timestamp("created_at").defaultNow().notNull(),
-  },
-  (table) =&amp;gt; ({
-    emailIdx: uniqueIndex("waitlist_entries_email_idx").on(table.email),
-  })
-);
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(The leftover Japanese comment above it says "waitlist signups (pre-launch lead collection)". It outlived the code it described, which is a small preview of the whole problem.)&lt;/p&gt;

&lt;p&gt;I deleted the &lt;em&gt;definition&lt;/em&gt;. I did not drop the &lt;em&gt;table&lt;/em&gt;. In my head the feature was gone. In production the table was still there, empty and unreferenced.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Change 2: I added a column with a one-off script.&lt;/strong&gt; In the same commit, &lt;code&gt;reviews&lt;/code&gt; gained a column:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;     commentsCount: integer("comments_count").default(0),
&lt;span class="gi"&gt;+    suppressedCount: integer("suppressed_count").default(0),
&lt;/span&gt;     tokensUsed: integer("tokens_used").default(0),
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Instead of generating a migration, I added it to production with a small script that runs one statement:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;sql&lt;/span&gt;&lt;span class="s2"&gt;`
  ALTER TABLE reviews
    ADD COLUMN IF NOT EXISTS suppressed_count integer DEFAULT 0
`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It's idempotent, it checks &lt;code&gt;information_schema&lt;/code&gt; afterwards, and it worked. From production's point of view, everything was correct.&lt;/p&gt;

&lt;p&gt;So now:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;schema.ts&lt;/code&gt; had no waitlist table and did have &lt;code&gt;suppressed_count&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Production still had the waitlist table and also had &lt;code&gt;suppressed_count&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The latest drizzle snapshot (&lt;code&gt;0000_snapshot.json&lt;/code&gt;) still had the waitlist table and did &lt;strong&gt;not&lt;/strong&gt; have &lt;code&gt;suppressed_count&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Nothing complained, because nothing compares those three. The app only reads &lt;code&gt;schema.ts&lt;/code&gt;. Production only knows what has been executed against it. The snapshot only changes when you run &lt;code&gt;generate&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  More than a month later: &lt;code&gt;drizzle-kit generate&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;In early September I built a referral feature that needed two new tables, &lt;code&gt;referral_codes&lt;/code&gt; and &lt;code&gt;referrals&lt;/code&gt;. I added them to &lt;code&gt;schema.ts&lt;/code&gt; and ran &lt;code&gt;npm run db:generate&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The generated file contained the two &lt;code&gt;CREATE TABLE&lt;/code&gt; statements I expected, and two statements I didn't:&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;DROP&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="nv"&gt;"waitlist_entries"&lt;/span&gt; &lt;span class="k"&gt;CASCADE&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="nv"&gt;"reviews"&lt;/span&gt; &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="nv"&gt;"suppressed_count"&lt;/span&gt; &lt;span class="p"&gt;...;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(I edited the generated file before committing it, so these are the two lines as I recorded them in my notes at the time, not a byte-for-byte copy of the original output.)&lt;/p&gt;

&lt;p&gt;My first reaction was that drizzle-kit had somehow connected to the wrong database. It hadn't connected to any database.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the diff looks like that
&lt;/h2&gt;

&lt;p&gt;This was the part I had wrong in my head for months: &lt;strong&gt;&lt;code&gt;drizzle-kit generate&lt;/code&gt; does not diff your schema against your database. It diffs your schema against its own last snapshot.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The flow is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Read &lt;code&gt;schema.ts&lt;/code&gt; and build a schema object.&lt;/li&gt;
&lt;li&gt;Read the most recent snapshot JSON in &lt;code&gt;meta/&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Emit SQL for every difference between the two.&lt;/li&gt;
&lt;li&gt;Write a new snapshot equal to the current &lt;code&gt;schema.ts&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;My &lt;code&gt;drizzle.config.ts&lt;/code&gt; has a &lt;code&gt;dbCredentials&lt;/code&gt; block with the production-shaped URL, which made it easy to believe that &lt;code&gt;generate&lt;/code&gt; looks at the database. It doesn't. Those credentials are for commands that actually talk to a database (&lt;code&gt;push&lt;/code&gt;, &lt;code&gt;migrate&lt;/code&gt;, &lt;code&gt;pull&lt;/code&gt;, &lt;code&gt;studio&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;Run the drift through that flow:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Last snapshot (&lt;code&gt;0000&lt;/code&gt;)&lt;/th&gt;
&lt;th&gt;
&lt;code&gt;schema.ts&lt;/code&gt; now&lt;/th&gt;
&lt;th&gt;Generated SQL&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;waitlist_entries&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;exists&lt;/td&gt;
&lt;td&gt;gone&lt;/td&gt;
&lt;td&gt;&lt;code&gt;DROP TABLE ... CASCADE&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;reviews.suppressed_count&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;missing&lt;/td&gt;
&lt;td&gt;exists&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ADD COLUMN&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;referral_codes&lt;/code&gt;, &lt;code&gt;referrals&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;missing&lt;/td&gt;
&lt;td&gt;exist&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;CREATE TABLE&lt;/code&gt; x2&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every line is correct relative to the snapshot. The snapshot was the stale party, and it was stale because I had changed the schema twice without telling the generator.&lt;/p&gt;

&lt;p&gt;I can confirm this from the repository now. &lt;code&gt;grep&lt;/code&gt; over the snapshot files shows &lt;code&gt;waitlist_entries&lt;/code&gt; present in &lt;code&gt;0000_snapshot.json&lt;/code&gt; and absent from &lt;code&gt;0001&lt;/code&gt; onward, and &lt;code&gt;suppressed_count&lt;/code&gt; absent from &lt;code&gt;0000&lt;/code&gt; and present from &lt;code&gt;0001&lt;/code&gt; onward. The snapshot quietly absorbed both changes at the moment I ran &lt;code&gt;generate&lt;/code&gt; for an unrelated feature.&lt;/p&gt;

&lt;h2&gt;
  
  
  What each statement would have done
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The DROP.&lt;/strong&gt; In my case the damage would have been close to zero. Before deciding anything, I checked production: the table existed, and &lt;code&gt;SELECT COUNT(*)&lt;/code&gt; on it returned an empty result set. But "it happened to be empty" is luck, not a safety property. Put any other "dead" table in that position (an audit log you stopped writing to, a table a cron job still reads, a table a teammate is about to wire up) and the same &lt;code&gt;generate&lt;/code&gt; run produces the same line. &lt;code&gt;CASCADE&lt;/code&gt; widens it further: it also drops whatever depends on the table, such as foreign keys from other tables and views.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The ADD COLUMN.&lt;/strong&gt; This one would have failed loudly, since production already had the column, and Postgres rejects a plain &lt;code&gt;ADD COLUMN&lt;/code&gt; for an existing column. Loud is better than silent, but it still matters where it fails. If the file is applied as a single transaction, the whole migration rolls back and you retry. If it's applied statement by statement (some SQL consoles autocommit each statement), you end up with whatever ran before the failure applied and whatever came after it not. That's a schema nobody designed.&lt;/p&gt;

&lt;p&gt;My one-off script had used &lt;code&gt;ADD COLUMN IF NOT EXISTS&lt;/code&gt;, which is idempotent. The generator emits a plain &lt;code&gt;ADD COLUMN&lt;/code&gt;, because as far as the snapshot knows the column has never existed. The idempotency lived in the script, not in the migration history.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I actually did
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Checked production before touching anything.&lt;/strong&gt; I queried &lt;code&gt;information_schema.tables&lt;/code&gt; and &lt;code&gt;information_schema.columns&lt;/code&gt; to confirm what really existed. The table was there, and the column was there.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Removed both unrelated statements from the generated file.&lt;/strong&gt; The migration was narrowed to the referral tables only. A migration should do what its name says and nothing else.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cleaned up the dead table as a separate, deliberate step.&lt;/strong&gt; After confirming it was empty, and with explicit sign-off, I dropped &lt;code&gt;waitlist_entries&lt;/code&gt; by hand. It was its own decision instead of a side effect of a referral feature.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Applied the migration by hand, in a transaction.&lt;/strong&gt; The committed file is now wrapped in &lt;code&gt;BEGIN; ... COMMIT;&lt;/code&gt; with a header comment explaining why. A later edit to the same file made the transaction even more important: it replaces an index with a unique version (&lt;code&gt;DROP INDEX IF EXISTS&lt;/code&gt; followed by &lt;code&gt;CREATE UNIQUE INDEX&lt;/code&gt;). In an autocommit console, if the create fails after the drop succeeds, you're left with no index at all, which breaks every &lt;code&gt;ON CONFLICT&lt;/code&gt; that relied on it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Because the snapshot had already been rewritten to match &lt;code&gt;schema.ts&lt;/code&gt;, the next &lt;code&gt;generate&lt;/code&gt; (for later changes) produced clean diffs. The drift was absorbed into history. Repository and production agreed again, but only because a human reconciled them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the side door existed in the first place
&lt;/h2&gt;

&lt;p&gt;It's worth being honest about why I was making schema changes outside the generator at all, because the reason wasn't laziness. It was friction, and friction is what pushes everyone toward side doors.&lt;/p&gt;

&lt;p&gt;The header comment of my one-off column script explains it: I didn't use &lt;code&gt;drizzle-kit push&lt;/code&gt; because push can stop and ask interactive questions (for example, whether it's allowed to truncate a table), and a command that waits for a keypress in a non-interactive context just hangs. So for a single additive column against production, a tiny script with &lt;code&gt;ADD COLUMN IF NOT EXISTS&lt;/code&gt;, a dry-run mode by default, an explicit &lt;code&gt;--apply&lt;/code&gt; flag, and an &lt;code&gt;information_schema&lt;/code&gt; check afterwards felt &lt;em&gt;safer&lt;/em&gt; than the official tool. For that one change, it was.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;generate&lt;/code&gt; has the same trait. When I ran the referral migration, I first tried it from an automated, non-TTY session, and it stopped at an interactive prompt it couldn't get an answer to. It had to be run from a real terminal. That's a reasonable design, since some schema changes are genuinely ambiguous (is this a rename or a drop-and-create?) and a tool shouldn't guess. But it means the "proper" path costs more than the side door, and every time the proper path costs more, some changes will skip it.&lt;/p&gt;

&lt;p&gt;The fix isn't to ban side doors. Sometimes a hand-written, idempotent statement really is the right way to change production. The fix is to make sure every side door ends with a step that updates the front door's records: after the script runs, run &lt;code&gt;generate&lt;/code&gt; so the snapshot catches up, and commit the result as "already applied". The script changes the database. The generate run changes the history. You need both, or the history starts lying.&lt;/p&gt;

&lt;h2&gt;
  
  
  The check I already had, and why it wasn't enough
&lt;/h2&gt;

&lt;p&gt;I already had a read-only preflight script that runs before any DDL. It opens a &lt;code&gt;READ ONLY&lt;/code&gt; transaction, lists the tables in &lt;code&gt;public&lt;/code&gt;, and compares them with what &lt;code&gt;schema.ts&lt;/code&gt; defines. Here's the core of it (comments translated):&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;// Query inside a READ ONLY transaction.&lt;/span&gt;
&lt;span class="c1"&gt;// Even if code below ever tries to write, the database refuses it.&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;currentDatabase&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;tables&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;sql&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;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tx&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;await&lt;/span&gt; &lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="s2"&gt;`SET TRANSACTION READ ONLY`&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;current_database&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;tx&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;current_database&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}[]&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="s2"&gt;`
    SELECT current_database()
  `&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;rows&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;tx&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;table_name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}[]&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="s2"&gt;`
    SELECT table_name
    FROM information_schema.tables
    WHERE table_schema = 'public' AND table_type = 'BASE TABLE'
    ORDER BY table_name
  `&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;currentDatabase&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;current_database&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;tables&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;table_name&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;and later:&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;warnings&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;unexpected&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;tables&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;ALL_EXPECTED&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&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;unexpected&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;warnings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`table exists that schema.ts does not define: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;unexpected&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;, &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That warning is exactly the signal for the waitlist half of this bug: a table in production that the code doesn't define. It would have fired. But a warning in a JSON report is something you have to go and read, and the preflight answers a different question ("am I connected to the right database?"). It checks tables, not columns, so it would never have caught the &lt;code&gt;suppressed_count&lt;/code&gt; half. And it compares production with &lt;code&gt;schema.ts&lt;/code&gt;, not with the snapshot. The snapshot is the third party that actually drives &lt;code&gt;generate&lt;/code&gt;, and nothing was looking at it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I do now
&lt;/h2&gt;

&lt;p&gt;None of this needs special tooling. It's mostly about treating a generated migration as a &lt;em&gt;proposal&lt;/em&gt; and treating the snapshot as state that can go stale.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Read every generated statement and trace it back to your change.&lt;/strong&gt; For each line, ask "which edit in this branch caused this?" If the answer is "none", stop. An unexplained &lt;code&gt;DROP&lt;/code&gt; or &lt;code&gt;ALTER&lt;/code&gt; means the snapshot and reality have diverged somewhere, and the fix is to find out where, not to delete the line and move on.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Treat &lt;code&gt;DROP&lt;/code&gt; and &lt;code&gt;CASCADE&lt;/code&gt; in a generated file as a hard stop.&lt;/strong&gt; Any destructive statement gets checked against the live database (does it exist, does it have rows, what depends on it) before it's allowed to stay. If it stays, it should be its own reviewed change, not a side effect of something else.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Don't change the schema outside the generator, or if you do, close the loop.&lt;/strong&gt; One-off scripts are fine and sometimes the right call for production. But if a column goes in by script, run &lt;code&gt;generate&lt;/code&gt; immediately afterwards so the snapshot learns about it, and commit that migration marked as already applied. Otherwise the generator will "discover" the change later, inside somebody else's feature branch.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Removing a table from &lt;code&gt;schema.ts&lt;/code&gt; is a migration, not a cleanup.&lt;/strong&gt; Deleting the definition is the start of removing a table, not the end. Either drop it in the same change, through a reviewed migration, or leave the definition in place with a comment until you're ready to drop it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Wrap hand-applied migrations in a transaction.&lt;/strong&gt; Partial application is the failure mode that turns an ordinary error into an unplanned schema.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;6. Check production against &lt;code&gt;schema.ts&lt;/code&gt; periodically, not just before migrations.&lt;/strong&gt; Tables &lt;em&gt;and&lt;/em&gt; columns. An &lt;code&gt;information_schema&lt;/code&gt; query is enough:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;table_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;column_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;data_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;column_default&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;information_schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;columns&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;table_schema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'public'&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="k"&gt;table_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ordinal_position&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 list with your schema file. Anything that exists on only one side is drift that your next &lt;code&gt;generate&lt;/code&gt; will try to "fix" for you.&lt;/p&gt;

&lt;h2&gt;
  
  
  The general lesson
&lt;/h2&gt;

&lt;p&gt;Most migration tools that generate SQL from a declarative schema work this way. They have to compare against &lt;em&gt;something&lt;/em&gt;, and if that something is a file in your repo rather than the live database, it can only be as accurate as the history you fed it. Every schema change made by hand, by script, from a SQL console, or by deleting code without dropping the table it described creates a gap between the snapshot and reality. The generator closes that gap the only way it can: by writing SQL that makes the database match the file, whether you meant to ask for that or not.&lt;/p&gt;

&lt;p&gt;The dangerous part isn't the generator. It's the assumption that "the schema" is one thing. In my project it was three: what the code declares, what the tool's snapshot records, and what the database actually contains. They agree only as long as every change goes through the same door. The day one change uses a side door, the next person to use the front door gets a migration that "fixes" it.&lt;/p&gt;

&lt;p&gt;So the rule I follow now is short: a generated migration is a diff against history, not against production. Read it like a diff from a teammate who has been away for a month, because in effect that's what it is.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>database</category>
      <category>typescript</category>
      <category>webdev</category>
    </item>
    <item>
      <title>A stray .env file broke exactly 13 pages of my Next.js build — and dev mode never saw it</title>
      <dc:creator>phi_blankslate</dc:creator>
      <pubDate>Mon, 28 Sep 2026 08:37:19 +0000</pubDate>
      <link>https://dev.to/phi_blankslate/a-stray-env-file-broke-exactly-13-pages-of-my-nextjs-build-and-dev-mode-never-saw-it-i44</link>
      <guid>https://dev.to/phi_blankslate/a-stray-env-file-broke-exactly-13-pages-of-my-nextjs-build-and-dev-mode-never-saw-it-i44</guid>
      <description>&lt;p&gt;&lt;code&gt;npx next build&lt;/code&gt; was failing on precisely 13 pages, every time, with the same cryptic error:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;TypeError: Invalid URL, input: ''
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;npm run dev&lt;/code&gt; worked fine. &lt;code&gt;git stash&lt;/code&gt; back to a commit from weeks earlier — same 13 pages, same error. Whatever this was, it wasn't something I'd just written.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;git stash&lt;/code&gt; step mattered more than it might look. My first assumption, like most people's, was "I broke something recently." Stashing back past several days of commits and reproducing the exact same failure on the exact same 13 pages ruled that out immediately — whatever this was, it wasn't in the diff. It was in the environment, which meant it was invisible to &lt;code&gt;git log&lt;/code&gt;, invisible to code review, and invisible to anyone just reading the codebase. That's the property that made this bug worth writing up: it wasn't a logic error anyone could have caught by reading code, because the code was never wrong.&lt;/p&gt;

&lt;p&gt;This is the story of chasing that error down to a single leftover file from a debugging session a month prior, and why the fix required understanding something about how Next.js loads &lt;code&gt;.env*&lt;/code&gt; files that I'd never had a reason to learn before.&lt;/p&gt;

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

&lt;p&gt;The app is a Next.js 14 (App Router) SaaS with NextAuth for GitHub OAuth login. &lt;code&gt;app/layout.tsx&lt;/code&gt; is the root layout every page goes through, and it calls &lt;code&gt;getServerSession()&lt;/code&gt; so the UI can tell if you're logged in:&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;export&lt;/span&gt; &lt;span class="k"&gt;default&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;RootLayout&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;children&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;children&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;React&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ReactNode&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;session&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;session&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;getServerSession&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;authOptions&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="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="k"&gt;as&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;})?.&lt;/span&gt;&lt;span class="nx"&gt;digest&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;DYNAMIC_SERVER_USAGE&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;throw&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="s2"&gt;[layout] getServerSession failed (likely missing env vars):&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="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;html&lt;/span&gt; &lt;span class="na"&gt;lang&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"en"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;body&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Providers&lt;/span&gt; &lt;span class="na"&gt;session&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;children&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Providers&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;body&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;html&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note the &lt;code&gt;try/catch&lt;/code&gt; there. I'd added it specifically so that missing env vars during a static build wouldn't crash the whole page — log it, fall back to &lt;code&gt;session = null&lt;/code&gt;, move on. It felt like it should be bulletproof. It was not the code that was crashing.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Providers&lt;/code&gt; is a small client component:&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;use client&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;SessionProvider&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;next-auth/react&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;Providers&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;children&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;session&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;SessionProvider&lt;/span&gt; &lt;span class="na"&gt;session&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;children&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;SessionProvider&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Thirteen lines, no logic. And yet this is where the trail eventually led.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two wrong guesses, then I stopped guessing
&lt;/h2&gt;

&lt;p&gt;My first instinct was: something's missing from &lt;code&gt;.env.local&lt;/code&gt;. I checked &lt;code&gt;NEXTAUTH_URL&lt;/code&gt; — it was there, with a value. Not that.&lt;/p&gt;

&lt;p&gt;Second guess: maybe &lt;code&gt;NEXTAUTH_URL_INTERNAL&lt;/code&gt; (a lesser-known NextAuth variable used behind proxies) was set to something malformed. I grepped &lt;code&gt;.env.local&lt;/code&gt; for it. The line didn't exist at all. Not that either.&lt;/p&gt;

&lt;p&gt;Two guesses, two misses. At that point I stopped inventing theories about &lt;code&gt;.env.local&lt;/code&gt; and went looking for hard evidence instead — specifically, whatever code was actually constructing the URL that was failing. The build output is right there in &lt;code&gt;.next/server/&lt;/code&gt;, so I grepped the compiled chunk for the literal pattern the stack trace implied:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="s1"&gt;'new URL([^)]*'&lt;/span&gt; .next/server/chunks/662.js
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nineteen matches. All nineteen referenced exactly three identifiers: &lt;code&gt;NEXTAUTH_URL&lt;/code&gt;, &lt;code&gt;NEXTAUTH_URL_INTERNAL&lt;/code&gt;, and &lt;code&gt;VERCEL_URL&lt;/code&gt;. That's not application code — that's &lt;code&gt;next-auth/react&lt;/code&gt; itself. My own code doesn't construct a &lt;code&gt;URL&lt;/code&gt; object anywhere near auth.&lt;/p&gt;

&lt;p&gt;That grep was the actual turning point, and it's worth naming why it worked when guessing didn't. A &lt;code&gt;.env&lt;/code&gt; file is a hypothesis space — you can stare at it and imagine a dozen ways a value could be wrong. A compiled bundle is not a hypothesis space; it's a record of what the code actually does. Every &lt;code&gt;new URL(...)&lt;/code&gt; call in that chunk is a call some real module makes, using real variable names, and there's no interpretation involved in reading it. I didn't need to know anything about NextAuth internals going in — the grep told me exactly which three environment variables mattered and, implicitly, which package to go read next. Compiled output is one of the few artifacts in a JS project that can't be wrong about what it contains.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a client component can crash a build with an uncatchable error
&lt;/h2&gt;

&lt;p&gt;Here's the part that made the &lt;code&gt;try/catch&lt;/code&gt; in &lt;code&gt;layout.tsx&lt;/code&gt; irrelevant: &lt;code&gt;next-auth/react&lt;/code&gt;'s &lt;code&gt;SessionProvider&lt;/code&gt; module runs &lt;code&gt;parseUrl(process.env.NEXTAUTH_URL ?? process.env.VERCEL_URL)&lt;/code&gt; at &lt;strong&gt;import time&lt;/strong&gt; — at the top level of the module, not inside a function that gets called (and could be wrapped) later. By the time &lt;code&gt;Providers&lt;/code&gt; even renders, the module has already executed and either succeeded or thrown.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;parseUrl&lt;/code&gt; itself (from &lt;code&gt;next-auth@4.24.15&lt;/code&gt;'s &lt;code&gt;utils/parse-url.js&lt;/code&gt;) is short:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;parseUrl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;_url2&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;defaultUrl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;http://localhost:3000/api/auth&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&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;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;http&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="nx"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`https://&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;_url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;_url2&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;_url2&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;_url2&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;defaultUrl&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The bug hinges on one line: &lt;code&gt;(_url2 = url) !== null &amp;amp;&amp;amp; _url2 !== void 0 ? _url2 : defaultUrl&lt;/code&gt;. That's just &lt;code&gt;url ?? defaultUrl&lt;/code&gt; written out — nullish coalescing. If &lt;code&gt;url&lt;/code&gt; is &lt;code&gt;undefined&lt;/code&gt;, this correctly falls back to &lt;code&gt;defaultUrl&lt;/code&gt;. But if &lt;code&gt;url&lt;/code&gt; is an &lt;strong&gt;empty string&lt;/strong&gt;, &lt;code&gt;""&lt;/code&gt; is not &lt;code&gt;null&lt;/code&gt; and not &lt;code&gt;undefined&lt;/code&gt;, so the nullish check passes it straight through. And a few lines up, &lt;code&gt;if (url &amp;amp;&amp;amp; !url.startsWith("http"))&lt;/code&gt; also short-circuits on falsy, so an empty string skips the &lt;code&gt;https://&lt;/code&gt; prefix step too. The empty string sails through both guards unmodified and lands in &lt;code&gt;new URL("")&lt;/code&gt;, which throws immediately: &lt;code&gt;TypeError: Invalid URL, input: ''&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;So somewhere, &lt;code&gt;NEXTAUTH_URL&lt;/code&gt; was resolving to &lt;code&gt;""&lt;/code&gt; — not undefined, not missing, but an actual empty string. And because this throw happens during module evaluation inside a Client Component that every route imports through the root layout, it aborts static generation for every route where that import gets evaluated — with no &lt;code&gt;try/catch&lt;/code&gt; in my own code anywhere near it, because the exception isn't inside a function call I made; it's inside a &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt;-equivalent module body that runs as a side effect of importing &lt;code&gt;next-auth/react&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This is a general property of &lt;code&gt;import&lt;/code&gt;, not something specific to this library: importing a module runs its top-level code immediately, synchronously, before you get a reference to anything it exports. A &lt;code&gt;try/catch&lt;/code&gt; you write can only guard the code between its braces — it has no reach into work a dependency chose to do the moment it was loaded. Singleton clients, feature-flag SDKs, analytics initializers, anything that reads &lt;code&gt;process.env&lt;/code&gt; at the top of a file rather than inside an exported function, can all fail this same way: the crash happens on import, in a stack frame that belongs to someone else's package, at a point in the render tree your own error boundaries were never positioned to reach.&lt;/p&gt;

&lt;h2&gt;
  
  
  The number that confirmed it
&lt;/h2&gt;

&lt;p&gt;The stack trace hit exactly 13 pages: &lt;code&gt;/&lt;/code&gt;, &lt;code&gt;/blog&lt;/code&gt;, four blog post pages, &lt;code&gt;/compare&lt;/code&gt;, &lt;code&gt;/faq&lt;/code&gt;, &lt;code&gt;/how-it-works&lt;/code&gt;, and four &lt;code&gt;/legal/*&lt;/code&gt; pages. Three routes in the app — &lt;code&gt;/signup&lt;/code&gt;, &lt;code&gt;/dashboard&lt;/code&gt;, &lt;code&gt;/dashboard/team&lt;/code&gt; — were &lt;strong&gt;not&lt;/strong&gt; affected, and all three had &lt;code&gt;export const dynamic = "force-dynamic"&lt;/code&gt;, meaning Next.js skips prerendering them at build time entirely. They never evaluate the layout's module tree during &lt;code&gt;next build&lt;/code&gt; the same way, so they never hit the crash.&lt;/p&gt;

&lt;p&gt;Thirteen static pages, thirteen failures, zero false positives among the dynamic ones. That's the kind of exact match that turns a hypothesis into a near-certainty before you've even fixed anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the empty string was coming from
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;NEXTAUTH_URL&lt;/code&gt; in &lt;code&gt;.env.local&lt;/code&gt; had a real value. So why was the build reading &lt;code&gt;""&lt;/code&gt;?&lt;/p&gt;

&lt;p&gt;Next.js loads env files in a specific priority order, and it's different depending on which command you run. For &lt;code&gt;next build&lt;/code&gt; (which runs with &lt;code&gt;NODE_ENV=production&lt;/code&gt;), the order is roughly:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;code&gt;.env.production.local&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;.env.local&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;.env.production&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;.env&lt;/code&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;code&gt;next dev&lt;/code&gt;, meanwhile, prefers &lt;code&gt;.env.development.local&lt;/code&gt; — a completely different file. That's exactly why &lt;code&gt;npm run dev&lt;/code&gt; never showed the problem: it was never reading the file that had the bad value.&lt;/p&gt;

&lt;p&gt;And sitting in my working directory, dated about a month earlier, was a leftover &lt;code&gt;.env.production.local&lt;/code&gt;. I'd created it running &lt;code&gt;vercel env pull .env.production.local --environment=production&lt;/code&gt; while debugging something else entirely. What I hadn't caught at the time: Vercel's CLI doesn't return the actual values for env vars marked "sensitive" via &lt;code&gt;env pull&lt;/code&gt; — it writes them back as &lt;strong&gt;empty strings&lt;/strong&gt;. I'd pulled a file full of blanks, moved on to the actual thing I was debugging, and never deleted it. Next.js then quietly gave that file top priority over my real &lt;code&gt;.env.local&lt;/code&gt; on every production build, from that day forward, for everyone who ran &lt;code&gt;next build&lt;/code&gt; locally.&lt;/p&gt;

&lt;p&gt;And here's the part that let it hide for a month: &lt;code&gt;.env.production.local&lt;/code&gt; matches the &lt;code&gt;.env*.local&lt;/code&gt; pattern that ships in every default Next.js &lt;code&gt;.gitignore&lt;/code&gt;, right alongside &lt;code&gt;.env.local&lt;/code&gt; itself. It was never staged, never committed, never visible in a diff, never something a teammate or a past version of me could have flagged in review. It just sat on disk, correctly ignored by git for exactly the reason &lt;code&gt;.env.local&lt;/code&gt; files are supposed to be ignored — because they're meant to hold real secrets — while accidentally also being an override file that Next.js would treat as authoritative the next time anyone ran a production build from that machine. The gitignore rule that protects secrets and the priority rule that resolves env files are two completely independent systems that happen to both key off the same filename convention, and nothing forces them to agree with your intentions at the same time.&lt;/p&gt;

&lt;p&gt;The fix, once identified, took one command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;rm&lt;/span&gt; .env.production.local
npx next build
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first time I ran that rebuild, it still looked broken — until I noticed the &lt;code&gt;.next/BUILD_ID&lt;/code&gt; timestamp didn't match anything I'd just done. I had two checkouts of the repo open (a worktree for an unrelated branch, and the main one), and the rebuild had run in the wrong directory against a &lt;code&gt;.next&lt;/code&gt; cache that had nothing to do with the fix. Once I chained the &lt;code&gt;cd&lt;/code&gt; and the build into a single command instead of relying on whichever shell happened to have focus, the real result came back clean: all 24 pages generated, zero errors, &lt;code&gt;Environments: .env.local&lt;/code&gt; printed at the top of the build log where &lt;code&gt;.env.production.local&lt;/code&gt; used to be listed first.&lt;/p&gt;

&lt;p&gt;It's a small thing, but it's the same category of mistake as the bug itself — trusting which directory or which file you &lt;em&gt;think&lt;/em&gt; you're operating in, instead of checking what the tool actually reports it used. The build log telling you which env files it loaded, in priority order, was there the whole time; I just hadn't been reading that line until this happened.&lt;/p&gt;

&lt;h2&gt;
  
  
  The lesson that generalizes
&lt;/h2&gt;

&lt;p&gt;A few things about this are specific to Next.js and NextAuth, but the shape of the bug isn't:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;next build&lt;/code&gt; and &lt;code&gt;next dev&lt;/code&gt; don't read the same env files.&lt;/strong&gt; This is documented, but it's the kind of documented-but-easy-to-forget fact that only bites you when a stale file happens to exist. If a bug only reproduces in one command and not the other, checking &lt;em&gt;which&lt;/em&gt; &lt;code&gt;.env*&lt;/code&gt; files each one actually loads should be an early step, not a last resort.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A tool that writes credentials back to disk can write blanks instead of failing loudly.&lt;/strong&gt; &lt;code&gt;vercel env pull&lt;/code&gt; succeeding with exit code 0 told me nothing about whether the values inside were real. A pull that returns empty strings for sensitive vars is functionally a silent failure wearing a success message. If you ever run a credential-pulling command for a one-off debugging session, treat the output file as radioactive — name it something that will never be picked up by convention (not &lt;code&gt;.env.production.local&lt;/code&gt;), and delete it the moment you're done.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Two wrong guesses is a signal to stop guessing.&lt;/strong&gt; Both of my first theories were reasonable and both were about the same file (&lt;code&gt;.env.local&lt;/code&gt;) that turned out to be completely fine. The thing that actually worked was going one level closer to the truth: instead of theorizing about what &lt;em&gt;might&lt;/em&gt; be in an env file, I grepped the compiled output for the literal code path that was throwing. Compiled output doesn't lie about what identifiers it references. If you find yourself on a second incorrect theory, that's usually the point to trade speculation for whatever raw artifact — a compiled bundle, a request log, a database row — can tell you definitively what actually ran.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A &lt;code&gt;try/catch&lt;/code&gt; only catches what runs inside it.&lt;/strong&gt; My layout's &lt;code&gt;try/catch&lt;/code&gt; around &lt;code&gt;getServerSession()&lt;/code&gt; was correct and necessary, but it gave me false confidence that env-var failures near auth were handled. A separate, unrelated code path — a third-party client component's module-level side effect — could still crash the same render with no relation to the code I'd defended. When a library does real work at import time rather than inside a function you call, no amount of wrapping your own call sites protects you from it.&lt;/p&gt;

&lt;p&gt;None of this was a Next.js bug or a NextAuth bug. Both behaved exactly as documented. It was one leftover debugging artifact, two library behaviors that are individually reasonable, and a build tool that reads a different file than the one I was staring at. That combination is what took an afternoon to trace back to a single &lt;code&gt;rm&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  A short checklist, if this ever happens to you
&lt;/h2&gt;

&lt;p&gt;If &lt;code&gt;next build&lt;/code&gt; fails somewhere &lt;code&gt;next dev&lt;/code&gt; doesn't, and the error points toward configuration rather than logic, this is roughly the order I'd check things in now:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;ls -la .env*&lt;/code&gt; in the project root. Anything named &lt;code&gt;.env.production.local&lt;/code&gt;, &lt;code&gt;.env.production&lt;/code&gt;, or similar that you don't remember writing on purpose is suspect by default — delete it and rebuild before doing anything else.&lt;/li&gt;
&lt;li&gt;If the build output already exists, grep the compiled chunks for the specific string in the error (a URL, a variable name, whatever's unique) before forming a third theory about source files you've already checked twice.&lt;/li&gt;
&lt;li&gt;Treat any command that pulls secrets from a remote source (&lt;code&gt;vercel env pull&lt;/code&gt;, &lt;code&gt;aws secretsmanager get-secret-value&lt;/code&gt;, &lt;code&gt;doppler secrets download&lt;/code&gt;, and so on) as something that can succeed with exit code 0 and still hand you garbage. Sensitive/encrypted values are the most likely category to come back blank, redacted, or truncated depending on the tool's permission model — check a value, don't just check the exit code.&lt;/li&gt;
&lt;li&gt;If a third-party package throws during static generation, don't assume the crash originates in a function you called. Client components and providers can run real code — including &lt;code&gt;new URL(...)&lt;/code&gt;, &lt;code&gt;fetch&lt;/code&gt;, or anything else — the moment they're imported, before your own &lt;code&gt;try/catch&lt;/code&gt; blocks ever get a chance to run.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Every one of those steps would have gotten me to the answer faster than the two guesses I actually made.&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>typescript</category>
      <category>webdev</category>
      <category>debugging</category>
    </item>
    <item>
      <title>Stripe said "Cancels". My dashboard said "Renews". The webhook returned 200 the whole time.</title>
      <dc:creator>phi_blankslate</dc:creator>
      <pubDate>Wed, 23 Sep 2026 05:51:46 +0000</pubDate>
      <link>https://dev.to/phi_blankslate/stripe-said-cancels-my-dashboard-said-renews-the-webhook-returned-200-the-whole-time-h4i</link>
      <guid>https://dev.to/phi_blankslate/stripe-said-cancels-my-dashboard-said-renews-the-webhook-returned-200-the-whole-time-h4i</guid>
      <description>&lt;p&gt;I run a small subscription SaaS on Next.js, Drizzle and Postgres, billed through Stripe. Before opening it up to real customers I did the most boring test there is: I became a customer in production. Real card, real trial, real Customer Portal. Then I canceled.&lt;/p&gt;

&lt;p&gt;Stripe's side looked perfect. The subscription showed a clear "Cancels" badge with the end date. My own dashboard, the page my customers actually look at, still said:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Renews on &lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A hard refresh didn't change it. Both webhook deliveries for the cancellation had returned &lt;code&gt;200&lt;/code&gt;. My runtime logs showed zero errors. Every health signal I had was green, and the one thing a customer who just canceled wants to see, confirmation that they won't be charged again, was wrong.&lt;/p&gt;

&lt;p&gt;This post is about what caused it, why my first theory was wrong, and the small set of habits I now use for any code that reads fields from a payment provider.&lt;/p&gt;

&lt;h2&gt;
  
  
  The code that "obviously" worked
&lt;/h2&gt;

&lt;p&gt;Here's the relevant part of my webhook handler, before the fix. The handler doesn't trust the event payload. For every &lt;code&gt;customer.subscription.*&lt;/code&gt; event it re-fetches the subscription from Stripe, so an old or out-of-order event can never overwrite newer state:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;customer.subscription.created&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;customer.subscription.updated&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;customer.subscription.deleted&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;eventSub&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;object&lt;/span&gt; &lt;span class="k"&gt;as&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;Subscription&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;handleSubscriptionEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;eventSub&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="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;handleSubscriptionEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;subscriptionId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&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;stripe&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getStripe&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;sub&lt;/span&gt;&lt;span class="p"&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;Subscription&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;sub&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;subscriptions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;retrieve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;subscriptionId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&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="nf"&gt;isResourceMissingError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&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="nf"&gt;deactivateSubscriptionByStripeId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;subscriptionId&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="k"&gt;throw&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="c1"&gt;// ... then upsertSubscription(sub)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the line that decided what the dashboard would show:&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="nx"&gt;cancelAtPeriodEnd&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cancel_at_period_end&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The dashboard reads that boolean and renders one of two words:&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="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;cancelAtPeriodEnd&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Cancels&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="s2"&gt;Renews&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="nx"&gt;on&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&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="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;currentPeriodEnd&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toLocaleDateString&lt;/span&gt;&lt;span class="p"&gt;()}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you've integrated Stripe Billing in the last few years, that line probably looks familiar. &lt;code&gt;cancel_at_period_end&lt;/code&gt; is the field every tutorial, blog post and Stack Overflow answer about "show the user their subscription is ending" points at. The name even describes exactly what happened: the customer canceled, and the subscription will end at the end of the period.&lt;/p&gt;

&lt;p&gt;It was &lt;code&gt;false&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  My first theory was wrong
&lt;/h2&gt;

&lt;p&gt;Two webhooks came in for the cancellation. When a boolean is "stuck" and there are multiple events involved, the reflex is to blame ordering. My first hypothesis, written down in my notes at the time, was a race condition: the events arrive out of order, the older state wins, the flag gets overwritten.&lt;/p&gt;

&lt;p&gt;It's a reasonable-sounding theory. It's also the kind of theory you can "fix" without ever confirming it. Add a timestamp check, add a lock, redeploy, cancel again, and if the dashboard happens to update you'll believe you fixed a race that never existed.&lt;/p&gt;

&lt;p&gt;Two things stopped me from doing that.&lt;/p&gt;

&lt;p&gt;First, the handler above already re-fetches the subscription from Stripe on every event. Out-of-order delivery shouldn't matter, because whichever event is processed last reads the &lt;em&gt;current&lt;/em&gt; state, not the state embedded in the event. A race theory has to explain why the current state would still say "not canceling". It couldn't.&lt;/p&gt;

&lt;p&gt;Second, I didn't have the evidence yet. So instead of patching, I went to get it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the evidence actually was
&lt;/h2&gt;

&lt;p&gt;My first stop was my hosting provider's runtime logs. They were gone. By the time I sat down to debug, the entries for the cancellation had already aged out of the log retention my plan includes. I hadn't thought about that window at all until I needed something outside it.&lt;/p&gt;

&lt;p&gt;The fix for that was simple once I noticed it: &lt;strong&gt;the payment provider keeps its own event log, and it keeps it for longer.&lt;/strong&gt; In Stripe Workbench, every webhook event is stored with its full payload, including &lt;code&gt;previous_attributes&lt;/code&gt;, the diff Stripe computed for that change.&lt;/p&gt;

&lt;p&gt;That diff answered the question in one screen. For the cancellation event:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;previous_attributes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;cancel_at&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;                    &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="s"&gt;  → &amp;lt;timestamp&amp;gt;&lt;/span&gt;
  &lt;span class="na"&gt;canceled_at&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;                  &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="s"&gt;  → &amp;lt;timestamp&amp;gt;&lt;/span&gt;
  &lt;span class="na"&gt;cancellation_details.reason&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;  &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="s"&gt;  → "cancellation_requested"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And &lt;code&gt;cancel_at_period_end&lt;/code&gt;? Not in the diff at all. It was &lt;code&gt;false&lt;/code&gt; before, and &lt;code&gt;false&lt;/code&gt; after.&lt;/p&gt;

&lt;p&gt;So Stripe was telling me, very precisely, what had changed. The customer's cancellation was recorded as a &lt;code&gt;cancel_at&lt;/code&gt; timestamp. The field I was reading was never involved. No race. No lost update. A deterministic bug: I was reading a field that this subscription would never set.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why &lt;code&gt;cancel_at_period_end&lt;/code&gt; stayed false
&lt;/h2&gt;

&lt;p&gt;The payload had one more field that explained everything:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;billing_mode&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;flexible"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Stripe's changelog for the Basil API release covers this. &lt;code&gt;cancel_at_period_end&lt;/code&gt; is deprecated, and for subscriptions on the flexible billing mode, a "cancel at the end of the period" request is resolved immediately into a concrete &lt;code&gt;cancel_at&lt;/code&gt; timestamp. The boolean stays &lt;code&gt;false&lt;/code&gt;. The end date is the signal.&lt;/p&gt;

&lt;p&gt;When I read it that way, it makes sense. "Cancel at period end" is a relative instruction. It means "whenever this period happens to end". A timestamp is an absolute one. If the platform resolves the relative instruction into an absolute date the moment you make the request, there's no reason to keep a boolean that says the same thing less precisely. From Stripe's side, nothing is broken. The data model moved, and my code was still reading the old one.&lt;/p&gt;

&lt;p&gt;One detail I got wrong along the way and want to flag, because it's an easy mistake: my first draft of the code comment said this happens "when a customer cancels during a trial". That was the situation I happened to test, so I assumed it was the cause. It isn't. The condition is the billing mode, not the trial. An active, paying subscription on flexible billing behaves the same way. If I had shipped the comment as I first wrote it, the next person to read the code (probably me) would have believed the bug only affects trials and might have "optimized" the check away for paid plans.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;The fix is a single expression:&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;// Stripe API Basil (2025-05-28 / 2025-07-30 changelog "cancel at enums"):&lt;/span&gt;
&lt;span class="c1"&gt;// cancel_at_period_end is deprecated. For billing_mode=flexible subscriptions a&lt;/span&gt;
&lt;span class="c1"&gt;// period-end cancel is resolved immediately into a cancel_at timestamp and&lt;/span&gt;
&lt;span class="c1"&gt;// cancel_at_period_end stays false (trial or not; confirmed against production&lt;/span&gt;
&lt;span class="c1"&gt;// payloads). Check both, so the old behaviour keeps working too.&lt;/span&gt;
&lt;span class="nx"&gt;cancelAtPeriodEnd&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cancel_at_period_end&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cancel_at&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(The real comment in my repo is in Japanese; this is a faithful translation.)&lt;/p&gt;

&lt;p&gt;A few things about this I thought about before shipping it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why keep &lt;code&gt;cancel_at_period_end&lt;/code&gt; at all?&lt;/strong&gt; Backward compatibility. Older subscriptions or a future change to the API version I'm pinned to could still set the boolean. The &lt;code&gt;||&lt;/code&gt; means either signal is enough. Removing the old check wouldn't make the code more correct, only more fragile.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Could &lt;code&gt;cancel_at != null&lt;/code&gt; show "Cancels" to someone who never canceled?&lt;/strong&gt; This was the real risk, so I listed every way &lt;code&gt;cancel_at&lt;/code&gt; gets set in my setup: an explicit cancellation from the Portal or API, a cancellation date set manually in the Stripe dashboard, or a subscription schedule that ends in cancellation. All three genuinely mean "this subscription is going to end". I don't use subscription schedules. Failed-payment handling (dunning) moves the subscription through statuses like &lt;code&gt;past_due&lt;/code&gt; rather than pre-setting &lt;code&gt;cancel_at&lt;/code&gt;, per Stripe's docs. So in my setup, a non-null &lt;code&gt;cancel_at&lt;/code&gt; means a real scheduled cancellation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What does this value actually control?&lt;/strong&gt; I grepped for every consumer of &lt;code&gt;cancelAtPeriodEnd&lt;/code&gt;. There was exactly one: the "Cancels / Renews" label on the dashboard. It doesn't touch billing amounts, entitlements or usage limits. Stripe stops charging independently of what my app displays. So if this fix were somehow wrong, the worst case is a wrong word on a page, and the rollback is a single-commit revert. That's what let me ship it with confidence instead of agonizing over it.&lt;/p&gt;

&lt;h2&gt;
  
  
  A second bug hiding behind the first
&lt;/h2&gt;

&lt;p&gt;While confirming the fix, I found something I'd never have looked for otherwise: my app was talking to Stripe in two different API versions at once.&lt;/p&gt;

&lt;p&gt;My Stripe client was created 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="nx"&gt;stripeClient&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Stripe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;secretKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Follow the API version the current SDK recommends (unset = SDK default)&lt;/span&gt;
  &lt;span class="na"&gt;typescript&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="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No &lt;code&gt;apiVersion&lt;/code&gt;. So every &lt;code&gt;retrieve()&lt;/code&gt; call used whatever version the installed SDK pins by default, which at the time was &lt;code&gt;2026-03-25.dahlia&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The webhook endpoint, on the other hand, was registered in the Stripe dashboard with its own API version, &lt;code&gt;2026-06-24.dahlia&lt;/code&gt;. &lt;strong&gt;Webhook payloads are rendered in the endpoint's version, not your SDK's.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That means the payload I was reading in Workbench, which I used to diagnose the bug, was not the same shape of data my code reads via &lt;code&gt;retrieve()&lt;/code&gt;. In this particular case both versions are after the Basil change, so they agree about &lt;code&gt;cancel_at&lt;/code&gt;. But it was luck, not design. If those two versions had straddled a breaking change, I could have looked at a payload, confirmed a field behaves a certain way, and shipped a fix that does nothing, because my code never sees that version of the object.&lt;/p&gt;

&lt;p&gt;Pinning &lt;code&gt;apiVersion&lt;/code&gt; explicitly so the SDK and the webhook endpoint agree is the permanent fix. I deliberately kept it out of this change, since it touches every Stripe call in the app, and a one-line billing fix is the wrong place to slip in a global behavior change. It's tracked as its own task.&lt;/p&gt;

&lt;h2&gt;
  
  
  One thing I left alone on purpose
&lt;/h2&gt;

&lt;p&gt;Look at the dashboard snippet again:&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="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;cancelAtPeriodEnd&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Cancels&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="s2"&gt;Renews&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="nx"&gt;on&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&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="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;currentPeriodEnd&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toLocaleDateString&lt;/span&gt;&lt;span class="p"&gt;()}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fix changed which &lt;em&gt;word&lt;/em&gt; appears. It didn't change which &lt;em&gt;date&lt;/em&gt; appears. The date still comes from the current period end, not from &lt;code&gt;cancel_at&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;For the cancellation I tested, those are the same moment. I checked the payload: &lt;code&gt;cancel_at&lt;/code&gt;, the trial end and the current period end all held the same value, which is exactly what you'd expect from "cancel at the end of the period". But &lt;code&gt;cancel_at&lt;/code&gt; can in principle be any timestamp. If someone set a cancellation for the middle of a period, say by picking a custom date in the Stripe dashboard, my page would say "Cancels on" followed by the wrong date.&lt;/p&gt;

&lt;p&gt;I noticed this during review and deliberately did not fix it in the same change. In my setup the only person who can set an arbitrary cancellation date is me, from the Stripe dashboard. Customers can only cancel through the Portal, which resolves to the period end. The right fix is to store &lt;code&gt;cancel_at&lt;/code&gt; as its own column and render that, which means a schema change, a migration and a second change to billing code. That's a separate, reviewable piece of work, not something to bundle into a one-line hotfix just because I happened to be in the file.&lt;/p&gt;

&lt;p&gt;I think this is the part of billing work that's easy to get wrong in the other direction. Once you've found one bug, it's tempting to "clean up" everything nearby while you're there. On a money path, every extra line you touch is another thing a reviewer has to reason about and another thing a revert has to undo. I wrote the gap down as a known limitation with the exact condition that would trigger it, and moved on.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I verified it without creating a new subscription
&lt;/h2&gt;

&lt;p&gt;The obvious way to test this fix would be to run the whole flow again: new checkout, new trial, cancel in the Portal, check the dashboard. That creates another real subscription in production just to test a display label.&lt;/p&gt;

&lt;p&gt;Stripe Workbench has a better tool: &lt;strong&gt;Resend&lt;/strong&gt;. You can re-deliver any past webhook event to your endpoint. Because my handler re-fetches the subscription instead of trusting the payload, resending the original cancellation event after deploying the fix simply makes my code read the current subscription again and upsert it. No new charge, no new customer, nothing to clean up afterwards.&lt;/p&gt;

&lt;p&gt;So the verification was:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Deploy the fix, in a commit that contained only the webhook change, so it could be reverted on its own&lt;/li&gt;
&lt;li&gt;Resend the original &lt;code&gt;customer.subscription.updated&lt;/code&gt; event from Workbench&lt;/li&gt;
&lt;li&gt;Confirm the endpoint returned &lt;code&gt;200&lt;/code&gt; on the new deployment&lt;/li&gt;
&lt;li&gt;Reload the dashboard&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;It said "Cancels on ".&lt;/p&gt;

&lt;p&gt;That step also closed the last open question I had: whether &lt;code&gt;retrieve()&lt;/code&gt;, on the SDK's older API version, actually returns &lt;code&gt;cancel_at&lt;/code&gt; populated. I had only inferred it before. The label flipping proved it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I took away from this
&lt;/h2&gt;

&lt;p&gt;None of this is specific to Stripe, which is why I wanted to write it up. Here's what I've changed in how I work with any third-party API that controls money or access.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. A 200 is not the same as a correct result
&lt;/h3&gt;

&lt;p&gt;Every monitoring signal I had was about delivery: status codes, error counts, exceptions. None of them were about meaning. The webhook was received, processed and stored successfully. It just stored the wrong answer. If your only alerting is "did it throw", a field-mapping bug is invisible by design. The only thing that caught this was a human doing the end-to-end flow and looking at the result.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Get the payload before you form a theory
&lt;/h3&gt;

&lt;p&gt;"Race condition" felt like a diagnosis, but it was a guess shaped like a diagnosis. The real answer was sitting in &lt;code&gt;previous_attributes&lt;/code&gt; the whole time. My rule now: for any "the state is wrong" bug involving a webhook, I don't write a line of fix until I've seen the actual payload and the actual diff for the event in question.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Use the provider's logs, not just yours
&lt;/h3&gt;

&lt;p&gt;Your hosting logs have a retention window, and you probably don't know how long it is until you need something outside it. Stripe, and most serious payment and identity providers, keep a full, searchable event history with payloads. For money-path debugging, I now treat the provider's event log as the primary source and my own logs as supplementary.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Deprecated fields don't error. They go quiet.
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;cancel_at_period_end&lt;/code&gt; still exists in the type definitions. It still comes back in the response. It still has a perfectly valid value. It's just no longer the field that carries the information. There was no warning, no exception and no &lt;code&gt;undefined&lt;/code&gt; to trip over. A field that is present, typed and plausible but no longer authoritative is the worst kind of deprecation, and the only defense I know of is reading the changelog for any field that drives something a user sees.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Know which API version produced the data you're looking at
&lt;/h3&gt;

&lt;p&gt;Payloads in the dashboard, payloads your endpoint receives, and objects your SDK retrieves can each be rendered in different API versions. When you diagnose from one and fix code that reads another, make sure they match, or pin them so they can't drift apart.&lt;/p&gt;

&lt;h3&gt;
  
  
  6. Size the blast radius before you ship
&lt;/h3&gt;

&lt;p&gt;The single most useful thing I did before deploying was grep for every consumer of the field I was changing. Finding exactly one, a display label, turned a nervous billing-code change into a low-risk one with a trivial rollback. If I'd found it feeding into plan limits or charge amounts, the right move would have been a much slower rollout.&lt;/p&gt;

&lt;h3&gt;
  
  
  7. Test the thing the customer sees
&lt;/h3&gt;

&lt;p&gt;I'd tested my webhook handler. I hadn't tested the sentence "your subscription will end on this date" as a customer would read it, right after canceling, in production. That sentence is the whole point of the feature. It's also the one piece nothing in my automated checks would ever look at.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you use &lt;code&gt;cancel_at_period_end&lt;/code&gt; today
&lt;/h2&gt;

&lt;p&gt;It's worth a five-minute check:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Search your codebase for &lt;code&gt;cancel_at_period_end&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;For each hit, ask whether that code also looks at &lt;code&gt;cancel_at&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Check which API version your SDK client uses, and which version your webhook endpoint is registered with. If you never set &lt;code&gt;apiVersion&lt;/code&gt;, you're on the SDK default, which changes when you upgrade the package&lt;/li&gt;
&lt;li&gt;If you can, cancel a test subscription and look at the event's &lt;code&gt;previous_attributes&lt;/code&gt; in the dashboard to see which fields actually change on your account&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If the answers surprise you, you might have a dashboard telling your customers the opposite of what's actually going to happen, while every webhook returns 200.&lt;/p&gt;

&lt;p&gt;I'm curious whether others have hit this. Did the Basil change catch you too, or did you move to &lt;code&gt;cancel_at&lt;/code&gt; before it mattered?&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>stripe</category>
      <category>typescript</category>
      <category>nextjs</category>
    </item>
    <item>
      <title>How I built an AI code reviewer that knows when to shut up</title>
      <dc:creator>phi_blankslate</dc:creator>
      <pubDate>Fri, 18 Sep 2026 08:43:34 +0000</pubDate>
      <link>https://dev.to/phi_blankslate/how-i-built-an-ai-code-reviewer-that-knows-when-to-shut-up-3b0c</link>
      <guid>https://dev.to/phi_blankslate/how-i-built-an-ai-code-reviewer-that-knows-when-to-shut-up-3b0c</guid>
      <description>&lt;p&gt;Every AI code reviewer I tried had the same problem: it wouldn't stop talking.&lt;br&gt;
Rename this. Add a comment here. Consider extracting that. By the third file&lt;br&gt;
you've stopped reading, and a tool you've stopped reading is worse than no&lt;br&gt;
tool — it's a tool that will hide a real bug from you inside a wall of&lt;br&gt;
suggestions.&lt;/p&gt;

&lt;p&gt;So I built one with the opposite rule: say nothing unless you found something&lt;br&gt;
worth saying. That sounds like a prompt engineering problem. It isn't. The&lt;br&gt;
model will happily agree to be concise and then hand you fourteen findings&lt;br&gt;
anyway. Every constraint that actually held up in production is a constraint&lt;br&gt;
I enforce in application code, after the model has already spoken.&lt;/p&gt;

&lt;p&gt;Here's what that looks like, including three bugs I only found by pointing&lt;br&gt;
the thing at real pull requests and a real credit card.&lt;/p&gt;
&lt;h2&gt;
  
  
  The problem: nitpick fatigue is a trust problem, not a UX problem
&lt;/h2&gt;

&lt;p&gt;A reviewer you've muted is worse than no reviewer, because now there's a wall&lt;br&gt;
of suggestions for a real bug to hide behind. This matters most for solo&lt;br&gt;
developers and small teams — the people who don't already have an enterprise&lt;br&gt;
code-review bundle sitting on top of their existing tools. If the review&lt;br&gt;
output is noisy, they turn it off in week one and never come back.&lt;/p&gt;
&lt;h2&gt;
  
  
  Architecture in one line
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;GitHub webhook → diff fetch → Claude → structured findings → ranker → inline comments
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Every stage after "Claude" exists to decide what &lt;em&gt;not&lt;/em&gt; to show you.&lt;/p&gt;
&lt;h2&gt;
  
  
  Structured output, then rank it yourself
&lt;/h2&gt;

&lt;p&gt;The model returns JSON findings, each with &lt;code&gt;{ path, line, severity, message }&lt;/code&gt;,&lt;br&gt;
where severity is one of four values: &lt;code&gt;BUG | WARN | NIT | PRAISE&lt;/code&gt;. The&lt;br&gt;
severity string coming back from the model is validated against that&lt;br&gt;
whitelist — an invalid value gets the finding dropped rather than trusted.&lt;br&gt;
Then everything is stable-sorted by severity:&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;severityRank&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;ReviewComment&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;severity&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;BUG&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;WARN&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;NIT&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;PRAISE&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rankedComments&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;sanitizedComments&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;comment&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;index&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="nx"&gt;comment&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;index&lt;/span&gt; &lt;span class="p"&gt;}))&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rankDiff&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;severityRank&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;comment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;severity&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;severityRank&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;comment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;severity&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;rankDiff&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;rankDiff&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;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;index&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;index&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// same-severity ties keep the model's own order&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;comment&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Stable sort matters here: within the same severity, findings keep the order&lt;br&gt;
the model produced them in, instead of being silently reshuffled.&lt;/p&gt;
&lt;h2&gt;
  
  
  The cap is a constant, not a request
&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;comments&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;rankedComments&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;MAX_COMMENTS&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;suppressedCount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;sanitizedComments&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;comments&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&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;MAX_COMMENTS&lt;/code&gt; is a constant, applied by &lt;code&gt;slice&lt;/code&gt; after the sort — not a line&lt;br&gt;
in the prompt asking the model to "please be concise." A prompt is a request&lt;br&gt;
the model can ignore on a bad day; a &lt;code&gt;slice()&lt;/code&gt; cannot.&lt;/p&gt;

&lt;p&gt;What gets cut isn't dropped silently. &lt;code&gt;suppressedCount&lt;/code&gt; flows into the review&lt;br&gt;
body:&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="nx"&gt;body&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="s2"&gt;`🔇 &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;suppressedCount&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; lower-priority remark&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;suppressedCount&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;s&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="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; suppressed to keep this review focused.\n\n`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why disclose the count instead of just trimming quietly? Because "quiet&lt;br&gt;
reviewer" and "reviewer that missed it" look identical from the outside&lt;br&gt;
unless something tells you which one you're looking at.&lt;/p&gt;

&lt;p&gt;Fair pushback on this design, and I don't have a clean answer: on a PR with&lt;br&gt;
more than 8 genuine bugs, the cap works against you. Sorting bugs to the&lt;br&gt;
front means you at least see the worst of it first, but "the cap doesn't&lt;br&gt;
hide real bugs" is not a guarantee I'm willing to write — only that severity&lt;br&gt;
ordering makes it less likely.&lt;/p&gt;
&lt;h2&gt;
  
  
  PRAISE is not decoration
&lt;/h2&gt;

&lt;p&gt;There's a fourth severity that isn't a problem at all — a slot reserved for&lt;br&gt;
"this was a good change." A review that's only ever negative gets the same&lt;br&gt;
treatment as a chatty one: people stop opening it.&lt;/p&gt;
&lt;h2&gt;
  
  
  Position anchoring, and the fallback that keeps a finding alive
&lt;/h2&gt;

&lt;p&gt;GitHub's inline PR comments only land if the position matches the actual&lt;br&gt;
diff hunk. If a finding's line doesn't anchor, the naive move is to drop it.&lt;br&gt;
Instead, the handler falls back to a single top-level comment that lists&lt;br&gt;
everything, formatted findings included:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;createReview&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="cm"&gt;/* ...inline comments... */&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;reviewError&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="s2"&gt;Inline review failed, falling back to issue comment:&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;reviewError&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;postPRComment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;octokit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;owner&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;repo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;prNumber&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;fallbackCommentBody&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;reviewResult&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;A formatting mismatch shouldn't be able to delete a finding.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug #1 — the webhook that succeeded and failed at the same time
&lt;/h2&gt;

&lt;p&gt;GitHub wants a 2XX response within roughly 10 seconds of a webhook delivery.&lt;br&gt;
Generating a review — fetch the diff, call the model, post the comments —&lt;br&gt;
routinely takes longer than that. Doing all of it synchronously meant GitHub&lt;br&gt;
logged the delivery as failed while my function kept running and posted the&lt;br&gt;
comments anyway. The delivery log said "failed." The PR said otherwise. Both&lt;br&gt;
were technically correct, which made it maddening to debug.&lt;/p&gt;

&lt;p&gt;The fix: verify the signature, return 200 immediately, and do the actual&lt;br&gt;
work in the background.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pull_request&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
  &lt;span class="nf"&gt;waitUntil&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nf"&gt;handlePullRequestEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;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="s2"&gt;PR review background processing failed:&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="nx"&gt;deliveryId&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="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Bug #2 — the silent fail that going async created
&lt;/h2&gt;

&lt;p&gt;Async fixed the timeout problem and introduced a worse one: if the&lt;br&gt;
background work dies partway through, the review row just sits at&lt;br&gt;
&lt;code&gt;"pending"&lt;/code&gt; forever. Not visible on the dashboard as an error. Not counted&lt;br&gt;
against quota. GitHub already has its 200. Nothing anywhere tells you a&lt;br&gt;
review didn't happen — that's the definition of a silent failure.&lt;/p&gt;

&lt;p&gt;The fix was to stop depending on platform defaults and make the ceiling&lt;br&gt;
explicit:&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;// Give the background work (PR file fetch + Claude review + GitHub post)&lt;/span&gt;
&lt;span class="c1"&gt;// enough time. Leaving this unset silently inherits Vercel's default,&lt;/span&gt;
&lt;span class="c1"&gt;// and on timeout the review sits at "pending" with no way to detect it.&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;maxDuration&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;120&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Going async doesn't remove failure, it changes what failure looks like.&lt;br&gt;
Anything that runs outside the request/response cycle needs its own&lt;br&gt;
explicit way of surfacing "this didn't finish" — a timeout, a status you&lt;br&gt;
can query, something. Silent pending isn't good enough.&lt;/p&gt;
&lt;h2&gt;
  
  
  Bug #3 — the subscription that said "Renews" after being cancelled
&lt;/h2&gt;

&lt;p&gt;This one wasn't a code review bug, it was a billing bug, and I only found it&lt;br&gt;
because I ran my own Stripe checkout on the live account. Cancelled a trial&lt;br&gt;
from the customer portal. Stripe's own dashboard confirmed "cancels&lt;br&gt;
[date]." My app's dashboard kept saying "Renews."&lt;/p&gt;

&lt;p&gt;The webhook handler was trusting &lt;code&gt;cancel_at_period_end&lt;/code&gt; as the single source&lt;br&gt;
of truth for "is this cancelling":&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="nx"&gt;cancelAtPeriodEnd&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cancel_at_period_end&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;// before the fix&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pulling the actual webhook payload in the Stripe dashboard showed the real&lt;br&gt;
shape of the event: &lt;code&gt;cancel_at_period_end&lt;/code&gt; stayed &lt;code&gt;false&lt;/code&gt; through the whole&lt;br&gt;
cancellation, while &lt;code&gt;cancel_at&lt;/code&gt; flipped from &lt;code&gt;null&lt;/code&gt; to a real timestamp.&lt;br&gt;
Current Stripe subscription behavior resolves an end-of-period cancellation&lt;br&gt;
directly to a &lt;code&gt;cancel_at&lt;/code&gt; timestamp rather than only flipping the boolean.&lt;br&gt;
The fix reads both:&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;// cancel_at_period_end alone isn't reliable here — cancellation can resolve&lt;/span&gt;
&lt;span class="c1"&gt;// straight to a cancel_at timestamp instead. Check both, keep the boolean&lt;/span&gt;
&lt;span class="c1"&gt;// path for backward compatibility.&lt;/span&gt;
&lt;span class="nx"&gt;cancelAtPeriodEnd&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cancel_at_period_end&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cancel_at&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things I took from this: don't trust a single boolean field to represent&lt;br&gt;
a state transition without checking the real payload first, and billing&lt;br&gt;
paths get tested with a real card, not a happy-path assumption about what&lt;br&gt;
the API returns. Six lines to fix, but shipped as-is it would have told&lt;br&gt;
every cancelling customer a lie about their own subscription.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;DevReview reviews GitHub pull requests and ranks findings BUG → WARN → NIT →&lt;br&gt;
PRAISE, hard-capped at 8 comments per review with the suppressed count&lt;br&gt;
disclosed rather than hidden. Free tier is $0 forever — 15 reviews/month on&lt;br&gt;
one repo, no card involved. Pro is $9/user/month with a 14-day trial (card&lt;br&gt;
required at checkout, first charge on day 15).&lt;/p&gt;

&lt;p&gt;&lt;a href="https://getdevreview.com" rel="noopener noreferrer"&gt;https://getdevreview.com&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I'd genuinely like to know where the 8-comment cap is the wrong call —&lt;br&gt;
tell me if you try it.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>programming</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
