<?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>Forem: Sasaki Ryuji</title>
    <description>The latest articles on Forem by Sasaki Ryuji (@ryuji_saas).</description>
    <link>https://forem.com/ryuji_saas</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3944430%2F10193f14-7792-4c73-b2f7-3c12cdb99f0e.png</url>
      <title>Forem: Sasaki Ryuji</title>
      <link>https://forem.com/ryuji_saas</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/ryuji_saas"/>
    <language>en</language>
    <item>
      <title>I asked Claude if my plan was on track for the goal — and got an honest 'No'</title>
      <dc:creator>Sasaki Ryuji</dc:creator>
      <pubDate>Mon, 25 May 2026 06:34:47 +0000</pubDate>
      <link>https://forem.com/ryuji_saas/i-asked-claude-if-my-plan-was-on-track-for-the-goal-and-got-an-honest-no-3e7n</link>
      <guid>https://forem.com/ryuji_saas/i-asked-claude-if-my-plan-was-on-track-for-the-goal-and-got-an-honest-no-3e7n</guid>
      <description>&lt;p&gt;The moment you start thinking &lt;em&gt;"am I actually on track for my goal?"&lt;/em&gt; — the best move I've found as a solo founder is to ask the AI sitting right next to me, honestly. &lt;strong&gt;On May 24-25, 2026, I (Sasaki Ryuji) asked Claude Code (Opus 4.7) "are we hitting the 90% probability mark on my revenue goal?" and got back an honest "No."&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The satisfying feeling of stacking up new initiatives, vs. the cold reality of probability assessment — these two don't usually sit together. The more I ship, the more I drift toward "I should be making it." Asking the AI is a way to zero-reset that drift. This post is the raw log of that conversation, published as part of a Process-Driven series. &lt;strong&gt;Results aren't in yet. I'm just sharing the judgment and correction process as it happens.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The honest "No": structural reasons in numbers
&lt;/h2&gt;

&lt;p&gt;I asked Claude:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Honestly evaluate: am I on track for 90% probability of hitting my revenue goal? No optimism bias — just the raw numbers.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Claude's response was colder than I expected.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Claude's reply&lt;/strong&gt;: "No, this is foundational work to keep the 12-month-goal at 70-80% probability, but it doesn't reach the higher-goal 90%. Here's the probability snapshot (2026-05-24, optimism-bias-removed): for the medium goal — 12 months: 17-26%, 18 months: 70-80%, 24 months: 85-90%. For the stretch goal — 12 months: 5-10%, 24 months: 40-50%, 36 months: 60-70%, 48 months: 80-90%. Hitting the stretch goal at 90% in 12 months is structurally impossible."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;When numbers are on the table, the argument is over. I took notes. The basis for the probability snapshot rested on three structural axes:&lt;/p&gt;

&lt;h3&gt;
  
  
  Why 90% in 12 months is structurally impossible
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;AdSense approval rate ceiling&lt;/strong&gt;: New solo-blog AdSense applications pass at ~55-70% (primary source: multiple public logs from indie developer communities). Even when you pass, revenue takes 3-6 months to ramp. That's the "monetization lag" tax built into the timeline.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sixth-site launch timing&lt;/strong&gt;: Planned for early July, individual AdSense application in late September, revenue ramp December - February 2027. The probability that a brand-new site reaches meaningful MRR within 12 months (= by May 2027) is around 10-20%.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SaaS MRR ramp&lt;/strong&gt;: My existing SaaS shows linear month-over-month growth measured against actual 2026 numbers. There's no structural driver right now that suddenly 3x's MRR in a 12-month window.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The phrase "foundational work to keep the goal at 70-80%" was Claude's own framing. I'd never called it "foundational work." That was the AI's own language for what I was actually doing. &lt;strong&gt;And it was right.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Not disappointing. Actually liberating. &lt;strong&gt;Probability assessment for a business should be produced with numbers, decoupled from the founder's own hopes&lt;/strong&gt; — and whether you have someone (human or AI) who can do that for you is, I think, a survival condition for solo founder work. In a company, the PR team would pressure you to "set expectations higher." With AI, you get No with zero deference. That's a structural advantage.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why citing sources matters
&lt;/h3&gt;

&lt;p&gt;The reason Claude's number-based assessment was trustworthy: &lt;strong&gt;the source of every probability is explicitly logged in a memory file&lt;/strong&gt;. When Claude says "industry-average AdSense pass rate is 55-70%", it can show exactly which memory file the citation comes from. Without that, AI-produced numbers become "numbers-that-sound-right" — a perfect breeding ground for optimism bias.&lt;/p&gt;

