<?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>I almost killed a project because my test harness was measuring its own bug</title>
      <dc:creator>phi_blankslate</dc:creator>
      <pubDate>Sat, 31 Oct 2026 22:36:09 +0000</pubDate>
      <link>https://dev.to/phi_blankslate/i-almost-killed-a-project-because-my-test-harness-was-measuring-its-own-bug-5bme</link>
      <guid>https://dev.to/phi_blankslate/i-almost-killed-a-project-because-my-test-harness-was-measuring-its-own-bug-5bme</guid>
      <description>&lt;p&gt;I was one command away from spending real API money on an experiment whose answer would have been wrong — and wrong in the most dangerous direction: it would have told me the thing I was testing &lt;em&gt;worked&lt;/em&gt;, for the wrong reason, or failed for a reason that had nothing to do with it.&lt;/p&gt;

&lt;p&gt;What saved me was boring. Before running the paid version, I ran the free version and actually read the raw data with my own eyes. This post is about what I found there, why measurement bugs tend to lie in your favor, and the rule I now follow before any expensive evaluation.&lt;/p&gt;

&lt;h2&gt;
  
  
  The question I was trying to answer
&lt;/h2&gt;

&lt;p&gt;The project was a small accessibility tool. The pipeline looked like this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Load a real web page in a headless browser (Playwright).&lt;/li&gt;
&lt;li&gt;Run &lt;a href="https://github.com/dequelabs/axe-core" rel="noopener noreferrer"&gt;axe-core&lt;/a&gt; on it to detect accessibility violations — missing button labels, images without alt text, invalid ARIA attributes, that kind of thing.&lt;/li&gt;
&lt;li&gt;For each violation, hand the offending HTML element to an LLM and ask it for a fix in a strict format:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;BEFORE:
```html
&amp;lt;the element as it is now&amp;gt;
```
AFTER:
```html
&amp;lt;the fixed element&amp;gt;
```
EXPLANATION: ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(The code comments you'll see below are in Japanese — that's how they are in the actual repo, and I've kept them as-is rather than rewrite code for the post.)&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Score the result.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The whole project hinged on one kill question: &lt;em&gt;are the AI's fix suggestions actually usable?&lt;/em&gt; If not, there was no product. So I wrote a verification harness that would run the pipeline over a list of real sites and classify every response.&lt;/p&gt;

&lt;p&gt;The classifier was strict on purpose. A response was only counted as a valid, applicable fix if it passed a chain of checks, in this order:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// 判定順序は仕様通り: api_error -&amp;gt; no_text -&amp;gt; truncated -&amp;gt; malformed -&amp;gt; unresolved -&amp;gt; noop -&amp;gt; before_mismatch -&amp;gt; source_truncated -&amp;gt; valid_applicable&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The two checks that matter for this story are &lt;code&gt;noop&lt;/code&gt; and &lt;code&gt;before_mismatch&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;normalizeHtml&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;after&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nf"&gt;normalizeHtml&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;before&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;noop&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="nf"&gt;normalizeHtml&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;before&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nf"&gt;normalizeHtml&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;nodeHtml&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;before_mismatch&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In plain English: the model's &lt;code&gt;BEFORE&lt;/code&gt; block has to match the element I gave it. That is how I make sure the fix can actually be applied to the page as a find-and-replace patch, rather than being a hallucinated rewrite of something that doesn't exist.&lt;/p&gt;

&lt;p&gt;That check felt rigorous. It turned out to be the exact place where the harness lied to me.&lt;/p&gt;

&lt;h2&gt;
  
  
  The free run
&lt;/h2&gt;

&lt;p&gt;The paid run needed an API key that I didn't have yet. While waiting, I added a &lt;code&gt;--dry-run&lt;/code&gt; flag:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;//   node k2-verify.js --dry-run  APIを一切呼ばずscanUrlの配線確認のみ&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It runs the browser and axe-core against every site, builds every input the LLM &lt;em&gt;would&lt;/em&gt; have received, and writes them all to disk — but never calls the API. Zero cost. The point was just to confirm the wiring.&lt;/p&gt;

&lt;p&gt;I almost didn't read the output. The run completed, the summary table looked plausible, and the natural next step was "wait for the key." Instead I opened the raw JSON and read the actual HTML snippets I was about to send to the model.&lt;/p&gt;

&lt;p&gt;Many of them were broken.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 1: axe-core's &lt;code&gt;node.html&lt;/code&gt; is not the element
&lt;/h2&gt;

&lt;p&gt;I had assumed that &lt;code&gt;violation.nodes[i].html&lt;/code&gt; — the HTML string axe-core attaches to every violation — was the element's markup. It's not. It's a &lt;em&gt;summary&lt;/em&gt; of the element, built for humans reading a report.&lt;/p&gt;

&lt;p&gt;I didn't trust my memory about how it was built, so I opened &lt;code&gt;node_modules/axe-core/axe.js&lt;/code&gt; and read the function that produces it. In the version I had installed (4.12.1), &lt;code&gt;_getElementSource&lt;/code&gt; takes a &lt;code&gt;maxLength&lt;/code&gt; that defaults to 300 and an &lt;code&gt;attrLimit&lt;/code&gt; that defaults to 20:&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;_getElementSource&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;node&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;_ref24&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;arguments&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;1&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;arguments&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="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;arguments&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{},&lt;/span&gt; &lt;span class="nx"&gt;_ref24$maxLength&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;_ref24&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;maxLength&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;maxLength&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;_ref24$maxLength&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="mi"&gt;300&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;_ref24$maxLength&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;_ref24$attrLimit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;_ref24&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;attrLimit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;attrLimit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;_ref24$attrLimit&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="mi"&gt;20&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;_ref24$attrLimit&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;deepStr&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getOuterHtml&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;node&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;deepStr&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="nx"&gt;maxLength&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="nx"&gt;getTruncatedElementSource&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;maxLength&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;maxLength&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 attribute truncation is this:&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;truncate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;attrLimit&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="nx"&gt;str&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;lt;=&lt;/span&gt; &lt;span class="nx"&gt;attrLimit&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;str&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;str&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;substring&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;attrLimit&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;...&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So when an element's &lt;code&gt;outerHTML&lt;/code&gt; is longer than 300 characters, you don't get the element. You get its opening tag only — children gone, closing tag gone — with long attribute values cut to 20 characters plus &lt;code&gt;...&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That's a completely reasonable design for a violations report. It's a terrible input for "please write a patch for this exact markup."&lt;/p&gt;

&lt;h3&gt;
  
  
  Why this bug points toward "pass"
&lt;/h3&gt;

&lt;p&gt;Here's the part that still makes me uncomfortable.&lt;/p&gt;

&lt;p&gt;When you hand an LLM a truncated, unclosed element and ask for &lt;code&gt;BEFORE&lt;/code&gt; and &lt;code&gt;AFTER&lt;/code&gt;, a well-behaved model copies what you gave it into &lt;code&gt;BEFORE&lt;/code&gt;. Truncated attribute and all. Then my strict classifier compares &lt;code&gt;BEFORE&lt;/code&gt; to the input I sent — the same truncated string — and they match.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;before_mismatch&lt;/code&gt; passes.&lt;/strong&gt; The response gets counted as an applicable fix.&lt;/p&gt;

&lt;p&gt;But it isn't applicable. The real page doesn't contain &lt;code&gt;&amp;lt;a href="https://example.co..."&amp;gt;&lt;/code&gt; with no closing tag. Nobody can find-and-replace that into a real codebase. The "patch" is a patch against a string that only exists inside my harness.&lt;/p&gt;

&lt;p&gt;My most rigorous check was rigorous against the wrong ground truth. And because the error made the model &lt;em&gt;look&lt;/em&gt; better, I would never have gone looking for it after a good-looking result. I would have celebrated.&lt;/p&gt;

&lt;h3&gt;
  
  
  The detection trap
&lt;/h3&gt;

&lt;p&gt;My first instinct for detecting truncated inputs was to search for the &lt;code&gt;...&lt;/code&gt; marker. That turned out to be wrong in both directions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;False positives:&lt;/strong&gt; real page text contains &lt;code&gt;...&lt;/code&gt;. A heading like &lt;code&gt;&amp;lt;h3&amp;gt;Be Bold...&amp;lt;/h3&amp;gt;&lt;/code&gt; or an attribute like &lt;code&gt;title="..."&lt;/code&gt; isn't truncation, it's content.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;False negatives:&lt;/strong&gt; if every attribute on an element is short, the opening-tag-only fallback leaves &lt;em&gt;no marker at all&lt;/em&gt;. A &lt;code&gt;&amp;lt;html lang="en"&amp;gt;&lt;/code&gt; with its entire document body silently dropped looks perfectly clean.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The reliable signal ended up being structural: a non-void element whose snippet has no closing tag has lost its children. A string search for a marker was never going to be enough, and I only found that out because I read the library source instead of guessing what it "probably" does.&lt;/p&gt;

&lt;h3&gt;
  
  
  The fix: re-fetch the real element
&lt;/h3&gt;

&lt;p&gt;axe-core gives you a &lt;code&gt;target&lt;/code&gt; array — a selector path to the element. So after axe runs, and before the browser context closes, I go back to the live page and ask for the real &lt;code&gt;outerHTML&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;refetchOuterHtml&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;target&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;locator&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;buildLocatorForTarget&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;target&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;html&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;locator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;first&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;evaluate&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;el&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;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;outerHTML&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;html&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;string&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="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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;evaluate did not return a string (outerHTML unavailable)&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="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="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;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;html&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="na"&gt;error&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="nx"&gt;message&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I still need &lt;em&gt;some&lt;/em&gt; size cap for a prompt, so I wrote my own truncation — one that keeps the opening tag fully intact, says explicitly what was removed, and keeps the element closed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;marker&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`\n&amp;lt;!-- rammendo: children omitted (&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;omittedChars&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; chars) --&amp;gt;\n`&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;closing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;tagName&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="s2"&gt;`&amp;lt;/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;tagName&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;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="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;html&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;openingTag&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="nx"&gt;closing&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="na"&gt;childrenOmitted&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The difference from the library's version isn't "truncate vs. don't truncate." It's that this truncation is &lt;em&gt;declared&lt;/em&gt;. The model can see that children were omitted, the result record carries &lt;code&gt;childrenOmitted: true&lt;/code&gt;, and the scorer can treat it accordingly instead of mistaking it for the full element.&lt;/p&gt;

&lt;p&gt;And crucially: if the re-fetch fails, I don't quietly fall back to axe's string. I fall back loudly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;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;`[rammendo] outerHTML再取得失敗 (rule=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;violation&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;, url=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;pageUrl&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;): &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;refetch&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="s2"&gt; — axeのnode.htmlにフォールバックします`&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;__rammendo&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;html&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;sourceKind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;axe_fallback&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every record carries a &lt;code&gt;sourceKind&lt;/code&gt;, and the summary report has a row that counts &lt;code&gt;axe_fallback&lt;/code&gt; and prints a large warning block if it's anything other than zero. A silent fallback would have recreated the exact lie I had just removed, the first time a selector failed to resolve.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 2: I was counting other people's bugs as my customer's
&lt;/h2&gt;

&lt;p&gt;The second problem showed up in the same free run, in a different column.&lt;/p&gt;

&lt;p&gt;One site had a lot of ARIA violations — far more than the rest. Reading the raw records, almost all of them were inside an embedded third-party booking widget. axe-core scans &lt;em&gt;through&lt;/em&gt; iframes by default, which is the right default for an audit ("your users experience this page, including the widget"). It's the wrong default for my question, which was "can the AI fix the site owner's code?" The site owner can't edit markup that lives inside someone else's iframe.&lt;/p&gt;

&lt;p&gt;So that site's numbers were inflated by violations nobody on that site could fix, and any fix the LLM wrote for them would be a fix to code nobody could apply.&lt;/p&gt;

&lt;p&gt;The tempting fix is a heuristic: "if the class name contains the vendor's name, skip it." I explicitly didn't do that. Class names are guesses. Instead I used two mechanical signals:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;axe's &lt;code&gt;target&lt;/code&gt; array is longer than one entry when the element is inside a frame. The leading entries are the frame path.&lt;/li&gt;
&lt;li&gt;For the outermost frame, read its actual &lt;code&gt;src&lt;/code&gt;, resolve it against the page URL, and compare origins.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;thirdParty&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;iframeOrigin&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;pageOrigin&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;inIframe&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;framePath&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;iframeSrc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;iframeOrigin&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;pageOrigin&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;thirdParty&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;originNote&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And when the signal is missing — an iframe with no &lt;code&gt;src&lt;/code&gt;, or &lt;code&gt;about:blank&lt;/code&gt;, or a &lt;code&gt;srcdoc&lt;/code&gt; — I refuse to guess:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;originNote&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;iframeにsrc属性が無いか、srcdoc/about:blank等の非URL値。推測でthird party判定はしない&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Third-party violations aren't deleted. They're reported in their own section, with the provider's origin, as "this is real, but it isn't in your code." Hiding them would be its own kind of dishonesty; mixing them into the fixable count was the other kind.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why measurement bugs lean toward "pass"
&lt;/h2&gt;

&lt;p&gt;Both bugs share a shape, and it's the reason I'm writing this up.&lt;/p&gt;

&lt;p&gt;A bug in the thing you're testing usually makes results worse, and bad results get investigated. A bug in the &lt;em&gt;instrument&lt;/em&gt; can make results better, and good results almost never get investigated. Nobody reruns an experiment that came out the way they hoped.&lt;/p&gt;

&lt;p&gt;Bug 1 is the clean example. The strict "BEFORE must match the input" check is a self-consistency check. If the input is wrong, self-consistency still holds — the model is perfectly consistent with garbage. Every check that compares an output to the harness's own input inherits whatever is wrong with the input.&lt;/p&gt;

&lt;p&gt;Bug 2 points the other way — inflated failure counts — but it has the same root: the instrument was answering a slightly different question from the one I asked. axe-core answers "what's wrong with this page as rendered?" I needed "what's wrong with this site owner's code?" The two differ by exactly the stuff inside third-party frames.&lt;/p&gt;

&lt;p&gt;In both cases, if I had gone straight to the paid run, I would have gotten a number, and the number would have been about my harness rather than about the AI.&lt;/p&gt;

&lt;h2&gt;
  
  
  A quick checklist for "is my harness lying?"
&lt;/h2&gt;

&lt;p&gt;After this, I wrote down the questions I now ask before I trust any number an eval harness produces. They're cheap, and most of them take less time than reading this section.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where does each input come from, exactly?&lt;/strong&gt; Not "from the scanner" — which field, produced by which function, with which defaults. If the answer involves a library, I want to have seen the line of code. In this case the answer was "a display string with a 300-character cap," and that one fact changed everything downstream.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What does a perfect score look like if the model does nothing clever at all?&lt;/strong&gt; If a model that simply echoes its input back would pass a check, that check is measuring the harness, not the model. My &lt;code&gt;BEFORE&lt;/code&gt; comparison failed this test: an echo passes it by construction. It's still a useful check — it rejects hallucinated rewrites — but it can't tell you whether the input was right.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What fraction of records took a fallback path?&lt;/strong&gt; If the answer isn't printed in the summary, I don't know it, and "I don't know" tends to quietly become "zero" in my head. Now it's a row in the report with a warning attached.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is my denominator the population I care about?&lt;/strong&gt; The third-party iframe problem was a denominator problem. The scanner's population was "everything rendered on the page"; mine was "everything the site owner can edit." Counting the wrong population produces a precise answer to a question nobody asked.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Would I notice if this check broke silently?&lt;/strong&gt; For each check I ask what the report would look like if it always returned "pass." If the honest answer is "about the same, maybe slightly better," that check needs a test of its own — a deliberately broken input that it has to reject.&lt;/p&gt;

&lt;p&gt;None of this requires a framework. It's a short list in a markdown file next to the harness, and I read it before every paid run.&lt;/p&gt;

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

&lt;p&gt;These are the rules I took away. None of them are novel; I just hadn't been following them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Before any paid evaluation, do a free run and read the raw inputs.&lt;/strong&gt;&lt;br&gt;
Not the summary table. The actual payloads that would have gone to the model. A dry-run mode costs almost nothing to add, and it's the only stage where the inputs exist without results to bias how you read them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Ask which direction the instrument would lie.&lt;/strong&gt;&lt;br&gt;
For every check, I now ask: "if this check is broken, does it fail too much or pass too much?" Checks that would pass too much get extra scrutiny, because nothing downstream will catch them. Self-consistency checks — output compared to the harness's own input — are the first suspects.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Read the library source for any data transformation you depend on.&lt;/strong&gt;&lt;br&gt;
"axe gives you the element's HTML" was a belief, not a fact. Opening &lt;code&gt;node_modules&lt;/code&gt; and finding &lt;code&gt;maxLength&lt;/code&gt; and &lt;code&gt;attrLimit&lt;/code&gt; took a few minutes. Anything a library does to your data — truncation, normalization, summarizing, deduplication — is worth confirming in code rather than in memory or docs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Make every fallback visible and countable.&lt;/strong&gt;&lt;br&gt;
If the good path fails and you fall back to a worse source, tag the record and count it in the report. A fallback that can't be seen in the summary is just the original bug with extra steps.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Separate "what the tool reports" from "what you're actually asking."&lt;/strong&gt;&lt;br&gt;
A scanner's scope (the rendered page, including iframes) and your question's scope (the code a specific person can change) are often slightly different. Write the question down explicitly, then check whether the instrument's default matches it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;6. When a signal is missing, record that it's missing.&lt;/strong&gt;&lt;br&gt;
For the iframe check, "no &lt;code&gt;src&lt;/code&gt;" doesn't mean first-party or third-party. It means unknown, and the record says so in a note. Guessing would have been quieter and worse.&lt;/p&gt;

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

&lt;p&gt;The scariest part of this isn't that the harness had bugs. Every harness has bugs. It's that I was about to use the result to make a go/no-go decision about the whole project.&lt;/p&gt;

&lt;p&gt;If the bugs had pushed the numbers down, I might have concluded "AI fixes aren't good enough" and dropped an idea because of a problem in my own wiring that took an afternoon to fix. If they had pushed the numbers up, I would have built on a foundation that wasn't there. Either way, the decision would have been made by my instrument, not by the thing I thought I was measuring.&lt;/p&gt;

&lt;p&gt;The free run didn't make the project succeed. It made the eventual measurement mean what I wanted it to mean. That's a much smaller claim, and it's the one I actually care about now.&lt;/p&gt;

&lt;p&gt;If you're building an eval harness for an LLM feature — especially one where the model's output gets compared to the input you constructed — I'd be curious how you guard against the same thing. Do you read raw payloads before every run, or do you have checks that catch a broken input automatically?&lt;/p&gt;

</description>
      <category>a11y</category>
      <category>javascript</category>
      <category>testing</category>
      <category>ai</category>
    </item>
    <item>
      <title>How We Secure DevReview (October 2026)</title>
      <dc:creator>phi_blankslate</dc:creator>
      <pubDate>Sun, 25 Oct 2026 08:37:42 +0000</pubDate>
      <link>https://dev.to/phi_blankslate/how-we-secure-devreview-october-2026-26lm</link>
      <guid>https://dev.to/phi_blankslate/how-we-secure-devreview-october-2026-26lm</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on the &lt;a href="https://www.getdevreview.com/blog/how-we-secure-devreview-2026-10" rel="noopener noreferrer"&gt;DevReview blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;DevReview reads your pull requests. That means you are trusting us with code that isn't public yet. This post lays out what we do to deserve that trust, what outside scanners say about us, and what we have not finished.&lt;/p&gt;

&lt;p&gt;This is our own write-up, not an audit. Scores are dated because they change.&lt;/p&gt;

&lt;h2&gt;
  
  
  What outside scanners say (measured 2026-10-24)
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Qualys SSL Labs: A+ on one endpoint, A on the other.&lt;/strong&gt; Our site is served from two edge addresses; we report both rather than the better one. &lt;a href="https://www.ssllabs.com/ssltest/analyze.html?d=www.getdevreview.com" rel="noopener noreferrer"&gt;Run the test yourself&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mozilla HTTP Observatory: B (75), 11 of 12 checks passed.&lt;/strong&gt; The one we lose points on is Content Security Policy. Our CSP currently runs in &lt;strong&gt;Report-Only&lt;/strong&gt; mode: browsers report violations to us but do not block them yet. We are collecting reports first so that enforcing it doesn't break checkout or sign-in. We plan to switch to enforcement in early November 2026 and will update this post when we do. &lt;a href="https://developer.mozilla.org/en-US/observatory/analyze?host=www.getdevreview.com" rel="noopener noreferrer"&gt;Run the test yourself&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Your code
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;We don't keep your code.&lt;/strong&gt; DevReview fetches the diff it needs to review, sends it to the model, and posts comments. We don't store the diff, and we don't log the model's raw output.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Old data is deleted on a schedule.&lt;/strong&gt; A daily job removes installations that were never completed after 30 days, and error details from failed reviews after 90 days. We checked the first scheduled run in production on 2026-10-24.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What this means for you: if you uninstall, there isn't an archive of your source code sitting on our side.&lt;/p&gt;

&lt;h2&gt;
  
  
  The doors into our servers
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Webhooks are verified before anything else.&lt;/strong&gt; GitHub and Stripe webhooks are checked for a valid signature first. Bodies over a fixed size (2 MB for GitHub, 1 MB for Stripe) are rejected with &lt;code&gt;413&lt;/code&gt; before we parse them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Errors don't leak internals.&lt;/strong&gt; If something fails, you get a generic message. Stack traces and database errors stay in our logs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;HTTPS only.&lt;/strong&gt; HSTS is set for two years.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;New free accounts get a short wait.&lt;/strong&gt; GitHub accounts younger than 7 days can't use the free plan, which keeps throwaway accounts from burning review capacity.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Referral codes are random.&lt;/strong&gt; They come from a cryptographic random generator, not a counter you could guess.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How we run things behind the scenes
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Two-step sign-in on every admin account&lt;/strong&gt; we use to run DevReview (hosting, database, payments, domain, email, source code). One provider (our domain registrar) only offers emailed codes rather than an authenticator app.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Email that can't easily be spoofed.&lt;/strong&gt; SPF, DKIM and DMARC all pass, and DMARC is set to quarantine mail that fails.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Breach monitoring&lt;/strong&gt; for our domain via Have I Been Pwned.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Secret scanning before every push.&lt;/strong&gt; A gitleaks hook blocks a push if it finds something that looks like a key.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Production dependencies:&lt;/strong&gt; &lt;code&gt;npm audit&lt;/code&gt; reports 0 known vulnerabilities as of 2026-10-22.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;We practiced restoring the database.&lt;/strong&gt; Our database keeps 6 hours of point-in-time history, and we ran a restore drill on a copy rather than trusting the docs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A written first-30-minutes incident card&lt;/strong&gt;, plus a ledger of every key we hold and how to rotate it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What is not done yet
&lt;/h2&gt;

&lt;p&gt;We'd rather say this plainly than have you find it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;CSP is Report-Only&lt;/strong&gt;, as explained above.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Per-user rate limits and re-authentication for sensitive actions&lt;/strong&gt; are built but not shipped yet. They're waiting on a final review.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CAA DNS records&lt;/strong&gt; aren't set, because our DNS provider doesn't support them. We're looking at options.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Tips for your side
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Install DevReview only on the repositories you want reviewed, not "All repositories".&lt;/li&gt;
&lt;li&gt;Turn on two-factor authentication on your GitHub account if you haven't.&lt;/li&gt;
&lt;li&gt;If you leave, uninstall the GitHub App. That stops all access immediately.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Found something?
&lt;/h2&gt;

&lt;p&gt;Please tell us. Our &lt;a href="https://www.getdevreview.com/.well-known/security.txt" rel="noopener noreferrer"&gt;security.txt&lt;/a&gt; and &lt;a href="https://www.getdevreview.com/security" rel="noopener noreferrer"&gt;security page&lt;/a&gt; explain how to report a vulnerability. We read every report.&lt;/p&gt;

</description>
      <category>security</category>
      <category>webdev</category>
      <category>saas</category>
      <category>nextjs</category>
    </item>
    <item>
      <title>Stripe marks a subscription "active" before anyone has paid. Here's how I tested a reward that has to wait for real money.</title>
      <dc:creator>phi_blankslate</dc:creator>
      <pubDate>Thu, 22 Oct 2026 01:09:06 +0000</pubDate>
      <link>https://dev.to/phi_blankslate/stripe-marks-a-subscription-active-before-anyone-has-paid-heres-how-i-tested-a-reward-that-has-49p0</link>
      <guid>https://dev.to/phi_blankslate/stripe-marks-a-subscription-active-before-anyone-has-paid-heres-how-i-tested-a-reward-that-has-49p0</guid>
      <description>&lt;p&gt;I added a referral program to my SaaS. The rule sounds simple: when someone you referred becomes a paying annual customer, you get an account credit. Not when they sign up, not when they start a trial, but when money has actually moved.&lt;/p&gt;

&lt;p&gt;Writing that logic took an afternoon. Proving it worked took much longer, and most of that time went into one uncomfortable fact: the only event that matters, "the referred customer paid", happens at the end of a trial, on an annual plan, and you only get one real shot at it per customer. The next natural billing event for that subscription is a year away.&lt;/p&gt;

&lt;p&gt;This post is about how I tested that path without charging a real card, without creating fake accounts on any platform, and without touching my production database. The tools were Stripe Test Clocks and a disposable Postgres branch. Along the way the test surfaced two things I would not have found by reading the code: a &lt;code&gt;$0&lt;/code&gt; trial invoice counts as &lt;code&gt;paid&lt;/code&gt;, and on recent Stripe API versions the invoice's &lt;code&gt;subscription&lt;/code&gt; field is simply &lt;code&gt;null&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The stack is Next.js on Vercel, Drizzle, and Neon Postgres, but the testing approach doesn't depend on any of that.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug that shaped the design: "active" is not "paid"
&lt;/h2&gt;

&lt;p&gt;My first version of the reward check looked at the subscription status. If it was &lt;code&gt;active&lt;/code&gt; and on the annual price, the referrer got their credit.&lt;/p&gt;

&lt;p&gt;A security review caught the problem before it shipped. When a trial ends, Stripe moves the subscription out of &lt;code&gt;trialing&lt;/code&gt; and creates the first real invoice. The &lt;code&gt;customer.subscription.updated&lt;/code&gt; event with &lt;code&gt;status: "active"&lt;/code&gt; can arrive &lt;em&gt;before&lt;/em&gt; the payment on that invoice has succeeded. If the card is declined, the subscription later goes to &lt;code&gt;past_due&lt;/code&gt;, but by then my code had already handed out a credit for a payment that never happened.&lt;/p&gt;

&lt;p&gt;So the rule changed from "the subscription is active" to "the subscription is active &lt;strong&gt;and&lt;/strong&gt; its latest invoice is paid &lt;strong&gt;and&lt;/strong&gt; the amount paid is greater than zero". The trigger also moved: the main entry point is now the &lt;code&gt;invoice.paid&lt;/code&gt; webhook, with &lt;code&gt;customer.subscription.updated&lt;/code&gt; kept only as a fallback.&lt;/p&gt;

&lt;p&gt;Here is the eligibility check as it exists in the code today (comments translated from my original notes):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;rewardReferralIfEligible&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="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="kd"&gt;const&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="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;select&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="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;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;stripeSubscriptionId&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="nf"&gt;limit&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="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;referral&lt;/span&gt; &lt;span class="o"&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;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;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="c1"&gt;// Primary idempotency key: if a transaction ID is already recorded, the credit was granted.&lt;/span&gt;
  &lt;span class="c1"&gt;// Confirm "rewarded" without calling Stripe.&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;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;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="c1"&gt;// Re-check eligibility against Stripe's actual state. Don't trust the caller's pre-filtering.&lt;/span&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;priceId&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;items&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="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]?.&lt;/span&gt;&lt;span class="nx"&gt;price&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;priceId&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;priceId&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;PLAN_PRICE_IDS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pro_annual&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;latestInvoice&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
    &lt;span class="k"&gt;typeof&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;latest_invoice&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;object&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&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;latest_invoice&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;
      &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;latest_invoice&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;Invoice&lt;/span&gt;&lt;span class="p"&gt;)&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="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;span class="c1"&gt;// Compare-and-swap: only one process can move pending -&amp;gt; rewarded.&lt;/span&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;span class="c1"&gt;// ... resolve the referrer's Stripe customer, check for an existing credit, grant it&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every condition in that function is a claim about how Stripe behaves at the end of a trial. I had read the docs, and a reviewer had read the code. Neither of those is evidence. I needed to watch a trial end.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the obvious test plans didn't work
