<?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>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>