&lt;p&gt;Since May 24, every time I have Claude output a probability, I respond with "source?" Numbers without a memory-file source don't get adopted. That rule is now permanently encoded in &lt;code&gt;feedback_distinguish_completion_from_effectiveness.md&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  "OK, then design a different route to 90%": three parallel approaches
&lt;/h2&gt;

&lt;p&gt;After the honest No, I pushed:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;OK. Then design a different route to put the stretch goal at 90%.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Claude's response was structurally clear:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Claude's reply&lt;/strong&gt;: "Three parallel approaches. Approach A: scale to 10 media sites (currently 5, with the 6th launching in July). Approach B: ship a new SaaS that single-handedly clears the goal. Approach C: scale the existing SaaS MRR. If A/B/C succeed independently and you only need one to hit, the probability of at-least-one-success ≈ 86%. Reaching the stretch goal requires at least 2 of 3 to succeed, which is reachable within 90% range over 30-36 months."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The probability calculation was honest. Each approach independently:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A: 10-site media empire&lt;/strong&gt; → reaching meaningful monthly revenue = 60-75%&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;B: new SaaS&lt;/strong&gt; → same target = 30-50%&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;C: existing SaaS scale&lt;/strong&gt; → same target = 50-70%&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;"Probability all three fail" = (1 - 0.60) × (1 - 0.30) × (1 - 0.50) = 0.40 × 0.70 × 0.50 = &lt;strong&gt;14%&lt;/strong&gt;.&lt;br&gt;
"Probability at-least-one succeeds" = 1 - 14% = &lt;strong&gt;86%&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;This is just basic independent probability, but I'd never explicitly written it out for my own work. The framing "instead of betting all on one approach, run multiple independent positive-EV bets in parallel and exploit the math of independence" was philosophically different from the standard startup advice of "focus."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"Focus is everything" makes sense for an early-stage employee. For a solo founder running multiple income engines, the math is the opposite.&lt;/strong&gt; That clicked for me on May 24, the day Claude wrote it out in my session. The strategy got persisted to &lt;code&gt;expansion-strategy-no-narrowing.md&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Same day, killed Approach B. Down to two parallel approaches.
&lt;/h2&gt;

&lt;p&gt;The same evening, after writing out the 3-approach plan, I dug deeper into Approach B (the new SaaS candidate). I asked Claude to apply a 3-step filter (an existing rule from &lt;code&gt;third-saas-candidates-comparison-2026-05-25.md&lt;/code&gt;):&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Official feature check&lt;/strong&gt; (5 min): does the target platform already provide the same feature?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Existing SaaS competition check&lt;/strong&gt; (10 min): if 3+ competing products already exist for the feature, flag it red ocean.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Founder's first-hand pain check&lt;/strong&gt; (15 min): does this come from real friction the founder has, or just an idea?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I had 4 candidates for Approach B. &lt;strong&gt;All 4 turned out to be red ocean&lt;/strong&gt; when filtered. The detailed breakdown is in the linked memory file, but the gist: official platform features had already absorbed each space, and 3+ existing SaaS competitors meant any solo dev entering would face structural disadvantage.&lt;/p&gt;

&lt;p&gt;Approach B was killed before MVP work began. &lt;strong&gt;3 months of dev time saved, before I'd written a line of code.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That meant the 3-parallel-approach strategy got revised to 2-parallel (A + C). The probability of "at-least-one succeeds" stays at 86%, but reaching the stretch goal at 90% pushed back from 30-36 months to &lt;strong&gt;42-48 months&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The honesty isn't comfortable, but it's accurate. &lt;strong&gt;Results aren't in yet, and the longer time-to-target is the actual reality of running only solid bets.&lt;/strong&gt; That recognition got written into Process-Driven series post #1 (the one before this one) as the founding example.&lt;/p&gt;




&lt;h2&gt;
  
  
  The technique of not letting AI become a yes-machine
&lt;/h2&gt;

&lt;p&gt;The biggest discovery from this back-and-forth: &lt;strong&gt;AI without intervention drifts into optimism&lt;/strong&gt;. Claude particularly likes to write words like "blue ocean", "no competition", "definite", and "guaranteed". Those words feel fluent, but they're the exact loaded language that breeds optimism bias.&lt;/p&gt;