&lt;/h2&gt;

&lt;p&gt;Before I found a setup I liked, I talked myself out of three others. Each failure taught me something about what the test actually needed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Plan 1: subscribe for real in production, then cancel during the trial.&lt;/strong&gt; I had used this trick before to test checkout, because cancelling inside the trial costs nothing. But it tests nothing here. The reward only fires on &lt;code&gt;invoice.paid&lt;/code&gt; after a successful charge. Cancel during the trial and the code under test never runs. A free test of the wrong path is not a cheap test of the right one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Plan 2: subscribe for real, but give the test customer a balance credit large enough to cover almost the whole invoice.&lt;/strong&gt; This looked clever until I reread my own guard: &lt;code&gt;amount_paid &amp;gt; 0&lt;/code&gt;. If the credit covers the full amount, Stripe records the invoice as paid with nothing actually charged, and my code correctly refuses to reward it. To get through the guard I would have had to leave a small remainder on the card. That's workable, but it's a real charge on a real card in a real account, and it still runs against production data.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Plan 3, the one that actually killed the real-money approach: the referred customer has to be a different person.&lt;/strong&gt; My self-referral guard rejects a referral when the referrer and the referred user share a user ID or an email address. That is the point of the guard. To test the happy path end to end in production, I needed a second account that signs in through the same OAuth provider my app uses. I run exactly one account on that platform on purpose. Creating a second account to test my own app is precisely the kind of thing that gets accounts suspended, and I wasn't going to trade that risk for a test.&lt;/p&gt;