&lt;p&gt;By May 25, I'd already caught 7 distinct optimism-bias incidents from Claude in my sessions. Concrete patterns:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;First-draft requirements for a new SaaS: "blue ocean!" — turned out to be red ocean on competition check&lt;/li&gt;
&lt;li&gt;Affiliate revenue projection: "results soon!" — flagged by founder ("affiliate doesn't pay out in a week, you know?")&lt;/li&gt;
&lt;li&gt;New media site proposal: "second SaaS is the killer move!" — feature parity already shipped by the platform itself&lt;/li&gt;
&lt;li&gt;Strategic verdict: "30-36 months to 90% is reasonable!" — too optimistic for the time-stamp&lt;/li&gt;
&lt;li&gt;Stripe launch on a separate subdomain: "we need a dedicated site!" — founder flagged: "couldn't we just put it on the existing site?"&lt;/li&gt;
&lt;li&gt;Legal page status for our intake LP: "critical, this is a launch blocker!" — founder flagged: "isn't that outside our team's scope?"&lt;/li&gt;
&lt;li&gt;Cookie consent banner: "AdSense application blocker, fix today!" — actually only applies to EU/UK visitors and isn't a blocker for a Japan-focused site&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;All seven caught by founder intervention.&lt;/strong&gt; The structural risk of AI yes-machine behavior is real; AI alone can't catch its own optimism bias. The mechanism that &lt;em&gt;can&lt;/em&gt; catch it is the founder's intuitive "wait, is this actually true?"&lt;/p&gt;

&lt;p&gt;Three operational rules that work for me:&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Force a memory re-read before any judgment
&lt;/h3&gt;

&lt;p&gt;Before AI says words like "critical", "next move", "should do", "definite", a strict rule kicks in: re-read related memory files. That's now permanently encoded in &lt;code&gt;feedback_re_read_memory_before_judgment.md&lt;/code&gt; after 3 consecutive judgment errors on May 24-25 that were all caught by founder intervention but all had answers already written in memory.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Force pre- and post-implementation verification
&lt;/h3&gt;

&lt;p&gt;Every implementation requires pre-implementation verification (multi-axis: primary source check / competition check / regulatory check, run via parallel agents) and post-implementation verification (an 8-axis checklist matching AdSense / monetization / structural similarity / legal / quality standards). Zero exceptions.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Don't let subagents pretend first-hand experience
&lt;/h3&gt;

&lt;p&gt;When delegating article writing to a subagent, never instruct "write as the founder's first-hand experience". Subagents don't read the actual codebase or git log, so they fabricate convincing-sounding episodes 100% of the time. The Stripe Webhook article that got published yesterday (May 24) initially had "May 2025 v1.0.0 launch caused 30 duplicate Discord notifications" — entirely fabricated by the subagent. Caught in post-implementation verification, rewritten as "typical patterns warned about in Stripe's official documentation" with all fabricated specifics removed.&lt;/p&gt;

&lt;p&gt;The combination of these three rules turns AI from a yes-machine into something more like a brutally honest co-founder. Without them, AI alone defaults to confirmation. The founder's directive — "be critical when you disagree, don't just affirm" — is what makes the partnership actually work.&lt;/p&gt;




&lt;h2&gt;
  
  
  Conclusion: results aren't in, but the judgment process gets preserved
&lt;/h2&gt;

&lt;p&gt;What I'd want any solo founder reading this to take away:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Asking AI "am I on track?" with zero deference is a survival technique&lt;/strong&gt;, not a defeat. The AI's honest No is more useful than a coach's polite Yes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Probability assessment must be decoupled from your own hopes&lt;/strong&gt;. Whether you have a human or AI capable of that decoupling determines long-term solo founder survival.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Running 2-3 parallel independent bets exploits the math of independence&lt;/strong&gt;, raising at-least-one-success probability to 86%. The "focus is everything" advice is for employees, not for multi-engine solo founders.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AI is a yes-machine by default&lt;/strong&gt;. The technique of breaking that default = enforced memory re-read + pre/post verification + no fabricated first-person via subagent. The combination turns AI into a co-founder you can actually argue with.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Process-Driven publishing&lt;/strong&gt; — sharing the judgment process before results are known — is what corporate blogs structurally can't do. That's the moat for a solo founder.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is post #2 of the Process-Driven series. Results aren't in yet. I'll keep publishing the judgment and correction process as it happens — including the failed bets and direction changes.&lt;/p&gt;