&lt;p&gt;Once all three were off the table, the shape of the right test was clear. I didn't need a real person or a real card. I needed Stripe to believe that a trial had ended and a payment had succeeded, and I needed my database to contain the rows that would exist if a real referral had happened. Both of those can be faked safely, as long as the fakes are fenced off from everything real.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup: Stripe test mode, a Test Clock, and a throwaway database branch
&lt;/h2&gt;

&lt;p&gt;The test environment had three pieces:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Stripe test mode.&lt;/strong&gt; Customers, subscriptions, and invoices created with a &lt;code&gt;sk_test_&lt;/code&gt; key. Test card tokens like &lt;code&gt;pm_card_visa&lt;/code&gt; make payments succeed without any real card.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A Stripe Test Clock.&lt;/strong&gt; A test-mode customer can be attached to a clock that starts frozen at a chosen time. You then advance the clock, and Stripe runs every billing event that would have happened in between: the trial ends, the first invoice is created, payment is attempted.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A Neon branch of the production database.&lt;/strong&gt; Branching gives a copy of the schema (and data) that can be written to freely and then deleted. The production database is never connected.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I wrote one script with a subcommand for each step, so every step could be run, inspected, and repeated on its own: &lt;code&gt;setup&lt;/code&gt;, &lt;code&gt;reward&lt;/code&gt;, &lt;code&gt;advance&lt;/code&gt;, &lt;code&gt;assert&lt;/code&gt;, &lt;code&gt;reset-pending&lt;/code&gt;, &lt;code&gt;cleanup&lt;/code&gt;. Each subcommand prints a single JSON line with IDs, statuses, and counts, and never prints connection strings or keys.&lt;/p&gt;

&lt;h3&gt;
  
  
  Guards first
&lt;/h3&gt;

&lt;p&gt;A test script that creates customers and writes rows is one environment variable away from doing that in production. So before any subcommand does anything, it runs the same guard function:&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;// Never write to a DATABASE_URL whose hostname doesn't start with this&lt;/span&gt;
&lt;span class="c1"&gt;// (prevents accidental writes to production or other branches)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;S8_BRANCH_ENDPOINT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ep-xxxxxxxx&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// the test branch's endpoint, redacted here&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;assertGuards&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="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;stripe&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="nl"&gt;priceId&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="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;secretKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;STRIPE_SECRET_KEY&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;secretKey&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;secretKey&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;sk_test_&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;printAndExit&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;STRIPE_SECRET_KEY_NOT_TEST_MODE&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;connectionString&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;DATABASE_URL&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;connectionString&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;printAndExit&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;DATABASE_URL_NOT_SET&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="na"&gt;hostname&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="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;hostname&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;connectionString&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;hostname&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="nf"&gt;printAndExit&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;DATABASE_URL_UNPARSEABLE&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;hostname&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="nx"&gt;S8_BRANCH_ENDPOINT&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;printAndExit&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;DATABASE_URL_NOT_S8_BRANCH&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;priceId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;STRIPE_PRICE_PRO_ANNUAL&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;priceId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;printAndExit&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;STRIPE_PRICE_PRO_ANNUAL_NOT_SET&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="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;const&lt;/span&gt; &lt;span class="nx"&gt;price&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;prices&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;priceId&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;price&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;livemode&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;printAndExit&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;PRICE_IS_LIVEMODE&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="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="nx"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;priceId&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;There are three independent checks: the key prefix, the database hostname, and the &lt;code&gt;livemode&lt;/code&gt; flag on the price Stripe actually returns. The database check is an allowlist of one specific branch, not a denylist of production. A denylist would quietly allow any other database I happened to point it at.&lt;/p&gt;