&lt;p&gt;If you're a solo founder running multiple income engines and have your own technique for breaking AI's yes-machine default, I'd love to hear it in the comments.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://saas-diary.com/ai-driven/ai-driven-asked-ai-90-percent-honest-no/" rel="noopener noreferrer"&gt;saas-diary.com&lt;/a&gt; as part of an ongoing Process-Driven series documenting AI-pair-programming the build of a multi-site media + SaaS operation in 2026.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>indiehackers</category>
      <category>claude</category>
      <category>startup</category>
    </item>
    <item>
      <title>The night Meta flagged my Instagram automation - what I rewrote in v1.5.0</title>
      <dc:creator>Sasaki Ryuji</dc:creator>
      <pubDate>Sat, 23 May 2026 02:59:25 +0000</pubDate>
      <link>https://forem.com/ryuji_saas/the-night-meta-flagged-my-instagram-automation-what-i-rewrote-in-v150-1ham</link>
      <guid>https://forem.com/ryuji_saas/the-night-meta-flagged-my-instagram-automation-what-i-rewrote-in-v150-1ham</guid>
      <description>&lt;p&gt;If you're building any kind of SNS automation tool, the fight against bot detection is part of the job. This post is the postmortem of the day Meta's automation detection caught one of the Instagram accounts I was running through my indie SaaS (GramShift), what the actual trigger pattern looked like from the operator side, and the design rewrite I shipped in v1.5.0 as a response.&lt;/p&gt;

&lt;p&gt;Note up front: the specific thresholds, probabilities, and timing distributions inside the pacing engine are the product's moat and are intentionally not in this post. The shape of the rewrite is what's interesting; the exact numbers stay private.&lt;/p&gt;

&lt;h2&gt;
  
  
  What happened
&lt;/h2&gt;

&lt;p&gt;I detect the event from my own logs first, not from Instagram. The like count for a running cycle drops far below the expected band, and the operation log starts emitting &lt;code&gt;action_blocked&lt;/code&gt;. I open the affected account in a browser, log in, and the dashboard greets me with:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;We're sorry, but we limit how often certain actions can be performed.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This is not a permanent ban. It's a soft cooldown — "we're restricting this account's actions for a while." But if you keep automating through it, the escalation path leads to a permanent ban. I turn off every feature for that account in my tool immediately and let it sit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Postmortem: it wasn't one thing, it was three things stacking
&lt;/h2&gt;

&lt;p&gt;Sitting down with the two weeks of cycle logs leading up to the event, the picture that emerged was not a single smoking gun. It was three "aggressive" settings stacked on top of each other:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Cycle intervals too short, and the variance band too narrow.&lt;/strong&gt; The intervals were random, but the range was tight enough that on aggregate it still looked mechanical to a frequency analysis.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Daily action total was sitting near the implicit ceiling.&lt;/strong&gt; Industry chatter puts soft daily ceilings for IG engagement actions in a fairly well-known band. I was running near the top of that band, every day, with no rest days.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Actions were firing through the middle of the night.&lt;/strong&gt; A real person sleeps. An account that doesn't is one of the cheapest signals to detect.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Any one of these in isolation is probably survivable. All three at once for two weeks is the kind of pattern that gets flagged. That's the lesson — for this class of tool, individual parameters are less interesting than the &lt;em&gt;interaction&lt;/em&gt; between parameters.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cooldown
&lt;/h2&gt;

&lt;p&gt;Public information on how long the soft cooldown lasts is all over the map — "24 hours to 7 days" is what you'll find quoted. I left the account fully idle for several days, not just my tool but the human-side login too. When I checked back, the warning was gone and the normal feed was back. I assumed (correctly, in hindsight) that the account was likely still under closer monitoring for a while, and dialled the settings way down for the restart.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rewrite: GramShift v1.5.0 Human-Pacing
&lt;/h2&gt;

&lt;p&gt;Once the dust settled, I rewrote the pacing layer from the ground up. The shape of the rewrite, without the actual numbers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Wider, less mechanical interval distribution.&lt;/strong&gt; Fixed intervals or narrow random bands aggregate into a detectable rhythm. A distribution closer to how humans actually pace themselves is the goal.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hard skip at night-time hours.&lt;/strong&gt; If a human wouldn't be doing this, the tool shouldn't either.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Per-cycle action count is now variable, with a real ceiling.&lt;/strong&gt; "Like everything until you hit a wall" is structurally not allowed by the new design.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Daily cycle count cap.&lt;/strong&gt; No matter what the per-cycle math says, the day-level total is bounded so the account never drifts toward the implicit ceiling.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The short-term cost of this is real — engagement-driven follower growth gets slower. The trade against the alternative (lost account, lost followers, lost post history) is not close.&lt;/p&gt;