&lt;p&gt;The connection string for the branch went into a separate, git-ignored env file that the script loads on top of the normal one. I never needed to see its value, and the script never printed it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Creating the world
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;setup&lt;/code&gt; builds everything a real referral would have produced:&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;clock&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;testHelpers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;testClocks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;frozen_time&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;s8-referral-e2e&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;customerB&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;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;s8-referred@example.test&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;S8 Referred&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;test_clock&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;clock&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;payment_method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pm_card_visa&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;invoice_settings&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;default_payment_method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pm_card_visa&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;s8&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;1&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;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;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;customer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;customerB&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;items&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;price&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;priceId&lt;/span&gt; &lt;span class="p"&gt;}],&lt;/span&gt;
  &lt;span class="na"&gt;trial_period_days&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;14&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;discounts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;coupon&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;REFERRAL_COUPON_ID&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the database side, inside one transaction, it inserts two users (referrer A and referred B), a &lt;code&gt;trialing&lt;/code&gt; subscription row for B, and a &lt;code&gt;pending&lt;/code&gt; referral row linking them. The &lt;code&gt;.example.test&lt;/code&gt; email addresses are reserved for testing and can't belong to anyone.&lt;/p&gt;

&lt;p&gt;One detail was deliberate: &lt;strong&gt;user A gets no subscription row at all.&lt;/strong&gt; On the free plan in my app, a user has no Stripe customer and no subscription row until they first check out. The reward code has to create a Stripe customer for the referrer on demand before it can credit them. That lazy path is exactly the kind of branch that never runs in development, so the test environment was built to force it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test 1: the negative test, during the trial
&lt;/h2&gt;

&lt;p&gt;Before advancing the clock, I ran the reward function against the subscription while it was still in its trial. The expected outcome was: nothing happens, and the referral stays &lt;code&gt;pending&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;It did stay &lt;code&gt;pending&lt;/code&gt;. The interesting part was &lt;em&gt;why&lt;/em&gt;. It would be easy to assume that during a trial the latest invoice is missing, or unpaid. It was neither. The subscription already had a latest invoice, its status was &lt;code&gt;paid&lt;/code&gt;, and its &lt;code&gt;amount_paid&lt;/code&gt; was &lt;code&gt;0&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;When a subscription starts with a trial, Stripe creates a zero-amount invoice for the trial period and marks it paid. If my guard had been &lt;code&gt;latestInvoice.status === "paid"&lt;/code&gt; on its own, which reads like a perfectly reasonable definition of "the customer paid", the reward would have fired the moment the trial &lt;em&gt;started&lt;/em&gt;, with no money involved at all.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;amount_paid &amp;gt; 0&lt;/code&gt; half of the condition was the only thing standing between a correct reward and a free one. It looks like a minor extra check when you read the code. On the most ordinary path in the system, the trial, it is the check doing all the work.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test 2: advancing the clock past the end of the trial
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;advance&lt;/code&gt; moves the Test Clock to a point a few hours after the trial ends, then waits for Stripe to finish processing:&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;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;testHelpers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;testClocks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;advance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;testClockId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;frozen_time&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;trialEnd&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;3600&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;startedAt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;ready&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;startedAt&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;ADVANCE_TIMEOUT_MS&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;clock&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;testHelpers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;testClocks&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;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;testClockId&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;clock&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;ready&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;ready&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;break&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;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ADVANCE_POLL_MS&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;Advancing a clock is asynchronous. The API call returns right away, and the clock reports a non-ready status while Stripe works through the simulated time. Reading the subscription before the clock is &lt;code&gt;ready&lt;/code&gt; gives you a state that is halfway through the transition. That's another way to fool yourself, so the script polls until &lt;code&gt;ready&lt;/code&gt; and fails loudly on timeout.&lt;/p&gt;

&lt;p&gt;After the advance, the subscription was &lt;code&gt;active&lt;/code&gt;, the latest invoice was &lt;code&gt;paid&lt;/code&gt;, the amount paid was exactly the discounted annual price, and the &lt;code&gt;billing_reason&lt;/code&gt; was &lt;code&gt;subscription_cycle&lt;/code&gt;. That's the state my production webhook would see.&lt;/p&gt;

&lt;h3&gt;
  
  
  The surprise: &lt;code&gt;invoice.subscription&lt;/code&gt; was null
&lt;/h3&gt;

&lt;p&gt;The webhook handler for &lt;code&gt;invoice.paid&lt;/code&gt; has to find out which subscription the invoice belongs to. Older Stripe API versions put that on &lt;code&gt;invoice.subscription&lt;/code&gt;. Newer versions, starting with the release Stripe calls Basil, moved it to &lt;code&gt;invoice.parent.subscription_details.subscription&lt;/code&gt;. My SDK version and my webhook endpoint's API version were not the same, so I had written the extractor to check 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;// Invoice -&amp;gt; subscription ID. Basil and later: invoice.parent.subscription_details.subscription.&lt;/span&gt;
&lt;span class="c1"&gt;// Older versions: invoice.subscription. Check both, because the SDK's pinned version and the&lt;/span&gt;
&lt;span class="c1"&gt;// webhook endpoint's version differ.&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;extractSubscriptionId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;invoice&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;Invoice&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;inv&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;invoice&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;unknown&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;subscription&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="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="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;parent&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;subscription_details&lt;/span&gt;&lt;span class="p"&gt;?:&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="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="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="kc"&gt;null&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="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&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;raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;inv&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;parent&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;subscription_details&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;subscription&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;inv&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subscription&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;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;raw&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;raw&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When I wrote that fallback, I wasn't sure it was needed. It might just have been defensive noise. So the &lt;code&gt;advance&lt;/code&gt; step logged both paths separately on the real invoice. The new path had the subscription ID. The old path was &lt;code&gt;null&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Without the new path, &lt;code&gt;handleInvoicePaid&lt;/code&gt; would have returned early on every invoice. No error, no log line, just a referral sitting in &lt;code&gt;pending&lt;/code&gt; forever. Because this is an annual plan, "forever" is not an exaggeration: there is no second chance until the renewal a year later. That's the kind of bug a Test Clock is good at finding, because it hands you a real Stripe object of the current shape rather than the shape you remember.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test 3: granting the reward, and proving it happened exactly once
&lt;/h2&gt;

&lt;p&gt;With the subscription in its paid state, I ran the reward path through my manual sweep script, first as a dry run and then for real. Then &lt;code&gt;assert&lt;/code&gt; checked the outcome from three independent angles:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the &lt;code&gt;referrals&lt;/code&gt; row for this subscription was &lt;code&gt;rewarded&lt;/code&gt;, and there was exactly one row;&lt;/li&gt;
&lt;li&gt;the referrer now had a subscription row and a Stripe customer, created lazily by the reward code;&lt;/li&gt;
&lt;li&gt;the referrer's Stripe balance held exactly the expected credit, and exactly one balance transaction contained the marker string &lt;code&gt;(referral &amp;lt;id&amp;gt;)&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;"Exactly one" was the whole point. A reward system that double-pays is worse than one that pays late, because a late payment can be fixed with a sweep and a double payment means asking someone to give money back.&lt;/p&gt;

&lt;h3&gt;
  
  
  Re-running everything
&lt;/h3&gt;

&lt;p&gt;First, I simply ran the sweep again, and then called the reward function directly. Nothing changed. The referral was no longer &lt;code&gt;pending&lt;/code&gt;, so the function returned immediately.&lt;/p&gt;

&lt;p&gt;Second, I tested the failure mode that worried me more. The reward code calls Stripe with an idempotency key, so a retried request inside Stripe's retention window returns the original result instead of creating a second credit. But Stripe's docs say idempotency keys can be removed after they are &lt;a href="https://docs.stripe.com/api/idempotent_requests" rel="noopener noreferrer"&gt;at least 24 hours old&lt;/a&gt;. If the Stripe call succeeded but my process lost the response, the code rolls the referral back to &lt;code&gt;pending&lt;/code&gt;. If the retry then happens a day later, the idempotency key no longer protects anything.&lt;/p&gt;

&lt;p&gt;To simulate that, &lt;code&gt;reset-pending&lt;/code&gt; forces the row back into the "we don't know what happened" 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="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;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="na"&gt;stripeBalanceTransactionId&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;state&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="nf"&gt;returning&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then I ran the sweep again. This time the reward code reached its second line of defense. It listed the referrer's balance transactions on Stripe, found the existing credit by its marker, recorded that transaction's ID, and confirmed &lt;code&gt;rewarded&lt;/code&gt; without creating a new credit. The balance transaction count stayed at one.&lt;/p&gt;

&lt;p&gt;That second line of defense came out of a review, and up to this point it had only ever been read, never executed. Now it had run against a real Stripe API response.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cleanup is part of the test
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;cleanup&lt;/code&gt; deletes the Test Clock, then deletes the test referral, subscription, and user rows from the branch and prints how many rows of each it removed. After that I deleted the database branch itself and the temporary env file that held its connection string.&lt;/p&gt;

&lt;p&gt;I treat cleanup as a test step rather than an afterthought. A leftover database branch with a real-looking schema, and an env file pointing at it, is something future-me could connect to by mistake months later. A test that leaves debris behind has side effects, and side effects are exactly what I was trying to keep away from production in the first place.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this test did not prove
&lt;/h2&gt;

&lt;p&gt;This setup proved the reward logic: the eligibility conditions, the lazy customer creation, the compare-and-swap, the double-payment defenses, and the invoice shape on the current API version. It did &lt;strong&gt;not&lt;/strong&gt; prove:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;that the production webhook endpoint actually receives &lt;code&gt;invoice.paid&lt;/code&gt;. My script called the reward function directly instead of going through the webhook, so it can't see the endpoint's configuration at all. This one wasn't hypothetical: when the switch to &lt;code&gt;invoice.paid&lt;/code&gt; was made, a review added "confirm the endpoint is subscribed to &lt;code&gt;invoice.paid&lt;/code&gt;" as a separate pre-launch check. When I looked at the production endpoint, it &lt;em&gt;wasn't&lt;/em&gt; subscribed, and I had to add the event by hand;&lt;/li&gt;
&lt;li&gt;that the live Checkout page applies the referral discount correctly (I checked that separately, by creating a real Checkout Session with the same parameters and looking at the page, without completing it);&lt;/li&gt;
&lt;li&gt;that a real second person can go through the whole flow in the UI.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I wrote those gaps down next to the test results, in the same document. A test report that lists only what passed invites the next reader, usually me, to assume it covered everything.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd generalize from this
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Any condition based on "paid" needs a test that runs while a trial is still in progress.&lt;/strong&gt; Zero-amount invoices are real invoices with a real &lt;code&gt;paid&lt;/code&gt; status. If your logic means "money moved", say so in the code with an amount check, and make sure a test exercises the trial state, not just the after-trial state.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Time-dependent billing logic deserves a clock you control.&lt;/strong&gt; If the event you care about happens once per customer, at the end of a trial or a renewal, don't wait for it and don't fake it by calling your handler with a hand-written payload. A Test Clock gives you real Stripe objects in their real current shape, which is exactly what exposed the &lt;code&gt;invoice.subscription: null&lt;/code&gt; problem.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fence your test harness with allowlists, not hope.&lt;/strong&gt; Key prefix, a specific database hostname, and the &lt;code&gt;livemode&lt;/code&gt; flag of an object fetched from the API. Three cheap checks, each one independent of the others, all of them run before the first write.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Test your second line of defense by deliberately breaking the first.&lt;/strong&gt; Idempotency keys expire, responses get lost, rows get rolled back. If your defense against that is "look for an existing record before writing", the only way to know it works is to put the system into the ambiguous state on purpose and run it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Build the code path you can't reach in development.&lt;/strong&gt; My lazy customer creation only runs for a referrer who has never checked out. In normal development I always have a subscription, so it would never have run. The test data was shaped specifically to force that branch.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Write down what the test didn't cover, and give each gap its own check.&lt;/strong&gt; The most important production misconfiguration in this feature, a webhook endpoint not subscribed to the event that triggers the reward, sat entirely outside what my test could see. A green test run would never have revealed it. It was caught only because it had its own line on the pre-launch checklist, separate from the test.&lt;/p&gt;

&lt;p&gt;Most of the harness is guards and output formatting. The Test Clock calls themselves are a small fraction of it. That ratio seems about right to me: moving time is easy, and making sure you only moved time where you meant to is the part that takes the effort.&lt;/p&gt;

</description>
      <category>stripe</category>
      <category>typescript</category>
      <category>webdev</category>
      <category>api</category>
    </item>
    <item>
      <title>I moved my webhook work into waitUntil() to fix a timeout. The failures didn't go away, they just stopped being visible.</title>
      <dc:creator>phi_blankslate</dc:creator>
      <pubDate>Wed, 14 Oct 2026 01:11:34 +0000</pubDate>
      <link>https://dev.to/phi_blankslate/i-moved-my-webhook-work-into-waituntil-to-fix-a-timeout-the-failures-didnt-go-away-they-just-2nd</link>
      <guid>https://dev.to/phi_blankslate/i-moved-my-webhook-work-into-waituntil-to-fix-a-timeout-the-failures-didnt-go-away-they-just-2nd</guid>
      <description>&lt;p&gt;My GitHub App takes a pull request, asks an LLM to review it, and posts the comments back. The first time I ran it end to end against a real repository, the review showed up on the PR exactly as intended. Then I opened GitHub's webhook delivery log and found the delivery marked as failed.&lt;/p&gt;

&lt;p&gt;Both things were true at once: the feature worked, and the delivery had failed. That contradiction is the interesting part, and the fix I reached for first created a worse problem than the one it solved. This post walks through both, with the code, because the second problem is the kind that doesn't announce itself.&lt;/p&gt;

&lt;p&gt;The stack is Next.js (App Router) on Vercel, Drizzle and Postgres, but nothing below depends on those. If you run slow work behind a webhook on a serverless platform, you will meet the same shape.&lt;/p&gt;

&lt;h2&gt;
  
  
  The symptom: a successful review and a failed delivery