&lt;h2&gt;
  
  
  The conservative-restart rule
&lt;/h2&gt;

&lt;p&gt;After v1.5.0 shipped, I added an operator-side rule for any &lt;em&gt;new&lt;/em&gt; Instagram account being onboarded into the tool: the first two weeks are explicitly &lt;em&gt;under&lt;/em&gt;-tuned. Slow mode, follow feature off, daytime hours only, minimum number of cycles. The point is to let Meta classify the account as a "normal user" before any aggressiveness ramps up at all.&lt;/p&gt;

&lt;p&gt;Probably the single biggest lesson from this whole thing: &lt;strong&gt;for an SNS automation tool, the most important KPI is not engagement efficiency, it's account lifetime.&lt;/strong&gt; A banned account loses everything you built on top of it. A design that's harder to flag is more valuable than a design that's quicker to grow, in the same way that a slower compiler that produces correct code is more valuable than a fast compiler that produces wrong code.&lt;/p&gt;

&lt;p&gt;If you're building anything in this space, I'd argue the right mental model is: you're not optimizing for the next thousand likes, you're optimizing for the account still being alive in a year. Almost every parameter decision flips when you frame it that way.&lt;/p&gt;




&lt;p&gt;Curious if anyone else building bot-adjacent automation has done a similar postmortem. The "three aggressive settings stacked" pattern feels like it generalizes well beyond Instagram — same shape is probably hiding in scrapers, API clients, and any other class of tool where a counterparty is doing pattern detection on you.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>indiedev</category>
      <category>discuss</category>
    </item>
    <item>
      <title>Lessons from building Electron auto-update across 25 releases</title>
      <dc:creator>Sasaki Ryuji</dc:creator>
      <pubDate>Fri, 22 May 2026 00:34:26 +0000</pubDate>
      <link>https://forem.com/ryuji_saas/lessons-from-building-electron-auto-update-across-25-releases-1ah7</link>
      <guid>https://forem.com/ryuji_saas/lessons-from-building-electron-auto-update-across-25-releases-1ah7</guid>
      <description>&lt;p&gt;If you're shipping an Electron desktop app to end users and you don't have auto-update wired in, you're going to keep getting "this is broken" bug reports from users who are actually running a version from three releases ago.&lt;/p&gt;

&lt;p&gt;I've shipped 25 releases of GramShift Desktop (an Instagram automation indie SaaS) over the past several months, and bolting on auto-update from day one was easily one of the highest-ROI infrastructure decisions I've made. This post is the implementation core + the specific traps I walked into.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setup: electron-updater + GitHub Releases
&lt;/h2&gt;