&lt;/h2&gt;

&lt;p&gt;The handler was the obvious one. Verify the signature, parse the payload, do the work, return 200:&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="k"&gt;await&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;break&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;handlePullRequestEvent&lt;/code&gt; fetches the changed files, calls the model, and posts a review. The model call alone regularly takes longer than ten seconds.&lt;/p&gt;

&lt;p&gt;GitHub's webhook best-practices page says a delivery should respond within 10 seconds, and in my notes from this incident GitHub does not automatically redeliver a failed one. So the sequence was:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;GitHub sends the &lt;code&gt;pull_request&lt;/code&gt; event.&lt;/li&gt;
&lt;li&gt;My handler is still waiting on the LLM when GitHub gives up at about ten seconds and records the delivery as failed.&lt;/li&gt;
&lt;li&gt;My function keeps running anyway, because the serverless runtime doesn't know the caller hung up. It finishes, posts the review, and writes the result to the database.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;So the user-visible outcome was fine, and the only evidence of a problem was a red mark in a log that nobody looks at until something else goes wrong. That is a bad state to leave a system in even when the feature technically works: the next time a delivery fails for a real reason, it will look exactly like this one.&lt;/p&gt;

&lt;p&gt;The cause is simple. I was doing work whose duration I don't control inside the request/response cycle of a caller that gives me a fixed budget.&lt;/p&gt;

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

&lt;p&gt;Acknowledge immediately, do the work afterwards. On Vercel the primitive for "keep running after the response is sent" is &lt;code&gt;waitUntil&lt;/code&gt; from &lt;code&gt;@vercel/functions&lt;/code&gt;:&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;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;waitUntil&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;@vercel/functions&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="k"&gt;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="c1"&gt;// Generating the review often takes longer than 10 seconds, which is longer&lt;/span&gt;
  &lt;span class="c1"&gt;// than GitHub's delivery timeout (about 10s, one attempt). ACK immediately&lt;/span&gt;
  &lt;span class="c1"&gt;// and keep working in the background.&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;p&gt;Everything before this point is cheap and synchronous: verify the HMAC signature, parse the JSON. Then the response goes out, and the review is generated in the background.&lt;/p&gt;

&lt;p&gt;It type-checked and the change was small, so I was pleased with it. Before shipping, I ran a security review pass over the diff, and it pointed at the exact line I was pleased with. After I addressed that finding (below), I deployed, re-ran the end-to-end test, and the delivery log showed green.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this is worse than it looks
&lt;/h2&gt;

&lt;p&gt;Before the change, a failure had a natural place to show up. If the handler threw, GitHub got a 500, and the delivery was red. The work and the acknowledgement were the same event, so a problem with one was a problem with the other.&lt;/p&gt;

&lt;p&gt;After the change, the acknowledgement says only "I received your payload and the signature was valid." It says nothing about whether the work will finish. The failure modes of the background job are now invisible to the caller by construction:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The function hits its platform time limit and is killed.&lt;/li&gt;
&lt;li&gt;A deploy replaces the running instance.&lt;/li&gt;
&lt;li&gt;The process runs out of memory.&lt;/li&gt;
&lt;li&gt;An upstream call hangs and never returns.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of those run your &lt;code&gt;.catch&lt;/code&gt;. A killed process doesn't get to log anything or update any row. The delivery log stays green, because the delivery succeeded. What's left is whatever state you wrote before you started the work.&lt;/p&gt;

&lt;p&gt;In my case that state was a row in the &lt;code&gt;reviews&lt;/code&gt; table. I create it with &lt;code&gt;status = 'pending'&lt;/code&gt; before returning, and the background job moves it to &lt;code&gt;completed&lt;/code&gt; or &lt;code&gt;failed&lt;/code&gt; at the end. If the job dies in the middle, the row stays &lt;code&gt;pending&lt;/code&gt; for good.&lt;/p&gt;

&lt;p&gt;That sounds like a cosmetic problem, a spinner that never stops. It isn't, for a reason specific to how I count usage. A pending row counts toward the user's monthly quota, deliberately:&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;// Rows that count toward the quota:&lt;/span&gt;
&lt;span class="c1"&gt;//   pending (in progress) counts too. If only completed rows were counted,&lt;/span&gt;
&lt;span class="c1"&gt;//   concurrent webhooks that arrive before an earlier review finishes would all&lt;/span&gt;
&lt;span class="c1"&gt;//   be judged to be within the limit, making the free tier effectively unlimited.&lt;/span&gt;
&lt;span class="c1"&gt;//   failed rows are excluded in principle, but a row that failed after the&lt;/span&gt;
&lt;span class="c1"&gt;//   Claude API was called (tokens_used &amp;gt; 0) still counts...&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;countsTowardQuota&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="nf"&gt;or&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;ne&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;reviews&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;failed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;gt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;reviews&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tokensUsed&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I count pending rows because otherwise a burst of simultaneous events all look like they fit under the limit. That's correct, and it means a row that is stuck as pending is not just an ugly record. It permanently consumes someone's allowance. A killed background job turns into a user being told they've used reviews they never received.&lt;/p&gt;

&lt;p&gt;So the second lesson, after "don't hold the caller hostage," is that moving work out of the request cycle doesn't remove the failure modes. It moves them to a place where nothing is watching.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 1: make the work end before the platform ends it
&lt;/h2&gt;

&lt;p&gt;The first thing to do about a job that can be killed silently is to make sure it gets a chance to fail loudly instead. That means giving it an explicit time budget that is shorter than the platform's.&lt;/p&gt;