&lt;p&gt;The stack is boring on purpose:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;electron-updater&lt;/code&gt; (the npm package) handles update detection + download + install&lt;/li&gt;
&lt;li&gt;GitHub Releases hosts the installer assets + a &lt;code&gt;latest.yml&lt;/code&gt; manifest&lt;/li&gt;
&lt;li&gt;Clients periodically check &lt;code&gt;latest.yml&lt;/code&gt; against their current version&lt;/li&gt;
&lt;li&gt;On detection, they download and prompt the user to install&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Release flow:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;npm version patch&lt;/code&gt; (or &lt;code&gt;minor&lt;/code&gt;/&lt;code&gt;major&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;npm run build&lt;/code&gt; produces installers (&lt;code&gt;.exe&lt;/code&gt;, &lt;code&gt;.dmg&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Upload &lt;code&gt;.exe&lt;/code&gt; + &lt;code&gt;latest.yml&lt;/code&gt; to a new GitHub Release&lt;/li&gt;
&lt;li&gt;Running clients see the new manifest on next check, prompt the user, install&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The big upside: &lt;strong&gt;no separate update server&lt;/strong&gt;. GitHub Releases acts as your CDN. The free tier handles indie-scale traffic without breaking a sweat.&lt;/p&gt;

&lt;h2&gt;
  
  
  Minimum-viable main-process code
&lt;/h2&gt;

&lt;p&gt;The smallest thing that works in your Electron main process:&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="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;dialog&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;electron&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;autoUpdater&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;electron-updater&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;setupAutoUpdate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;mainWindow&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;autoUpdater&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;autoDownload&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="nx"&gt;autoUpdater&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;autoInstallOnAppQuit&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="nx"&gt;autoUpdater&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;update-available&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;info&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;dialog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;showMessageBox&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;mainWindow&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;info&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Update available&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Version &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;info&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;version&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; is ready.`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;buttons&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="s1"&gt;Update now&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Later&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;then&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;response&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="nx"&gt;autoUpdater&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;downloadUpdate&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;autoUpdater&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;update-downloaded&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;dialog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;showMessageBox&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;mainWindow&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Update ready&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Restart to apply the update.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;buttons&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="s1"&gt;Restart&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;On next launch&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;then&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;response&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="nx"&gt;autoUpdater&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;quitAndInstall&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;autoUpdater&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;checkForUpdates&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&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="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;mainWindow&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createWindow&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nf"&gt;setupAutoUpdate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;mainWindow&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;&lt;code&gt;autoDownload = false&lt;/code&gt; is deliberate. Users hate background traffic and disk usage they didn't consent to. Asking first ("Update available, want it now?") meaningfully improves the install rate vs. silently downloading and then asking to restart.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 1: obfuscating your network module silently breaks production
&lt;/h2&gt;

&lt;p&gt;This one cost me about three hours of confused debugging.&lt;/p&gt;

&lt;p&gt;I was using JavaScript obfuscation to make reverse-engineering the desktop client harder. The obfuscator was configured to process most of &lt;code&gt;src/&lt;/code&gt;, and I accidentally included &lt;code&gt;api.js&lt;/code&gt; — the file that handles HTTP communication with the backend.&lt;/p&gt;

&lt;p&gt;The symptom: &lt;code&gt;reportActivity&lt;/code&gt; would silently fail in production. The dashboard would show "0 likes today" while the real database had the correct counts. Local dev was perfect because obfuscation only runs on the production build.&lt;/p&gt;

&lt;p&gt;Meanwhile &lt;code&gt;heartbeat&lt;/code&gt; and &lt;code&gt;reportStats&lt;/code&gt; (which lived in the same &lt;code&gt;api.js&lt;/code&gt;) were sending fine. The obfuscator's name-mangling had hit one specific function path differently from the others.&lt;/p&gt;

&lt;p&gt;Fixes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Exclude network-layer files from obfuscation entirely.&lt;/strong&gt; The marginal anti-reverse-engineering benefit is not worth the debugging cost when something goes wrong.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Diff the production bundle against local for any file that handles auth, network, or persistence.&lt;/strong&gt; A 30-second sanity check that would have saved my three hours.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Watch your dashboards in real time for at least an hour after a release.&lt;/strong&gt; This trap was visible in the data within minutes — I just wasn't looking.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Trap 2: forgetting to upload &lt;code&gt;latest.yml&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;electron-updater&lt;/code&gt; needs &lt;code&gt;latest.yml&lt;/code&gt; in the GitHub Release to detect new versions. It is shockingly easy to forget to upload it (or to upload only the installer).&lt;/p&gt;

&lt;p&gt;If you forget, &lt;strong&gt;existing clients never detect the new release&lt;/strong&gt;. They keep running the old version forever, and you don't notice until a user reports a bug "fixed in v1.4" while clearly running v1.3.&lt;/p&gt;

&lt;p&gt;Mitigation: I now have a release script that, right before announcing internally, fetches the latest GitHub Release via the API and checks both &lt;code&gt;.exe&lt;/code&gt; and &lt;code&gt;latest.yml&lt;/code&gt; are present. If either is missing, it errors out before I post the announcement.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 3: Windows SmartScreen + no code-signing cert
&lt;/h2&gt;

&lt;p&gt;For Windows distribution without a code-signing certificate, your installer triggers "Windows protected your PC" SmartScreen warnings for the first few hundred downloads. A code-signing cert is about ¥30-50k/year (USD ~$200-350), and that's the official fix.&lt;/p&gt;

&lt;p&gt;For an indie launching with limited budget, I chose to skip the cert and instead:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Publish a clear installation guide page with screenshots showing the "More info" → "Run anyway" path&lt;/li&gt;
&lt;li&gt;Link to that guide from the download button, not just the README&lt;/li&gt;
&lt;li&gt;Accept that there will be a small drop-off of users who bounce on the warning&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Empirically, SmartScreen's reputation system also softens after enough downloads accumulate — so the early downloads where the warning is harshest are also when you have the least audience. The cost of acquiring the early users with the rougher experience is real but bounded.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 4: full-version-number changelog mistakes are public forever
&lt;/h2&gt;

&lt;p&gt;The flip side of "easy release flow" is that every release note ships permanently. Early on I wrote some release notes that referenced internal feature names that weren't worth disclosing, or had typos that aged poorly.&lt;/p&gt;

&lt;p&gt;Habit I've adopted: every release note is "for users, not for me." Internal version comments go in &lt;code&gt;CHANGELOG.md&lt;/code&gt; (private), user-visible notes go in the GitHub Release body, and the two are written separately. The discipline is mildly annoying but the permanence of public release notes is the kind of thing you don't notice until you wish you hadn't written something a year ago.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why auto-update is a "support-cost-saving investment", not a "feature"
&lt;/h2&gt;

&lt;p&gt;The thing that surprised me most: auto-update is not a feature your users will thank you for. They expect it. What it actually does is move you from the "what version are you on? please update" support pattern to "let me see the error" within a minute or two.&lt;/p&gt;

&lt;p&gt;For a solo developer, that's the difference between a bug report consuming 30 minutes of triage vs. 5 minutes of actual diagnosis. The implementation work (about a day) pays itself back inside the first month.&lt;/p&gt;

&lt;p&gt;If you're shipping Electron and don't have auto-update yet, the ROI is hard to beat. Wiring it in &lt;em&gt;before&lt;/em&gt; your first paying customer means you never have to triage version drift.&lt;/p&gt;




&lt;p&gt;What auto-update trap have you hit? Curious if other Electron devs have run into the obfuscation-name-mangling one — felt like an obscure failure mode at the time, but the post-mortem suggests it's probably more common than people talk about.&lt;/p&gt;

</description>
      <category>electron</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>indiedev</category>
    </item>
    <item>
      <title>Three Stripe subscription patterns I locked in before going live (with code)</title>
      <dc:creator>Sasaki Ryuji</dc:creator>
      <pubDate>Thu, 21 May 2026 16:21:11 +0000</pubDate>
      <link>https://forem.com/ryuji_saas/three-stripe-subscription-patterns-i-locked-in-before-going-live-with-code-2fgb</link>
      <guid>https://forem.com/ryuji_saas/three-stripe-subscription-patterns-i-locked-in-before-going-live-with-code-2fgb</guid>
      <description>&lt;p&gt;I've been building and running a small SaaS (GramShift, Instagram automation desktop app) on Stripe subscriptions for the past several months. Getting the basic checkout flow to work was easy — what took more careful design were three implementation patterns where the docs mention the risk but it's easy to skim past.&lt;/p&gt;

&lt;p&gt;Sharing the three I locked in during the build phase, with the code I actually use. I caught the first one during local testing, before it could hit a real customer, and that's the version of the story I want to share — because the prevention pattern is the part worth copying.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pitfall 1: Webhook idempotency — design for retries from day one
&lt;/h2&gt;

&lt;p&gt;Stripe webhooks are designed to be retried. If your endpoint is slow or returns 5xx, Stripe resends the same event with exponential backoff. The docs say this. My initial code looked like 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="nx"&gt;fastify&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api/stripe/webhook&lt;/span&gt;&lt;span class="dl"&gt;'&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;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;reply&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;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;webhooks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;constructEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rawBody&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;stripe-signature&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nx"&gt;WEBHOOK_SECRET&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;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;checkout.session.completed&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;session&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="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;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="s2"&gt;`UPDATE users SET plan = 'pro', expires_at = ? WHERE id = ?`&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="nx"&gt;expires_at&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="nx"&gt;client_reference_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;return&lt;/span&gt; &lt;span class="nx"&gt;reply&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;received&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;Looks fine. But while building the webhook flow, I ran the Stripe CLI to replay events into my local server and noticed the same &lt;code&gt;event.id&lt;/code&gt; being processed multiple times when I deliberately slowed the handler. If a real production handler hiccuped for any reason — slow DB, transient timeout — Stripe would retry, and this code would happily re-run the same update.&lt;/p&gt;

&lt;p&gt;For paid SaaS that's the kind of bug you absolutely do not want to discover by reading customer complaints. So I locked in an idempotency table before going live:&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;fastify&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api/stripe/webhook&lt;/span&gt;&lt;span class="dl"&gt;'&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;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;reply&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;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;webhooks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;constructEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rawBody&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;stripe-signature&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nx"&gt;WEBHOOK_SECRET&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;exists&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;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SELECT 1 FROM stripe_events WHERE event_id = ?&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;exists&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;reply&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;received&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;duplicate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;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;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;INSERT INTO stripe_events (event_id, type, created_at) VALUES (?, ?, ?)&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&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="p"&gt;);&lt;/span&gt;
  &lt;span class="c1"&gt;// ...actual processing...&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;reply&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;received&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;Cost: one extra table, one extra query per webhook. Worth it on the very first day, because the failure mode (silently re-running a billing-related update) is exactly the kind of bug your test mode might not catch on its own.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pitfall 2: 3D Secure (SCA) — your EU/Brazil customers will need it
&lt;/h2&gt;

&lt;p&gt;The standard &lt;code&gt;PaymentIntent&lt;/code&gt; + &lt;code&gt;stripe.confirmCardPayment()&lt;/code&gt; flow works perfectly with &lt;code&gt;4242 4242 4242 4242&lt;/code&gt;. What it doesn't show you is that SCA (Strong Customer Authentication) requires explicit handling of the &lt;code&gt;requires_action&lt;/code&gt; branch — and that branch is what real EU/Brazil/Australia customers hit on a meaningful share of transactions.&lt;/p&gt;

&lt;p&gt;I added the branch during implementation after reading the SCA docs more carefully:&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;result&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="nf"&gt;confirmCardPayment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;clientSecret&lt;/span&gt;&lt;span class="p"&gt;,&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="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;card&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;cardElement&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;billing_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;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;span class="k"&gt;if &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;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;showError&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;error&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="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;paymentIntent&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="s1"&gt;requires_action&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="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;handleCardAction&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;paymentIntent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;client_secret&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="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;showError&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="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="nf"&gt;showSuccess&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;paymentIntent&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="s1"&gt;succeeded&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;showSuccess&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;Test card: &lt;code&gt;4000 0025 0000 3155&lt;/code&gt; — this always triggers 3D Secure. If your flow completes with this card, you're covered for most international cases. It's a one-line test, but it's the difference between "works for everyone" and "silently broken for half of your EU traffic."&lt;/p&gt;

&lt;h2&gt;
  
  
  Pitfall 3: "Immediate cancel" vs "end-of-period cancel" — choose the right default
&lt;/h2&gt;

&lt;p&gt;The simplest cancel implementation is &lt;code&gt;subscription.delete&lt;/code&gt; — terminate now. It's one line of code, and it's almost always the wrong default.&lt;/p&gt;

&lt;p&gt;When I sketched the cancel UX, I quickly hit three predictable problems:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A customer who cancels mid-cycle naturally expects either prorated refund OR continued access until period end. Immediate termination satisfies neither.&lt;/li&gt;
&lt;li&gt;"Cancel" and "turn off auto-renew" are not the same intent. Treating them the same loses customer trust.&lt;/li&gt;
&lt;li&gt;Cancel button + change-of-mind flow is much easier to build if cancel is reversible by default.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So I defaulted to &lt;code&gt;cancel_at_period_end&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;cancelAtPeriodEnd&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;return&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;update&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="na"&gt;cancel_at_period_end&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;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;renderCancelButton&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="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;endDate&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="nx"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current_period_end&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="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLocaleDateString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;en-US&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="s2"&gt;`&amp;lt;button&amp;gt;Cancel at next renewal (&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;endDate&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;)&amp;lt;/button&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;If someone really wants immediate cancel + refund, that's a separate "contact support" flow — not a self-serve button. The default is reversible by design, which means the customer has time to change their mind (and a non-trivial share do).&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick checklist before you launch Stripe subscriptions
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Idempotency table on every webhook&lt;/strong&gt;, keyed by event ID — design it in before going live, not after&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Handle &lt;code&gt;requires_action&lt;/code&gt; for 3D Secure&lt;/strong&gt;, test with &lt;code&gt;4000 0025 0000 3155&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Default to end-of-period cancel&lt;/strong&gt;, immediate cancel via support&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Self-subscribe for a full cycle&lt;/strong&gt; on a test card before launch — sign up, get charged, change cards, cancel, resume&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;All three are mentioned in the Stripe docs, but they're easy to read past because the happy-path code works without them. Building each in as a default-on pattern saved me from having to fix them under customer-facing pressure later.&lt;/p&gt;




&lt;p&gt;What patterns did you wish you'd implemented from day one with Stripe subscriptions? Curious what others built in early.&lt;/p&gt;

</description>
      <category>stripe</category>
      <category>webdev</category>
      <category>javascript</category>
      <category>indiedev</category>
    </item>
  </channel>
</rss>