&lt;p&gt;Without an explicit limit, the route depends on the platform default, and my comment in the code says what I found out about that:&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 processing (PR file fetch + Claude API review generation +&lt;/span&gt;
&lt;span class="c1"&gt;// GitHub post) enough time. Leaving this unset depends implicitly on the Vercel&lt;/span&gt;
&lt;span class="c1"&gt;// default and fails silently when interrupted (reviews stay "pending" and become&lt;/span&gt;
&lt;span class="c1"&gt;// undetectable).&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;And the model client gets a timeout that is strictly below it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Keep this shorter than maxDuration (the webhook background limit) so a hung&lt;/span&gt;
&lt;span class="c1"&gt;// API call surfaces as an error before the platform timeout (silent-fail guard).&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Anthropic&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;90&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The ordering matters more than the specific numbers. If the client timeout is longer than the function limit, the platform wins the race, kills the process, and nothing runs &lt;code&gt;catch&lt;/code&gt;. If the client timeout is shorter, the SDK throws, my &lt;code&gt;catch&lt;/code&gt; block runs, and the row is marked &lt;code&gt;failed&lt;/code&gt; with an error message. A timeout I control turns into a status I can query. A timeout the platform controls turns into silence.&lt;/p&gt;

&lt;p&gt;There is one caveat I want to be upfront about, because it's the kind of thing I'd want to know if I were copying this. A 90-second timeout on the client is a per-request limit. As far as I know, the Anthropic SDK retries failed requests by default, a couple of times. I have not set that option in this codebase and I have not tested what happens when a request times out and is retried. If retries do apply to timeouts, then the total time spent could be several multiples of 90 seconds, which is well beyond the function limit and would put the platform back in charge. I'm flagging that as something I still need to check rather than as a finding. The general point holds either way: when you stack a timeout under a deadline, budget the total across all attempts, not the single attempt.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 2: assume the first layer will sometimes fail anyway
&lt;/h2&gt;

&lt;p&gt;Timeouts catch the hung call. They don't catch a deploy that replaces the instance mid-flight, or an out-of-memory kill, because in those cases no code of mine gets to run at all.&lt;/p&gt;

&lt;p&gt;So the cleanup can't live in the dying process. It has to be done by something else, later. I didn't want to add a scheduler and a new moving part for a problem this small, so the reaper runs lazily: the next time the same user's webhook arrives, it sweeps their stale rows before counting anything.&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;// Turn stuck pending rows (function cut off, OOM, replaced by a deploy, never&lt;/span&gt;
&lt;span class="c1"&gt;// reaching completed/failed) into failed. Rows that died before the Claude call&lt;/span&gt;
&lt;span class="c1"&gt;// (tokens_used=0) drop out of the quota; rows that died after it (the webhook&lt;/span&gt;
&lt;span class="c1"&gt;// writes a sentinel tokens_used&amp;gt;=1) stay in the quota even once failed.&lt;/span&gt;
&lt;span class="c1"&gt;// The threshold is 10 minutes, comfortably longer than maxDuration (120s).&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;STALE_PENDING_MINUTES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;;&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;sweepStalePending&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="nb"&gt;Pick&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;update&lt;/span&gt;&lt;span class="dl"&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;userId&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cutoff&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="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;STALE_PENDING_MINUTES&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="nx"&gt;_000&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="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;reviews&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;failed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;errorMessage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`stale pending swept (older than &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;STALE_PENDING_MINUTES&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;m)`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;completedAt&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;reviews&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;userId&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;reviews&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="nf"&gt;lt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;reviews&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;createdAt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;cutoff&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;Three decisions in there are worth spelling out.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The threshold is derived from the limit.&lt;/strong&gt; Ten minutes is not a round number I liked. It has to be comfortably larger than &lt;code&gt;maxDuration&lt;/code&gt;, otherwise the sweeper could mark a healthy, still-running job as failed while it is still working. The sweep is only safe because "older than ten minutes" means "the platform has already killed it", and that holds only as long as the threshold stays above the function limit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It runs inside the same transaction and the same lock as the check it protects.&lt;/strong&gt; The sweep is called from the function that reserves a review slot, after taking a per-user advisory lock:&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;tx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sql&lt;/span&gt;&lt;span class="s2"&gt;`select pg_advisory_xact_lock(&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;LOCK_NS_USER&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;, hashtext(&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;))`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Reap stale pending rows under the lock, then count (serialized against&lt;/span&gt;
&lt;span class="c1"&gt;// concurrent runs for the same user)&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;sweepStalePending&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;userId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The point of doing the sweep under the lock is that the count which decides "is this user over the limit" is computed on cleaned-up data, and no other request for that user can interleave between the cleanup and the count.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Whether a swept row still costs the user depends on how far it got.&lt;/strong&gt; This is the part I like least to have to think about, and the part I'm happiest I did think about. A job that died before it called the model has cost nothing, so its row should fall out of the quota. A job that died after the model call started has cost real money, and if it were free it would be an exploit: anyone who could make the post-generation step fail on purpose would get unlimited generations. So the webhook writes a sentinel immediately before calling the model:&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;// Write a sentinel (tokens_used=1) just before the Claude call. If the call&lt;/span&gt;
&lt;span class="c1"&gt;// times out or the function is cut off before completing, the row still counts&lt;/span&gt;
&lt;span class="c1"&gt;// toward the quota as "generation started, cost may have been incurred". With&lt;/span&gt;
&lt;span class="c1"&gt;// tokens_used=0, a failed/swept row would free the quota and let someone abort&lt;/span&gt;
&lt;span class="c1"&gt;// generations to burn Claude cost for free.&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;reviews&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;tokensUsed&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="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;reviews&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;reviewRecord&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;tokensUsed&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The sweeper then doesn't need to know anything about what the job was doing. It just flips &lt;code&gt;pending&lt;/code&gt; to &lt;code&gt;failed&lt;/code&gt;, and the previously written &lt;code&gt;tokens_used&lt;/code&gt; decides whether that row still counts. The state machine carries the accounting.&lt;/p&gt;

&lt;h2&gt;
  
  
  The reaper created a new problem: a retry budget it can exhaust
&lt;/h2&gt;

&lt;p&gt;Once stuck rows get turned into &lt;code&gt;failed&lt;/code&gt;, something else has to decide whether that commit may be reviewed again. A webhook can be redelivered, and a user can close and reopen a PR, so the slot reservation does two checks before it allows another run for the same &lt;code&gt;(repository, PR, commit)&lt;/code&gt;:&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;// 1-a: already delivered (completed / pending) -&amp;gt; reject immediately&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;delivered&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="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;count&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;count&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;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;reviews&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="nx"&gt;sameCommit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;ne&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;reviews&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;failed&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;delivered&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;count&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;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;allowed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;used&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;limit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;record&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="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;duplicate&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kd"&gt;const&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 that &lt;code&gt;pending&lt;/code&gt; counts as "delivered" here. That is what stops a redelivered webhook from running the same review twice while the first is still going. It also means that a stuck pending row blocks the commit entirely until the sweep clears it, which is one more reason the sweep has to exist.&lt;/p&gt;

&lt;p&gt;The second check caps total attempts, because without a cap a close/reopen loop could trigger paid calls forever:&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;attemptsSince&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="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;ATTEMPT_WINDOW_MS&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;attempts&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="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;count&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;count&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;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;reviews&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="nx"&gt;sameCommit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;notQuotaNotice&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nf"&gt;gte&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;reviews&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;createdAt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;attemptsSince&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;attempts&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;count&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;gt;=&lt;/span&gt; &lt;span class="nx"&gt;MAX_ATTEMPTS_PER_COMMIT&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;allowed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;used&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;limit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;record&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="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;attempt-cap&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;gte(reviews.createdAt, attemptsSince)&lt;/code&gt; window is the interesting line, and it exists because of the reaper. A review caught by the sweep is a &lt;code&gt;failed&lt;/code&gt; row, and failed rows count as attempts. My own comment in the code explains the consequence of leaving the window out: the "zero-cost abort" rows the sweeper creates would pile up, and after a few infrastructure accidents of my own making, such as deploys replacing a running instance, that commit could never be reviewed again. The user would have done nothing wrong, and my cleanup logic would have permanently blocked them.&lt;/p&gt;

&lt;p&gt;I only found that by thinking about the reaper as a producer of data, not just a consumer. Every row it writes feeds the other rules in the system. If you add a janitor, trace what its output does to every check that reads the same table.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this still doesn't cover
&lt;/h2&gt;

&lt;p&gt;I'd rather list the gaps than have you find them.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The sweep is lazy.&lt;/strong&gt; It runs when that user's next webhook arrives. If they never send another, their stuck row stays pending. The place that displays the monthly count is read-only, so a stuck row keeps showing as used until the next event cleans it up. I decided that was acceptable for the volumes involved, and I wrote the decision down in a comment, but it is a real gap, and a scheduled sweep would close it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A swept review is simply gone.&lt;/strong&gt; The user's PR doesn't get a review, and nothing retries it. A new push produces a new event with a new commit, which is a fresh attempt. I left it that way because automatic retries on a billable operation are their own can of worms.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The time budget is reasoning, not measurement.&lt;/strong&gt; The 120, the 90 and the 10 come from how I reason about the pieces interacting. I haven't load-tested a run where several of those limits are approached together.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;If you only take one thing away: &lt;strong&gt;acknowledging a webhook early doesn't make the work reliable, it makes the work unobserved.&lt;/strong&gt; Before the change, a slow job turned the delivery red, which was annoying but honest. After it, the delivery is always green, and the only truth left is the state you wrote to your own database. So that state has to be designed:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Write a durable record before you acknowledge.&lt;/strong&gt; The row exists before the response goes out, so there is something to inspect if the job vanishes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Give the work a deadline shorter than the platform's, and order the timeouts.&lt;/strong&gt; Make your own timeout fire first, so failure arrives as an exception you can handle rather than a kill you can't.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Put the cleanup outside the process that might die.&lt;/strong&gt; A reaper that runs from somewhere else, later, is the only thing that works when &lt;code&gt;catch&lt;/code&gt; doesn't.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Derive the reaper's threshold from the limit, and decide in advance what a reaped row costs.&lt;/strong&gt; If "failed" means "free," someone will find out how to fail on purpose.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The webhook stayed fast, the delivery log stayed green, and the failure mode that used to be loud became a row with an error message and a timestamp. That is a better place for it to live. But I only got there after a review pass pointed at the one line I thought I'd finished with, which is a good reminder that "the red thing turned green" is not the same as "the problem is solved."&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>nextjs</category>
      <category>debugging</category>
      <category>api</category>
    </item>
    <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>
