<?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: Posting Dude</title>
    <description>The latest articles on DEV Community by Posting Dude (@posting-dude).</description>
    <link>https://dev.to/posting-dude</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%2F3883134%2F75bed475-b966-4e63-8921-41160c6a93f3.jpg</url>
      <title>DEV Community: Posting Dude</title>
      <link>https://dev.to/posting-dude</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/posting-dude"/>
    <language>en</language>
    <item>
      <title>Your Uptime Monitor Can't See a Dead Cron Job. Heartbeat the 5 Jobs a Tiny SaaS Runs.</title>
      <dc:creator>Posting Dude</dc:creator>
      <pubDate>Sun, 18 Oct 2026 06:24:23 +0000</pubDate>
      <link>https://dev.to/posting-dude/your-uptime-monitor-cant-see-a-dead-cron-job-heartbeat-the-5-jobs-a-tiny-saas-runs-2ajg</link>
      <guid>https://dev.to/posting-dude/your-uptime-monitor-cant-see-a-dead-cron-job-heartbeat-the-5-jobs-a-tiny-saas-runs-2ajg</guid>
      <description>&lt;p&gt;Most tiny SaaS apps have a handful of jobs that run on a timer and nobody watches. A nightly database backup. A job that sends trial-ending emails. A sync that pulls subscription state from Stripe so your own table doesn't drift. Something that cleans up expired sessions or old uploads. Maybe a weekly digest.&lt;/p&gt;

&lt;p&gt;Your uptime monitor checks that the homepage returns 200. It has no idea whether any of those ran last night. The site is up, the dashboard is green, and the backup job could have been failing since a dependency update nine days ago. You find out the day you need the backup.&lt;/p&gt;

&lt;p&gt;The fix is small and boring: a heartbeat for each job. The job pings a URL when it finishes, and if the ping doesn't arrive on time, you get an alert. It's sometimes called a dead man's switch. Here's how I'd set it up when you have under 100 users and one person on call (you).&lt;/p&gt;

&lt;p&gt;I'll use &lt;a href="https://healthchecks.io/docs/http_api/" rel="noopener noreferrer"&gt;Healthchecks.io's pinging API&lt;/a&gt; for the examples because the docs are clear and the free Hobbyist plan covers 20 jobs, which is more than a tiny app needs. Cronitor, Better Stack and Sentry Crons do the same basic thing. The pattern matters more than the tool.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Write down every job first
&lt;/h2&gt;

&lt;p&gt;Before any tooling, make a list. For each job, one line:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Job&lt;/th&gt;
&lt;th&gt;Schedule&lt;/th&gt;
&lt;th&gt;Where it runs&lt;/th&gt;
&lt;th&gt;What breaks if it stops&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;DB backup&lt;/td&gt;
&lt;td&gt;daily 02:00&lt;/td&gt;
&lt;td&gt;VPS crontab&lt;/td&gt;
&lt;td&gt;you can't restore&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Trial-ending emails&lt;/td&gt;
&lt;td&gt;hourly&lt;/td&gt;
&lt;td&gt;worker&lt;/td&gt;
&lt;td&gt;people get charged with no warning&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stripe reconcile&lt;/td&gt;
&lt;td&gt;daily 03:00&lt;/td&gt;
&lt;td&gt;worker&lt;/td&gt;
&lt;td&gt;access drifts from billing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Session cleanup&lt;/td&gt;
&lt;td&gt;daily&lt;/td&gt;
&lt;td&gt;Vercel cron&lt;/td&gt;
&lt;td&gt;table grows, slowly&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Weekly digest&lt;/td&gt;
&lt;td&gt;Mon 08:00&lt;/td&gt;
&lt;td&gt;GitHub Actions&lt;/td&gt;
&lt;td&gt;nobody notices for a while&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The last column decides how loud the alert should be. A missed backup or a missed billing job should reach your phone. Session cleanup can be an email you read tomorrow.&lt;/p&gt;

&lt;p&gt;Doing this list is usually where you find a job you forgot existed, or one that lives on a platform you stopped thinking about.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. One check per job, matched to the schedule
&lt;/h2&gt;

&lt;p&gt;Create one check per job. Don't share a check between two jobs, or one of them can die while the other keeps it green.&lt;/p&gt;

&lt;p&gt;For the schedule you have two options:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Period + grace.&lt;/strong&gt; "Expect a ping every 24 hours, wait 1 extra hour before alerting." Simple, and fine for most jobs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cron expression + timezone.&lt;/strong&gt; Paste the same expression as your crontab and set the server's timezone. Healthchecks.io then checks the job ran at the right time, not just at roughly the right interval. If your server isn't on UTC, the timezone field matters, because cron uses the machine's local time.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Grace time is the setting people get wrong. Too short and every slow night pages you. Too long and you hear about a failure half a day late. I'd start with roughly the job's normal run time plus a margin, then tighten it after a couple of weeks of real data.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Ping on success, and ping failure explicitly
&lt;/h2&gt;

&lt;p&gt;The basic version is one line at the end of the job:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-fsS&lt;/span&gt; &lt;span class="nt"&gt;-m&lt;/span&gt; 10 &lt;span class="nt"&gt;--retry&lt;/span&gt; 5 &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null https://hc-ping.com/your-uuid-here
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;-m 10&lt;/code&gt; stops the ping from hanging your job, &lt;code&gt;--retry 5&lt;/code&gt; retries on timeouts and transient server errors, and &lt;code&gt;-fsS&lt;/code&gt; keeps it quiet unless something goes wrong.&lt;/p&gt;

&lt;p&gt;That alone catches the job not running at all. To find out sooner when it runs and fails, send the exit code. Healthchecks.io treats &lt;code&gt;/0&lt;/code&gt; as success and any other number as a failure:&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="c"&gt;#!/bin/bash&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; pipefail
pg_dump &lt;span class="nt"&gt;-Fc&lt;/span&gt; mydb | &lt;span class="nb"&gt;gzip&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /backups/mydb-&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%F&lt;span class="si"&gt;)&lt;/span&gt;.dump.gz
curl &lt;span class="nt"&gt;-fsS&lt;/span&gt; &lt;span class="nt"&gt;-m&lt;/span&gt; 10 &lt;span class="nt"&gt;--retry&lt;/span&gt; 5 &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null https://hc-ping.com/your-uuid-here/&lt;span class="nv"&gt;$?&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;set -o pipefail&lt;/code&gt; line is the one that bites. Without it, a pipeline's exit code is the last command's. So if &lt;code&gt;pg_dump&lt;/code&gt; fails but &lt;code&gt;gzip&lt;/code&gt; happily compresses nothing, the script reports 0 and your heartbeat says the backup worked. That's an empty file with a green check next to it.&lt;/p&gt;

&lt;p&gt;In app code it's the same idea:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;

&lt;span class="n"&gt;URL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://hc-ping.com/your-uuid-here&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;ok&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;
&lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;ok&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;run_trial_reminders&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;  &lt;span class="c1"&gt;# return True only if it actually did the work
&lt;/span&gt;&lt;span class="k"&gt;finally&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;requests&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="n"&gt;URL&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;ok&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="n"&gt;URL&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/fail&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  4. Add /start to catch hung jobs
&lt;/h2&gt;

&lt;p&gt;A job that hangs is worse than one that crashes, because it never reaches the line that reports failure. Ping &lt;code&gt;/start&lt;/code&gt; when the job begins:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-fsS&lt;/span&gt; &lt;span class="nt"&gt;-m&lt;/span&gt; 10 &lt;span class="nt"&gt;--retry&lt;/span&gt; 5 &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null https://hc-ping.com/your-uuid-here/start
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If a start arrives and no success follows within the grace time, the check goes down and you get alerted. You also get run durations for free, which is how you'd notice a backup that used to take 4 minutes now takes 40.&lt;/p&gt;

&lt;p&gt;If the same job can overlap with itself, pass a run ID (&lt;code&gt;?rid=&amp;lt;uuid&amp;gt;&lt;/code&gt;) on both pings so durations don't get mixed up.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. "It ran" is not "it worked"
&lt;/h2&gt;

&lt;p&gt;The quiet failure is a job that runs on time, exits 0 and does nothing. The reconcile job that fetched zero subscriptions because the API key got rotated. The email job that found zero trials ending because a query filter broke.&lt;/p&gt;

&lt;p&gt;Two cheap ways to catch that:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Send a number in the ping body.&lt;/strong&gt; Healthchecks.io stores the first 100 kB of a POST body, so post something like &lt;code&gt;checked=37 updated=2&lt;/code&gt;. When something looks off, the history is right there.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fail on "suspiciously zero".&lt;/strong&gt; If the reconcile job normally touches 30+ customers and today it saw 0, ping &lt;code&gt;/fail&lt;/code&gt; instead of success. You'll get the odd false alarm on a quiet day. That's cheaper than a week of drift.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For backups, the heartbeat proves the dump ran. It doesn't prove you can restore it. I wrote up a &lt;a href="https://dev.to/posting-dude/your-backups-dont-exist-until-youve-restored-one-a-monthly-drill-for-a-solo-saas-54ip"&gt;monthly restore drill for a solo SaaS&lt;/a&gt; for that part.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Platform crons have their own catches
&lt;/h2&gt;

&lt;p&gt;If your jobs don't live in a crontab, read the small print. A few I'd check:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Vercel cron jobs&lt;/strong&gt; are best effort. Vercel's docs say it won't retry a failed invocation, and occasionally a request may not reach your function at all, with no runtime log for that run. On the Hobby plan, crons are limited to once a day and a &lt;code&gt;0 1 * * *&lt;/code&gt; job can fire anywhere between 1:00 and 1:59. Vercel also says a long job can overlap with the next run, and recommends locks plus idempotent jobs so a duplicate or missed run is safe.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GitHub Actions schedules&lt;/strong&gt; can be delayed at busy times, especially the top of the hour, and under heavy load some queued runs can be dropped. In a public repo, scheduled workflows are disabled automatically after 60 days with no repository activity. A weekly digest living in a quiet repo will simply stop one day.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your own VPS crontab&lt;/strong&gt; only "alerts" by emailing job output, which needs &lt;code&gt;MAILTO&lt;/code&gt; and a working mail setup. On a lot of servers that was never configured, so the output just goes nowhere.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A heartbeat covers all three the same way: if the ping doesn't show up, you hear about it. For Vercel and GitHub Actions, schedule a few minutes off the hour and set the grace time to cover the platform's timing window.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Send alerts where you'll actually see them
&lt;/h2&gt;

&lt;p&gt;Pick two levels and stop there:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Wake me up:&lt;/strong&gt; backup, billing-related jobs, anything where a missed run costs money or data. Phone push or a chat app with notifications on.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tell me today:&lt;/strong&gt; cleanup, digests, nice-to-haves. Email is fine.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then test it. Pause a job or point its ping at &lt;code&gt;/fail&lt;/code&gt; once, and confirm the alert actually reaches you. An alert channel you've never seen fire is the same as no alert channel.&lt;/p&gt;

&lt;p&gt;This fits into a bigger question of what to watch on a small app when you can't stare at dashboards all day. The SideHunt guide on &lt;a href="https://sidehunt.io/studio/how-to-monitor-a-side-project-during-your-day-job?utm_source=devto&amp;amp;utm_medium=crosslink&amp;amp;utm_campaign=cron-heartbeat-tiny-saas" rel="noopener noreferrer"&gt;monitoring a side project during your day job&lt;/a&gt; covers uptime checks, error alerts and alert tiers around it, so I won't repeat it here.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 20-minute version
&lt;/h2&gt;

&lt;p&gt;If you only do the minimum today:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;List your scheduled jobs and where each one runs.&lt;/li&gt;
&lt;li&gt;Create one check per job, with a grace time that matches how long it normally takes.&lt;/li&gt;
&lt;li&gt;Add the exit-code ping at the end, with &lt;code&gt;set -o pipefail&lt;/code&gt; in shell scripts.&lt;/li&gt;
&lt;li&gt;Force one failure and make sure the alert reaches your phone.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That's it. Don't be surprised if the first alert is for a job you'd forgotten about.&lt;/p&gt;

&lt;p&gt;How many scheduled jobs does your app run right now, and do you know for sure that each one ran last night?&lt;/p&gt;

</description>
      <category>saas</category>
      <category>indiehackers</category>
      <category>devops</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Before You Post a Launch-Day Promo Code in Stripe, Set These 5 Limits</title>
      <dc:creator>Posting Dude</dc:creator>
      <pubDate>Sat, 17 Oct 2026 06:25:06 +0000</pubDate>
      <link>https://dev.to/posting-dude/before-you-post-a-launch-day-promo-code-in-stripe-set-these-5-limits-29if</link>
      <guid>https://dev.to/posting-dude/before-you-post-a-launch-day-promo-code-in-stripe-set-these-5-limits-29if</guid>
      <description>&lt;p&gt;Launch day is when most tiny SaaS founders make their first public discount code. You open Stripe, make a coupon, type &lt;code&gt;LAUNCH50&lt;/code&gt;, paste it into the launch post and the first comment, and go back to replying to people.&lt;/p&gt;

&lt;p&gt;That works. It also leaves a code on the internet that you haven't really decided anything about. How long does the 50% last? Who can use it? When does it stop? What happens if it ends up on a coupon site next month?&lt;/p&gt;

&lt;p&gt;When you have under 100 customers, every one of those answers shows up in your MRR for a long time. So here's the checklist I'd run before posting the code. It's based on Stripe's coupon and promotion code docs, not on any special trick. Not legal or tax advice.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Coupon vs promotion code: post the code, not the coupon
&lt;/h2&gt;

&lt;p&gt;Stripe has two objects and it's easy to mix them up.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;strong&gt;coupon&lt;/strong&gt; is the discount logic: percent or amount off, how long it lasts, which products. You (your backend, or you in the Dashboard) apply it to a subscription. The customer never types anything.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;promotion code&lt;/strong&gt; is the customer-facing string, like &lt;code&gt;LAUNCH50&lt;/code&gt;, that points at a coupon. Many codes can point at the same coupon.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For a public launch you want a promotion code. That's the one with the controls that matter here: first-time customers only, a minimum spend, an expiry date, a redemption cap, and you can switch it off without deleting anything.&lt;/p&gt;

&lt;p&gt;The part people skip: since many codes can point at one coupon, make &lt;strong&gt;one code per place you post it&lt;/strong&gt;. Same 30% off coupon underneath, but &lt;code&gt;PH30&lt;/code&gt; for one launch platform, &lt;code&gt;IH30&lt;/code&gt; for another, &lt;code&gt;NEWS30&lt;/code&gt; for your own email list. Each promotion code has its own redemption count, so a week later you can see which audience actually paid, not just which one clicked. With 20 launch-week signups that's often the only clean attribution you'll get.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Pick the duration on purpose
&lt;/h2&gt;

&lt;p&gt;The coupon's duration is the setting with the longest tail. There are three values:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Duration&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;th&gt;Use it when&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;once&lt;/code&gt; (default)&lt;/td&gt;
&lt;td&gt;Applies to the first invoice only&lt;/td&gt;
&lt;td&gt;You want a launch "first month cheaper"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;repeating&lt;/code&gt; + &lt;code&gt;duration_in_months&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Applies to every invoice in that window&lt;/td&gt;
&lt;td&gt;You want "3 months at 30% off"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;forever&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Every invoice, indefinitely&lt;/td&gt;
&lt;td&gt;Almost never for a public launch code&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two traps worth knowing:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Forever plus an expiry date doesn't mean what it sounds like.&lt;/strong&gt; The expiry only stops &lt;em&gt;new&lt;/em&gt; people redeeming it. Anyone who used it before the date keeps the discount forever, per Stripe's docs. So a "launch week only, 50% off forever" code is really "everyone from launch week pays half until they cancel". At 15 customers that might be fine. At 60 it's a permanent hole in your revenue that you set up in 30 seconds. If that's actually what you want, it's basically a lifetime deal and deserves the same thinking (I like this breakdown of &lt;a href="https://indiehunt.io/studio/how-to-run-a-lifetime-deal-without-wrecking-your-indie-saas?utm_source=devto&amp;amp;utm_medium=crosslink&amp;amp;utm_campaign=launch-promo-code-limits" rel="noopener noreferrer"&gt;what a lifetime deal costs a small SaaS later&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Repeating coupons and annual plans.&lt;/strong&gt; Stripe applies a repeating coupon to every invoice in the window. On a yearly plan, if the subscription renews within that window, the discount covers the whole year. So "3 months at 50% off" on an annual plan can turn into a year at 50% off. If you offer both monthly and yearly, either restrict the coupon to the monthly product or make a separate one for yearly.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Set the four limits before you post
&lt;/h2&gt;

&lt;p&gt;On the promotion code (not just the coupon), set these:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Expiry date (&lt;code&gt;expires_at&lt;/code&gt;).&lt;/strong&gt; Pick a real end, like the end of launch week in your time zone. If the coupon itself has a &lt;code&gt;redeem_by&lt;/code&gt;, the code can't expire later than that.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Max redemptions.&lt;/strong&gt; A cap you'd be happy with even if every redemption came from strangers. The coupon's cap is shared across all codes pointing at it, and a code's cap can't be bigger than the coupon's. So if you make &lt;code&gt;PH30&lt;/code&gt; and &lt;code&gt;IH30&lt;/code&gt; from one coupon capped at 50, that's 50 total, not 50 each.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;First-time order only (&lt;code&gt;first_time_transaction&lt;/code&gt;).&lt;/strong&gt; Stops your existing customers from switching to a cheaper code. But read the next section before you tick it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Minimum amount (optional).&lt;/strong&gt; Only checked on the first payment of a subscription, so it's not a way to protect later invoices.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Once a promotion code hits its cap or its expiry date, it's permanently inactive and can't be switched back on. That's what you want. If you need more, make a new code.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. The first-time-only trap if you ran a trial
&lt;/h2&gt;

&lt;p&gt;This one is easy to miss for launch day specifically.&lt;/p&gt;

&lt;p&gt;Stripe's first-time-only rule blocks anyone with prior transaction history. That includes someone who started a PaymentIntent that never completed, and someone who &lt;strong&gt;started a trial&lt;/strong&gt;, even if it was canceled.&lt;/p&gt;

&lt;p&gt;Now picture a normal pre-launch: you've had a free trial open for a month, 30 people tried it, and on launch day you email them "here's 30% off if you upgrade this week". If the code is first-time only, a lot of those people will hit an error at checkout. They're exactly the warm people most likely to pay.&lt;/p&gt;

&lt;p&gt;So decide which audience each code is for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Code for strangers on launch platforms: first-time only is fine.&lt;/li&gt;
&lt;li&gt;Code for your trial users or waitlist: leave first-time off, and use a lower cap or a customer-specific code instead. Promotion codes can be restricted to one specific customer, which is the cleanest option when the list is small enough to do by hand.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  5. Make the code actually usable, then check where it shows up
&lt;/h2&gt;

&lt;p&gt;A few things that cause launch-day support emails:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Checkout needs the box turned on.&lt;/strong&gt; If you use Stripe Checkout, the promo field only appears when the Checkout Session is created with &lt;code&gt;allow_promotion_codes&lt;/code&gt; enabled. Easy to forget when you've never used a discount before. Test it with the actual code in test mode.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Codes are case-insensitive&lt;/strong&gt; and must be unique among active codes, so &lt;code&gt;launch50&lt;/code&gt; and &lt;code&gt;LAUNCH50&lt;/code&gt; are the same thing. You can't have two active public codes with the same string.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The Customer Portal can show and accept codes.&lt;/strong&gt; If you turned on promotion codes in the portal, customers can apply them when they change plans, and the portal displays the codes already applied to their subscription. If you'd rather a code not get passed around, set limits on it or apply a coupon directly instead. I wrote up the portal settings I'd start with in &lt;a href="https://dev.to/posting-dude/turn-on-stripe-customer-portal-before-you-hit-50-customers-gg6"&gt;turning on Stripe Customer Portal before 50 customers&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A &lt;code&gt;once&lt;/code&gt; coupon disappears from the subscription after it's used.&lt;/strong&gt; After the invoice that used it finalizes, the subscription's &lt;code&gt;discounts&lt;/code&gt; no longer includes it. The invoice still shows it. So if your app reads "does this subscription have a discount?" to show a badge or decide something, it'll say no after month one. Read the invoice if you need history.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  When launch week is over
&lt;/h2&gt;

&lt;p&gt;Don't delete the coupon to "end" the promo. Deleting a coupon doesn't remove the discount from subscriptions that already have it; it just stops new redemptions (and archives its codes). Usually the expiry date has already done the job. If a code leaks somewhere you didn't post it, archive just that promotion code by setting it to inactive. Your other codes keep working.&lt;/p&gt;

&lt;p&gt;Then look at the per-code redemption counts. That's the useful launch-day data: which audience actually paid with a discount. Whether they'd have paid without one is a separate question, and with this few customers you can just email them and ask. If you're still in the reply-to-everyone phase of the launch, this playbook on &lt;a href="https://auraplusplus.com/studio/how-to-turn-launch-day-comments-into-paying-users?utm_source=devto&amp;amp;utm_medium=crosslink&amp;amp;utm_campaign=launch-promo-code-limits" rel="noopener noreferrer"&gt;turning launch-day comments into paying users&lt;/a&gt; pairs well with the per-code numbers.&lt;/p&gt;

&lt;p&gt;All of the settings above are in Stripe's &lt;a href="https://docs.stripe.com/billing/subscriptions/coupons" rel="noopener noreferrer"&gt;coupons and promotion codes docs&lt;/a&gt; if you want the API names next to the Dashboard ones.&lt;/p&gt;

&lt;p&gt;If you've posted a launch code before: did you make one code for everywhere, or one per platform? And did anyone end up with a "forever" discount you didn't mean to give?&lt;/p&gt;

</description>
      <category>saas</category>
      <category>indiehackers</category>
      <category>stripe</category>
      <category>startup</category>
    </item>
    <item>
      <title>Turn On Stripe Customer Portal Before You Hit 50 Customers</title>
      <dc:creator>Posting Dude</dc:creator>
      <pubDate>Fri, 16 Oct 2026 06:20:27 +0000</pubDate>
      <link>https://dev.to/posting-dude/turn-on-stripe-customer-portal-before-you-hit-50-customers-gg6</link>
      <guid>https://dev.to/posting-dude/turn-on-stripe-customer-portal-before-you-hit-50-customers-gg6</guid>
      <description>&lt;p&gt;When you have under 50 paying customers, "update my card", "can you send the invoice", and "I want to cancel" still land in your inbox. You open the Stripe Dashboard, find the customer, click around, paste a screenshot, apologize for the delay. It works. It's also a bad habit that gets worse every week you wait.&lt;/p&gt;

&lt;p&gt;Stripe's Customer Portal is the cheap offload for that size. Hosted UI. You configure which features are on. Customer does the billing chores themselves. You stop being the human middleware between their card and Stripe.&lt;/p&gt;

&lt;p&gt;This is the operational order I'd use &lt;em&gt;before&lt;/em&gt; you hit 50 — not because 50 is magic, just because after that the email volume makes the Dashboard click-path painful. Not legal advice. Grounded in Stripe's customer portal docs.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. What to turn on first (minimal config)
&lt;/h2&gt;

&lt;p&gt;Configure the portal in the Dashboard (or via portal Configurations in the API). Live mode and sandbox each have their own config — changing one does not change the other. Easy to forget when you tested everything in test mode and then wonder why live customers see different buttons.&lt;/p&gt;

&lt;p&gt;For a tiny SaaS under ~50 customers, turn these on first:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Feature&lt;/th&gt;
&lt;th&gt;Start with&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Payment method update&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;On&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Stops "my card expired" emails&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Invoice history (view / pay / download)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;On&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Stops "send me last month's invoice" emails&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cancel&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;On&lt;/strong&gt;, at &lt;strong&gt;period end&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;Stops you doing cancels by hand; keeps access until paid-through&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Update billing info / tax IDs&lt;/td&gt;
&lt;td&gt;On if you charge tax or need company details&lt;/td&gt;
&lt;td&gt;Optional early&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Subscription update (plan change / quantity)&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Off&lt;/strong&gt; until you have a real catalog + proration story&lt;/td&gt;
&lt;td&gt;Plan upgrades without prices is a trap&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Leave plan upgrades off until you've set a product catalog the portal can actually show. Stripe requires that catalog if customers can upgrade, downgrade, or change quantities. Under 50 people, you can still change plans for them in the Dashboard when someone asks. Self-serve upgrades without clear prices just creates weird mid-cycle invoices you'll have to explain.&lt;/p&gt;

&lt;p&gt;Cancel at period end is the default I'd pick at this size. Immediate cancel is fine for some products; just don't leave it on by accident because you clicked through the settings once.&lt;/p&gt;

&lt;p&gt;Preview from the Dashboard: open a sandbox customer → Actions → Open customer portal. Read-only Preview exists while you tweak settings. Use it before you put a button in production.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. One "Manage billing" button (not a permanent URL)
&lt;/h2&gt;

&lt;p&gt;The integration is small:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Customer is logged into &lt;strong&gt;your&lt;/strong&gt; app (authenticate first — Stripe is clear on this).&lt;/li&gt;
&lt;li&gt;Your backend creates a Billing Portal Session with their Stripe &lt;code&gt;customer&lt;/code&gt; ID and a &lt;code&gt;return_url&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Redirect them to &lt;code&gt;session.url&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Something like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;POST /create-portal-session
→ stripe.billingPortal.sessions.create({
    customer: cus_xxx,
    return_url: "https://yourapp.com/account"
  })
→ redirect to session.url
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Sessions are short-lived. Stripe's docs: a new portal session expires after about &lt;strong&gt;5 minutes&lt;/strong&gt; if unused; if the customer uses it, it expires within about &lt;strong&gt;1 hour&lt;/strong&gt; of the most recent activity. So don't paste a permanent portal URL into email and hope it works next week. Create a session when they click, then redirect.&lt;/p&gt;

&lt;p&gt;There's also a shareable &lt;code&gt;login_page&lt;/code&gt; link per configuration. Treat that as a special case, not your default. The normal path is: auth on your site → create session → redirect. Don't use the customer billing email as a login credential either — billing email updates in the portal are billing info, not account login.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Deep links for the three emails you already send
&lt;/h2&gt;

&lt;p&gt;Once the homepage portal works, deep-link the emails you're already writing. Set &lt;code&gt;flow_data[type]&lt;/code&gt; when you create the session. Navigational chrome for the rest of the portal is hidden so they land on one action.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Email you already send&lt;/th&gt;
&lt;th&gt;&lt;code&gt;flow_data[type]&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;Notes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Failed payment / expired card&lt;/td&gt;
&lt;td&gt;&lt;code&gt;payment_method_update&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Put this in dunning and "card expiring" mail&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"I want to cancel" / cancel confirmation&lt;/td&gt;
&lt;td&gt;&lt;code&gt;subscription_cancel&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Pass the subscription ID; pair with period-end cancel&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tax ID / address / company name&lt;/td&gt;
&lt;td&gt;&lt;code&gt;customer_update&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Added as a portal deep link in Stripe's 2026-08-26 changelog&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Also available when you need them later: &lt;code&gt;subscription_update&lt;/code&gt;, &lt;code&gt;subscription_update_confirm&lt;/code&gt;. Skip those until plan changes are actually enabled in the config.&lt;/p&gt;

&lt;p&gt;Top-level &lt;code&gt;return_url&lt;/code&gt; is the "back to your site" link they can use anytime (e.g. they decide not to cancel). &lt;code&gt;flow_data[after_completion]&lt;/code&gt; can redirect after they finish the action, show a hosted confirmation, or send them to the portal homepage. For cancel emails, a clear &lt;code&gt;after_completion&lt;/code&gt; redirect back to your account page is less confusing than dumping them on a generic Stripe screen.&lt;/p&gt;

&lt;p&gt;You don't need fancy copy. You need the link to open the right screen on the first click.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Webhooks so access matches what they did
&lt;/h2&gt;

&lt;p&gt;The portal changes Stripe objects. Your app's entitlements have to follow. From Stripe's integrate guide, the ones that matter for a tiny setup:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Event&lt;/th&gt;
&lt;th&gt;Why you care&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;customer.subscription.updated&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Plan change, quantity, &lt;strong&gt;or&lt;/strong&gt; cancel scheduled at period end (&lt;code&gt;cancel_at_period_end&lt;/code&gt; / &lt;code&gt;cancel_at&lt;/code&gt; depending on billing mode)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;customer.subscription.deleted&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Subscription actually ended — revoke access here if you cancel at period end&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;payment_method.attached&lt;/code&gt; / &lt;code&gt;detached&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Card changed; useful for support and for clearing "update card" banners&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;customer.updated&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Billing details / default payment method changed — &lt;strong&gt;don't&lt;/strong&gt; treat billing email as login&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The cancel trap: if the portal is set to cancel at period end, the first signal is usually &lt;code&gt;customer.subscription.updated&lt;/code&gt; with cancel-at-period-end set, &lt;strong&gt;not&lt;/strong&gt; &lt;code&gt;deleted&lt;/code&gt;. If you revoke access on that update, you cut them off while they still paid through the period. Keep access until &lt;code&gt;deleted&lt;/code&gt; (or until &lt;code&gt;current_period_end&lt;/code&gt; / &lt;code&gt;cancel_at&lt;/code&gt; actually passes — same idea). If they reactivate before the end, you'll get another &lt;code&gt;updated&lt;/code&gt; with cancel cleared — restore "not leaving" state, don't invent a new subscription.&lt;/p&gt;

&lt;p&gt;Same discipline as any other Stripe webhook: verify the signature on the raw body, store &lt;code&gt;event.id&lt;/code&gt; for idempotency, re-fetch the subscription if you're unsure about ordering.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Traps that show up under 50 customers
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Cancel left on "immediate" by accident.&lt;/strong&gt; Someone cancels on day 3 of a monthly plan and loses access the same afternoon. If that's intentional, fine. If you meant period end, fix the config before the next person finds it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Plan changes enabled with no (or wrong) catalog.&lt;/strong&gt; Empty upgrade UI, or prices you never meant to sell. Keep subscription update off until the catalog is real.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Shareable &lt;code&gt;login_page&lt;/code&gt; link as your only path.&lt;/strong&gt; Convenient for a quick support paste. Worse for security hygiene than auth → session. Don't make it the product default without thinking.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Treating portal cancel as "access already gone" when cancel-at-period-end is true.&lt;/strong&gt; See webhooks above. Your paywall and your email should agree: "you're paid through [date], then it ends."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Forgetting live-mode config is separate from test.&lt;/strong&gt; You polished sandbox. Live still has defaults. Flip View test data off, configure live, add live webhooks, then ship the button.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Emailing a stale session URL.&lt;/strong&gt; Five-minute unused expiry is short. Always create a fresh session from a button or a signed link on your domain.&lt;/p&gt;

&lt;p&gt;If cancel UX is the part you're nervous about (retention copy, confirmation, what happens to data), that's a product design problem next to the portal config — &lt;a href="https://auraplusplus.com/studio/how-to-design-a-saas-cancel-flow-without-dark-patterns?utm_source=devto&amp;amp;utm_medium=crosslink&amp;amp;utm_campaign=customer-portal-under-50" rel="noopener noreferrer"&gt;how to design a cancel flow without dark patterns&lt;/a&gt; is the frame I'd use so the portal cancel screen and your in-app copy don't fight each other.&lt;/p&gt;

&lt;p&gt;Portal also shows up in other small-scale ops — especially trial-ending mail that needs a card update before the first charge. If you're still wiring that flow, &lt;a href="https://dev.to/posting-dude/when-a-free-trial-ends-and-you-have-under-40-active-trials-34c7"&gt;when a free trial ends under ~40 active trials&lt;/a&gt; is the sibling checklist. Source of truth for the portal itself: &lt;a href="https://docs.stripe.com/customer-management" rel="noopener noreferrer"&gt;Stripe's customer portal overview&lt;/a&gt; and the &lt;a href="https://docs.stripe.com/customer-management/integrate-customer-portal" rel="noopener noreferrer"&gt;API integrate guide&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;If you're under 50 customers: what do you leave &lt;strong&gt;on&lt;/strong&gt; vs &lt;strong&gt;off&lt;/strong&gt; in the Customer Portal right now — and did you set cancel to period end or immediate on purpose?&lt;/p&gt;

</description>
      <category>saas</category>
      <category>indiehackers</category>
      <category>stripe</category>
      <category>webdev</category>
    </item>
    <item>
      <title>why launch days feel empty: makers couldn't see visitors/clicks, so they didn't bring their audience. shipped live maker stats on aura++
https://auraplusplus.com/projects/kai/live?utm_source=devto&amp;utm_medium=social&amp;utm_campaign=bip-aura-maker-live-stats</title>
      <dc:creator>Posting Dude</dc:creator>
      <pubDate>Thu, 15 Oct 2026 06:30:36 +0000</pubDate>
      <link>https://dev.to/posting-dude/why-launch-days-feel-empty-makers-couldnt-see-visitorsclicks-so-they-didnt-bring-their-2gci</link>
      <guid>https://dev.to/posting-dude/why-launch-days-feel-empty-makers-couldnt-see-visitorsclicks-so-they-didnt-bring-their-2gci</guid>
      <description>&lt;div class="crayons-card c-embed text-styles text-styles--secondary"&gt;
    &lt;div class="c-embed__content"&gt;
        &lt;div class="c-embed__cover"&gt;
          &lt;a href="https://auraplusplus.com/projects/kai/live?utm_source=devto&amp;amp;amp%3Butm_medium=social&amp;amp;amp%3Butm_campaign=bip-aura-maker-live-stats" class="c-link align-middle" rel="noopener noreferrer"&gt;
            &lt;img alt="" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Ftxmhk1zrnc.ufs.sh%2Ff%2FxSkWTCqmKWx9FNwHTcorJ71RgK2nIYBGt5o9PNZLOi48xpMQ" height="256" class="m-0" width="256"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="c-embed__body"&gt;
        &lt;h2 class="fs-xl lh-tight"&gt;
          &lt;a href="https://auraplusplus.com/projects/kai/live?utm_source=devto&amp;amp;amp%3Butm_medium=social&amp;amp;amp%3Butm_campaign=bip-aura-maker-live-stats" rel="noopener noreferrer" class="c-link"&gt;
            
          &lt;/a&gt;
        &lt;/h2&gt;
          &lt;p class="truncate-at-3"&gt;
            Recorded launch-day visitors, upvotes, outbound clicks, and comments for Kai on Aura++.
          &lt;/p&gt;
        &lt;div class="color-secondary fs-s flex items-center"&gt;
            &lt;img alt="favicon" class="c-embed__favicon m-0 mr-2 radius-0" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fauraplusplus.com%2Ffavicon.ico" width="256" height="256"&gt;
          auraplusplus.com
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
&lt;/div&gt;


</description>
    </item>
    <item>
      <title>When a Free Trial Ends and You Have Under 40 Active Trials</title>
      <dc:creator>Posting Dude</dc:creator>
      <pubDate>Thu, 15 Oct 2026 06:29:44 +0000</pubDate>
      <link>https://dev.to/posting-dude/when-a-free-trial-ends-and-you-have-under-40-active-trials-34c7</link>
      <guid>https://dev.to/posting-dude/when-a-free-trial-ends-and-you-have-under-40-active-trials-34c7</guid>
      <description>&lt;p&gt;When you have under 40 active free trials, the hard part isn't starting them. Stripe Checkout will do that with a couple of fields. The hard part is the last three days, when a trial is about to become a charge, or nothing, or a paused subscription you forgot about.&lt;/p&gt;

&lt;p&gt;Most tiny SaaS setups stop at &lt;code&gt;trial_period_days=14&lt;/code&gt;. They never decide what access looks like on the morning the trial ends, what email goes out, or what Stripe should do if there's no card on file. Then the first trial ends on a Tuesday and you're inventing policy in the Dashboard.&lt;/p&gt;

&lt;p&gt;This is the operational side for that size. Not legal advice, just the order I'd set things in before the next trial hits day 11.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Know what Stripe actually fires (and when)
&lt;/h2&gt;

&lt;p&gt;For the classic free trial (&lt;code&gt;trial_end&lt;/code&gt; / &lt;code&gt;trial_period_days&lt;/code&gt;), Stripe sends &lt;code&gt;customer.subscription.trial_will_end&lt;/code&gt; &lt;strong&gt;three days before the trial ends&lt;/strong&gt;. If the trial is shorter than three days, it fires basically immediately. That's the default on the Dashboard "Trial ending events" setting — you can change the lead time, but three days is what most accounts get.&lt;/p&gt;

&lt;p&gt;Two things people mix up:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;trial_will_end&lt;/code&gt; is a reminder, not the end.&lt;/strong&gt; The subscription is still &lt;code&gt;trialing&lt;/code&gt; when this lands. Nothing has charged yet.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stripe's own hosted reminder email&lt;/strong&gt; (the compliance one in Billing settings) is a different clock. Their docs put it at &lt;strong&gt;seven days&lt;/strong&gt; before the trial ends. So you can get Stripe's email on day 7 of a 14-day trial, and your webhook on day 11. Don't assume they're the same day.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When the trial actually ends and the sub isn't paused, Stripe creates an invoice and attempts to charge it roughly an hour later. You'll also see &lt;code&gt;customer.subscription.updated&lt;/code&gt; as status moves from &lt;code&gt;trialing&lt;/code&gt; toward &lt;code&gt;active&lt;/code&gt; (or into trouble).&lt;/p&gt;

&lt;p&gt;If you already keep an entitlements table off Stripe status alone, treat &lt;code&gt;trial_will_end&lt;/code&gt; as "check the customer has a usable payment method, then send &lt;em&gt;your&lt;/em&gt; email." Don't wait for the charge to fail and then scramble.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Card-upfront vs no card: pick one, then set &lt;code&gt;missing_payment_method&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Two common shapes under 40 trials:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A) Card at signup.&lt;/strong&gt; Checkout collects a payment method, trial starts at $0, day N they get charged. Simpler ops. Higher signup friction. Abuse is lower.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;B) No card during trial.&lt;/strong&gt; Faster signups, more junk accounts, and you &lt;em&gt;must&lt;/em&gt; tell Stripe what to do when the trial ends with nothing to charge. That's &lt;code&gt;trial_settings.end_behavior.missing_payment_method&lt;/code&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;th&gt;What Stripe does at trial end&lt;/th&gt;
&lt;th&gt;Webhook you care about&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;create_invoice&lt;/code&gt; (default)&lt;/td&gt;
&lt;td&gt;Creates an invoice; if it can't be paid, sub can go &lt;code&gt;past_due&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;invoice.created&lt;/code&gt; / &lt;code&gt;invoice.payment_failed&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;cancel&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Cancels the subscription&lt;/td&gt;
&lt;td&gt;&lt;code&gt;customer.subscription.deleted&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;pause&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Pauses; no more invoices until you resume&lt;/td&gt;
&lt;td&gt;&lt;code&gt;customer.subscription.paused&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Stripe checks &lt;code&gt;default_payment_method&lt;/code&gt; and &lt;code&gt;default_source&lt;/code&gt; on both the subscription and the customer. If either has a usable method, it's not "missing."&lt;/p&gt;

&lt;p&gt;For a tiny product with a real cost to serve (AI calls, storage, support), &lt;code&gt;cancel&lt;/code&gt; is usually cleaner than leaving paused zombies. &lt;code&gt;pause&lt;/code&gt; is useful if you want a soft "come back and add a card" path via the Customer Portal without creating a new subscription. &lt;code&gt;create_invoice&lt;/code&gt; without a card is how you accidentally farm &lt;code&gt;past_due&lt;/code&gt; rows.&lt;/p&gt;

&lt;p&gt;Don't leave this unset and assume cancel. The default is invoice, not cancel.&lt;/p&gt;

&lt;p&gt;(Stripe also has a newer "Trial Offers" API for flexible billing. Most indie Checkout setups still use the legacy &lt;code&gt;trial_period_days&lt;/code&gt; path above. Same webhook names either way for the reminder.)&lt;/p&gt;

&lt;h2&gt;
  
  
  3. The emails: three messages, not a drip
&lt;/h2&gt;

&lt;p&gt;You don't need a seven-email sequence at this size. You need three decisions written down:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Mid-trial (optional, day 3–5 of a 14-day trial).&lt;/strong&gt; Only if they haven't hit your activation event. One short "here's the one thing to try" note. Skip it if they already did the thing. Calendar drips that ignore usage just train people to ignore you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. The &lt;code&gt;trial_will_end&lt;/code&gt; email (your own, ~3 days out).&lt;/strong&gt; This is the one that matters. Subject should say the date and the amount, not "Your trial is ending soon!!"&lt;/p&gt;

&lt;p&gt;Something like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight email"&gt;&lt;code&gt;&lt;span class="nt"&gt;Subject&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="na"&gt; Your trial ends Thursday. $29/mo starts then&lt;/span&gt;

Hey [name],

Your trial of [product] ends on [date]. If you keep the plan,
we'll charge $[amount] on that day to the card on file.

If you want to stop, cancel here before then: [portal or cancel link]

If the card isn't current, update it here: [portal / update link]

[your name]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Rules: no feature laundry list, no fake urgency, one cancel path that actually works. If there's no card on file (shape B), swap the middle for "add a payment method to keep access" and link the Customer Portal. Stripe's free-trial docs literally walk that portal flow off &lt;code&gt;trial_will_end&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Day-of / day-after.&lt;/strong&gt;  &lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Charged successfully → short receipt + "you're on the paid plan" (or rely on Stripe's receipt if you've tested it lands).
&lt;/li&gt;
&lt;li&gt;No card + cancel → "trial ended, account is closed, here's how to start again if you want."
&lt;/li&gt;
&lt;li&gt;No card + pause → "paused, add a card to resume" with the portal link.
&lt;/li&gt;
&lt;li&gt;Payment failed → that's not a trial email anymore; treat it like a failed renewal (same tone as a failed-payment recovery mail, not a marketing nudge).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Under 40 trials you can send these from Resend/Loops/Postmark with a spreadsheet of trial end dates if webhooks feel scary. Webhooks are better once you're past a handful a week — but a missed webhook during a deploy is how someone gets charged with no warning. If you use webhooks, verify signatures and don't trust event order; re-fetch the subscription.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Access on the day the trial ends
&lt;/h2&gt;

&lt;p&gt;Write this before the first trial ends. Same idea as a refund policy: inventing it while someone is logging in goes badly.&lt;/p&gt;

&lt;p&gt;Rough matrix that works at small scale:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Situation&lt;/th&gt;
&lt;th&gt;Access I'd give&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Still &lt;code&gt;trialing&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Full product (or whatever the trial promised)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;trial_will_end&lt;/code&gt; fired, card on file&lt;/td&gt;
&lt;td&gt;Full access; show a calm banner "paid plan starts [date]"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Trial ended, &lt;code&gt;active&lt;/code&gt;, invoice paid&lt;/td&gt;
&lt;td&gt;Full paid access&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Trial ended, payment failed / &lt;code&gt;past_due&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Keep access for a short grace (e.g. 3–7 days) with a banner, then read-only or lock — match whatever you do for normal failed renewals&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Trial ended, &lt;code&gt;canceled&lt;/code&gt; (no card + cancel)&lt;/td&gt;
&lt;td&gt;No access. Export/download if you promised data out.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Trial ended, &lt;code&gt;paused&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;No (or read-only) access until they resume with a card&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The mistake is checking only &lt;code&gt;status === 'active'&lt;/code&gt;. A trial that just ended can sit in &lt;code&gt;paused&lt;/code&gt;, &lt;code&gt;canceled&lt;/code&gt;, &lt;code&gt;past_due&lt;/code&gt;, or briefly look weird while the first invoice is open. If you've read &lt;a href="https://dev.to/posting-dude/stripe-has-8-subscription-statuses-your-paywall-probably-checks-one-aj1"&gt;how Stripe's eight subscription statuses actually map to access&lt;/a&gt;, you already know &lt;code&gt;active&lt;/code&gt; alone lies in both directions.&lt;/p&gt;

&lt;p&gt;Also decide: &lt;strong&gt;do trial users keep data after cancel?&lt;/strong&gt; If yes for 30 days, say so in the day-of email. If you wipe on cancel, say that too. Silence here creates support tickets that feel like betrayal even when you did nothing sneaky.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. A boring checklist before you open trials
&lt;/h2&gt;

&lt;p&gt;Copy this into a note only you see:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Trials (internal)
- Length: __ days
- Card at signup: yes / no
- missing_payment_method: cancel / pause / create_invoice
- trial_will_end email: on (own template) / Stripe-hosted only
- Access on cancel: lock now / read-only __ days
- Access on pause: lock / read-only
- Grace on failed first charge: __ days
- Webhook events: trial_will_end, updated, deleted, paused, resumed,
  invoice.paid, invoice.payment_failed
- Test with a test clock: happy path, no card, failed card
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run the test clock once. The "no card + cancel" path and the "card declines on first charge" path are the ones that surprise people in week three.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. One product decision this is not
&lt;/h2&gt;

&lt;p&gt;This whole post assumes you already picked a free trial (not freemium, not both). If you're still deciding the access model, that's a different problem. &lt;a href="https://auraplusplus.com/studio/how-to-choose-free-trial-vs-freemium-before-pmf?utm_source=devto&amp;amp;utm_medium=crosslink&amp;amp;utm_campaign=free-trial-end-under-40" rel="noopener noreferrer"&gt;choosing free trial vs freemium before PMF&lt;/a&gt; is the decision frame I'd use first. Ending mechanics won't save a trial you never should have offered.&lt;/p&gt;

&lt;p&gt;For the Stripe field names and the no-card cancel/pause setup, the source of truth is &lt;a href="https://docs.stripe.com/billing/subscriptions/trials/free-trials" rel="noopener noreferrer"&gt;Stripe's free trial docs&lt;/a&gt;. The event timing table lives in their &lt;a href="https://docs.stripe.com/billing/subscriptions/webhooks" rel="noopener noreferrer"&gt;subscription webhooks guide&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;If you're running trials right now: card at signup or not, and on the day it ends do you lock access immediately or leave a grace window? What broke the first time a trial actually ended?&lt;/p&gt;

</description>
      <category>saas</category>
      <category>indiehackers</category>
      <category>stripe</category>
      <category>email</category>
    </item>
    <item>
      <title>When Someone Asks for a Refund and You Have Under 30 Paying Customers</title>
      <dc:creator>Posting Dude</dc:creator>
      <pubDate>Wed, 14 Oct 2026 06:23:10 +0000</pubDate>
      <link>https://dev.to/posting-dude/when-someone-asks-for-a-refund-and-you-have-under-30-paying-customers-1ifl</link>
      <guid>https://dev.to/posting-dude/when-someone-asks-for-a-refund-and-you-have-under-30-paying-customers-1ifl</guid>
      <description>&lt;p&gt;When you have under 30 paying customers, refund requests are rare. Maybe one every month or two. But each one is loud. It's one person out of a small group telling you something didn't work, it's real money leaving a pretty small balance, and it's a question about access you probably never answered on purpose.&lt;/p&gt;

&lt;p&gt;Most founders treat it as a Dashboard click. The click is the easy part. The decisions around it are where small SaaS gets messy, and they're much easier to make before the first email shows up than while you're reading it.&lt;/p&gt;

&lt;p&gt;This is the order I'd go in. Not legal advice, just the operational side.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Decide what happens to access before anyone asks
&lt;/h2&gt;

&lt;p&gt;When a refund email comes in, the first question isn't "how much". It's "do they keep using the product after this?"&lt;/p&gt;

&lt;p&gt;There's basically three options for a small subscription product:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Refund + cancel now.&lt;/strong&gt; Money back, access ends today. Cleanest. Good for "I signed up by mistake" or "this isn't what I thought it was".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Refund + keep access until period end.&lt;/strong&gt; Money back, but they can still log in until the date they already paid for. Feels generous, costs you nothing extra, but you're giving away a month of usage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Partial refund + keep the plan.&lt;/strong&gt; For "the feature I needed was broken for a week" type cases. You refund part of the charge and they stay a customer.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these is the right one. The mistake is picking a different one every time depending on your mood or how nice the email was. Two customers who asked for the same thing should get the same answer.&lt;/p&gt;

&lt;p&gt;So write it down. Literally three or four lines in a doc only you read:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Refund policy (internal)
- First charge, asked within 14 days: full refund, cancel now
- Renewal they forgot about, no usage since renewal: full refund, cancel now
- Product broken for them for a real stretch: partial refund, keep plan
- Anything else: reply first, decide after
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The 14 days is just an example, use your own number. The point is you're not inventing policy at 11pm with an annoyed customer waiting.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Reply once before you refund (unless it's obvious)
&lt;/h2&gt;

&lt;p&gt;If the reason is obvious, like a duplicate charge or they forgot to cancel and haven't logged in since, just refund it. Don't make people explain themselves for that.&lt;/p&gt;

&lt;p&gt;If it's not obvious, send one short reply first. Not to argue or talk them out of it. Under 30 customers, the reason they want out is worth more to you than the money in most cases, and it's the only time they'll tell you.&lt;/p&gt;

&lt;p&gt;Something like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Hey [name],

Sure, I can sort that out. Before I do, can I ask what
didn't work for you? Even one line helps, I'm a small team
and I read every one of these.

If it was something broken, tell me and I'll check
whether it's still happening.

[your name]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Rules for this email: no discount offer in the first reply, no "are you sure", no list of features they "might have missed". Just a question. If they don't answer in a couple of days, refund anyway and move on. Holding the refund hostage for feedback is how a refund request turns into something worse.&lt;/p&gt;

&lt;p&gt;And that's actually the other reason to be quick and polite here. A customer who feels ignored can go to their bank instead of you. If you've read &lt;a href="https://dev.to/posting-dude/under-100-payments-a-month-one-stripe-dispute-is-already-a-1-dispute-rate-5ge4"&gt;why one dispute already looks like a 1% rate when you're small&lt;/a&gt;, you know a refund is almost always the cheaper outcome.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. The Stripe mechanics, as a checklist
&lt;/h2&gt;

&lt;p&gt;Here's what's worth knowing before you click, mostly from &lt;a href="https://docs.stripe.com/refunds" rel="noopener noreferrer"&gt;Stripe's refund docs&lt;/a&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Full or partial.&lt;/strong&gt; The Dashboard defaults to a full refund, you can type a smaller amount for partial. You can do more than one partial refund on the same charge, but the total can't go over the original amount. The API takes an &lt;code&gt;amount&lt;/code&gt; in the smallest currency unit (cents for USD).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pick a reason.&lt;/strong&gt; The Dashboard asks for one. If you choose "Other" you have to write a note, which is honestly useful later.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fees don't come back.&lt;/strong&gt; Stripe's processing fees from the original charge aren't returned when you refund. So a refund costs you a little more than the refund amount. Not a reason to refuse, just know it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Refunds come out of your available balance.&lt;/strong&gt; Not pending money. If you just got paid out and your available balance is low, Stripe holds card refunds as pending until there's enough. With a tiny customer base that can actually happen.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Original payment method only.&lt;/strong&gt; You can't send it to a different card or bank account. If their card expired, the issuer usually routes it to the replacement card.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Timing.&lt;/strong&gt; Stripe says the customer sees the credit roughly 5â€“10 business days later depending on their bank. If you refund very soon after the charge, it can show as a reversal instead: the original charge just disappears from their statement and no separate credit shows up. Worth telling the customer, because "I don't see a refund" is the most common follow-up.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Email to the customer.&lt;/strong&gt; There's a Dashboard setting to email customers about refunds. It only sends if the charge was on a Customer in your account and that Customer has an email. Turn it on or send your own, but don't send nothing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't refund into an open dispute.&lt;/strong&gt; If they already disputed the charge with their bank, go through the dispute instead. Stripe's docs say if a customer disputes while a refund is pending, the refund can fail with &lt;code&gt;charge_for_pending_refund_disputed&lt;/code&gt;, and they recommend accepting or challenging the dispute rather than refunding, so you don't pay them back twice. For bank debit methods (SEPA, ACH, Bacs etc.) they flag the double refund risk directly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Refunds can fail.&lt;/strong&gt; Closed account, card problem, lost or stolen card. Stripe sends &lt;code&gt;refund.failed&lt;/code&gt;, and the money comes back to your balance (can take up to 30 days). Then you need some other way to pay them back. If you already have webhooks, add a listener for it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The reference number.&lt;/strong&gt; If someone says the money never arrived, open the payment, go to the Timeline, View Details on the refund. Where available Stripe shows an ARN (or STAN/RRN). Give that to the customer, their bank can trace it. It can take up to 7 business days to show up, and there isn't one for reversals.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  4. After the refund, close the loop
&lt;/h2&gt;

&lt;p&gt;The refund going through isn't the end. Three small things:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do the access change you decided in step 1.&lt;/strong&gt; If it's "cancel now", cancel the subscription now so there's no renewal next month. A refund on a charge doesn't cancel the subscription by itself. If it's "keep until period end", set it to cancel at period end and check your app actually respects that date. Refunding someone and then charging them again 30 days later is the fastest way to get a dispute.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Send a short confirmation.&lt;/strong&gt; Amount, that it goes back to the card they paid with, the 5â€“10 business day window, and that a quick refund might show as the charge just disappearing. One paragraph.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Log it.&lt;/strong&gt; One row in a sheet. Stripe has the payment data, but it doesn't know your access decision or what the customer actually said.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csvs"&gt;&lt;code&gt;&lt;span class="k"&gt;date&lt;/span&gt;       &lt;span class="err"&gt;|&lt;/span&gt; &lt;span class="k"&gt;customer&lt;/span&gt;        &lt;span class="err"&gt;|&lt;/span&gt; &lt;span class="k"&gt;amount&lt;/span&gt; &lt;span class="err"&gt;|&lt;/span&gt; &lt;span class="k"&gt;full&lt;/span&gt;&lt;span class="err"&gt;/&lt;/span&gt;&lt;span class="k"&gt;partial&lt;/span&gt; &lt;span class="err"&gt;|&lt;/span&gt; &lt;span class="k"&gt;reason&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;their&lt;/span&gt; &lt;span class="k"&gt;words&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;          &lt;span class="err"&gt;|&lt;/span&gt; &lt;span class="k"&gt;access&lt;/span&gt;        &lt;span class="err"&gt;|&lt;/span&gt; &lt;span class="k"&gt;notes&lt;/span&gt;
&lt;span class="ld"&gt;2026-10-14&lt;/span&gt; &lt;span class="err"&gt;|&lt;/span&gt; &lt;span class="k"&gt;jane&lt;/span&gt;&lt;span class="kp"&gt;@acme&lt;/span&gt;&lt;span class="err"&gt;.&lt;/span&gt;&lt;span class="k"&gt;test&lt;/span&gt;  &lt;span class="err"&gt;|&lt;/span&gt; &lt;span class="nv"&gt;$29&lt;/span&gt;    &lt;span class="err"&gt;|&lt;/span&gt; &lt;span class="k"&gt;full&lt;/span&gt;         &lt;span class="err"&gt;|&lt;/span&gt; &lt;span class="s2"&gt;"needed CSV export, no CSV"&lt;/span&gt;   &lt;span class="err"&gt;|&lt;/span&gt; &lt;span class="k"&gt;cancel&lt;/span&gt; &lt;span class="k"&gt;now&lt;/span&gt;    &lt;span class="err"&gt;|&lt;/span&gt; &lt;span class="mf"&gt;2&lt;/span&gt;&lt;span class="k"&gt;nd&lt;/span&gt; &lt;span class="k"&gt;ask&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;CSV&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That "reason in their words" column is the one that matters. At 30 customers you won't see patterns in Stripe charts, but you'll see them in eight rows of a sheet. If three of those rows say some version of the same missing thing, that's your roadmap telling you something.&lt;/p&gt;

&lt;p&gt;Also worth separating these from failed payments. A refund is a customer choosing to leave and telling you. A failed card is someone leaving without choosing to, and that needs a different setup, like &lt;a href="https://dev.to/posting-dude/failed-payment-emails-when-you-have-under-50-customers-2cdk"&gt;failed payment emails for involuntary churn&lt;/a&gt;. Don't mix both into one "churn" number or you won't know which problem you have.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Make the exit not hurt
&lt;/h2&gt;

&lt;p&gt;A lot of refund emails happen because cancelling was confusing or someone didn't realise they'd renew. Hide the cancel button and people email you for a refund instead, or go to their bank. If you want to fix the part before the refund request, this guide on &lt;a href="https://auraplusplus.com/studio/how-to-design-a-saas-cancel-flow-without-dark-patterns?utm_source=devto&amp;amp;utm_medium=crosslink&amp;amp;utm_campaign=refunds-under-30-customers" rel="noopener noreferrer"&gt;designing a SaaS cancel flow without dark patterns&lt;/a&gt; covers how to make leaving easy while still asking the one question you'd want answered.&lt;/p&gt;

&lt;p&gt;For people who've already issued a few refunds on a small product: did you keep access until period end, or cut it off the same day? And did either choice ever come back to bite you?&lt;/p&gt;

</description>
      <category>saas</category>
      <category>indiehackers</category>
      <category>stripe</category>
      <category>payments</category>
    </item>
    <item>
      <title>Shipped a free AI Visibility Score on Aura++. Ran EarlyHunt first: 53/100, F. Crawlers and on-page both 20/20. AI recall 0/40.
https://praneetbrar7.medium.com/my-launch-site-scored-0-40-on-ai-recall-its-seo-checks-were-all-green-546dc57d32f5</title>
      <dc:creator>Posting Dude</dc:creator>
      <pubDate>Tue, 13 Oct 2026 06:39:09 +0000</pubDate>
      <link>https://dev.to/posting-dude/shipped-a-free-ai-visibility-score-on-aura-ran-earlyhunt-first-53100-f-crawlers-and-on-page-350g</link>
      <guid>https://dev.to/posting-dude/shipped-a-free-ai-visibility-score-on-aura-ran-earlyhunt-first-53100-f-crawlers-and-on-page-350g</guid>
      <description>&lt;div class="crayons-card c-embed text-styles text-styles--secondary"&gt;
    &lt;div class="c-embed__content"&gt;
      &lt;div class="c-embed__body flex items-center justify-between"&gt;
        &lt;a href="https://praneetbrar7.medium.com/my-launch-site-scored-0-40-on-ai-recall-its-seo-checks-were-all-green-546dc57d32f5" rel="noopener noreferrer" class="c-link fw-bold flex items-center"&gt;
          &lt;span class="mr-2"&gt;praneetbrar7.medium.com&lt;/span&gt;
          

        &lt;/a&gt;
      &lt;/div&gt;
    &lt;/div&gt;
&lt;/div&gt;


</description>
    </item>
    <item>
      <title>Under 100 Payments a Month, One Stripe Dispute Is Already a 1% Dispute Rate</title>
      <dc:creator>Posting Dude</dc:creator>
      <pubDate>Tue, 13 Oct 2026 06:38:21 +0000</pubDate>
      <link>https://dev.to/posting-dude/under-100-payments-a-month-one-stripe-dispute-is-already-a-1-dispute-rate-5ge4</link>
      <guid>https://dev.to/posting-dude/under-100-payments-a-month-one-stripe-dispute-is-already-a-1-dispute-rate-5ge4</guid>
      <description>&lt;p&gt;Here's some math nobody puts in the Stripe tutorial. If your SaaS does 100 payments a month and one customer disputes a charge, your dispute rate for that month is 1%. Stripe's own docs say the card industry treats dispute activity above 0.75% as excessive.&lt;/p&gt;

&lt;p&gt;One dispute. That's all it takes when you're small. To be fair, the card networks judge this over months, not one bad week, so a single dispute isn't getting you fined. But your numbers look bad very fast and there's almost no room.&lt;/p&gt;

&lt;p&gt;Stripe even calls this out in its fraud best practices: a business with fewer than 100 payments a month is one where "one or two fraud disputes can have a very outsized impact" on the dispute rate. So the playbook for a tiny SaaS is a bit different from the one you read in big-company posts. Here's what I'd set up, and what each dispute actually costs you.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a dispute costs (US numbers, from Stripe's pricing page)
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Dispute received fee: $15.&lt;/strong&gt; Charged the moment the dispute opens. On standard pricing, outside Mexico, you don't get it back, even if you win.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dispute countered fee: $15.&lt;/strong&gt; Only if you respond with evidence manually. You get this one back if you win.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Smart Disputes:&lt;/strong&gt; Stripe's AI builds the evidence for you and charges 30% of the disputed amount, only on disputes it wins. The $15 received fee still applies.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So take a $29/month customer:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What you do&lt;/th&gt;
&lt;th&gt;What you're out&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Refund before any dispute exists&lt;/td&gt;
&lt;td&gt;$29 (plus the original processing fee, which refunds don't return)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Accept the dispute&lt;/td&gt;
&lt;td&gt;$29 + $15 = $44&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fight and lose&lt;/td&gt;
&lt;td&gt;$29 + $15 + $15 = $59&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fight and win&lt;/td&gt;
&lt;td&gt;$15&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Fees vary by country (Stripe's India page lists ₹1,000 for each), so check yours. But the shape is the same everywhere: once the dispute exists, even winning costs money, and it counts toward your dispute rate whether you win or lose.&lt;/p&gt;

&lt;p&gt;That changes the goal. At 100 payments a month the win isn't "fight every dispute well". It's "have fewer of them exist at all".&lt;/p&gt;

&lt;h2&gt;
  
  
  Prevention first: the three dispute reasons a small SaaS actually sees
&lt;/h2&gt;

&lt;p&gt;Stripe has a list of dispute reasons. For a subscription product, the ones I'd worry about are these three.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. &lt;code&gt;unrecognized&lt;/code&gt;: they don't know what the charge is.&lt;/strong&gt;&lt;br&gt;
This one is so often avoidable. If your Stripe statement descriptor still says your legal company name or some old project name, people look at their card statement, see a charge they don't recognize, and call the bank. Stripe's guidance is to use a name or URL customers would associate with the purchase.&lt;/p&gt;

&lt;p&gt;The rules: 5 to 22 characters, Latin characters only, at least one letter. If you use a shortened prefix for card charges it's 2 to 10 characters. For subscriptions you can't set a suffix on the PaymentIntent because Stripe creates those charges, so set &lt;code&gt;statement_descriptor&lt;/code&gt; on the Product (or on the Invoice) instead. Also turn on receipts so there's an email to search for when they see the charge.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. &lt;code&gt;subscription_canceled&lt;/code&gt;: "I canceled and you kept charging me".&lt;/strong&gt;&lt;br&gt;
Usually this is someone who thought they canceled, couldn't find the button, or forgot the renewal was coming. Three cheap fixes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Make cancel easy to find. A dark-pattern cancel flow is basically a dispute generator. There's a longer piece on &lt;a href="https://auraplusplus.com/studio/how-to-design-a-saas-cancel-flow-without-dark-patterns?utm_source=devto&amp;amp;utm_medium=crosslink&amp;amp;utm_campaign=stripe-disputes-under-100-payments" rel="noopener noreferrer"&gt;how to design a cancel flow without dark patterns&lt;/a&gt; on Aura++, and almost all of it applies here.&lt;/li&gt;
&lt;li&gt;Send a confirmation email when they cancel, and actually pass the cancel through to Stripe.&lt;/li&gt;
&lt;li&gt;Turn on renewal reminders. In Billing settings there's "Send emails about upcoming renewals". It only works for subscriptions with &lt;code&gt;collection_method: charge_automatically&lt;/code&gt;, which is most small SaaS setups anyway. Annual plans are where this matters most, nobody remembers a charge from 12 months ago.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;3. &lt;code&gt;fraudulent&lt;/code&gt;: a stolen card.&lt;/strong&gt;&lt;br&gt;
This is mostly card testing and junk signups, which I covered in &lt;a href="https://dev.to/posting-dude/fake-signups-and-card-testing-a-small-defense-stack-for-a-tiny-saas-g21"&gt;a small defense stack for fake signups and card testing&lt;/a&gt;. The part that matters for disputes is the next section.&lt;/p&gt;
&lt;h2&gt;
  
  
  Early fraud warnings: refund faster than you think
&lt;/h2&gt;

&lt;p&gt;Before a fraud dispute shows up, you'll often get an early fraud warning (EFW). Visa, Mastercard and JCB issuers send these when they suspect a payment was fraud. Stripe sends a &lt;code&gt;radar.early_fraud_warning.created&lt;/code&gt; event for it.&lt;/p&gt;

&lt;p&gt;Stripe's numbers: if you don't refund, about 40% of Visa and Mastercard EFWs become fraud disputes. Their general advice is to refund EFWs on charges roughly at or under your dispute fee, and that it's usually not worth it on charges more than 35% above the fee.&lt;/p&gt;

&lt;p&gt;But the same docs list exceptions where refunding more aggressively makes sense, and "new or small business (say, fewer than 100 payments per month)" is one of them. A $29 charge is about twice the $15 fee, so by the general rule you'd maybe wait. At small volume I wouldn't. Refund it and save yourself the dispute.&lt;/p&gt;

&lt;p&gt;Two details from the docs worth knowing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Refunding doesn't erase the EFW. Visa counts EFWs in its VAMP monitoring program anyway. What the refund does is stop the same payment from also becoming a dispute, and a transaction that shows up as both is counted twice.&lt;/li&gt;
&lt;li&gt;Refund the full amount. A fully refunded payment can't be disputed. A partially refunded one can still be disputed for the full amount.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In the Dashboard use "Refund as fraud", which also tells Stripe it was fraud.&lt;/p&gt;
&lt;h2&gt;
  
  
  The webhook gotcha: a dispute doesn't tell you which subscription
&lt;/h2&gt;

&lt;p&gt;When a dispute comes in you get &lt;code&gt;charge.dispute.created&lt;/code&gt;. You probably want to do something in your app: pause access, flag the account, at least email them. The catch is that the Dispute object has &lt;code&gt;charge&lt;/code&gt; and a nullable &lt;code&gt;payment_intent&lt;/code&gt;, but no invoice or subscription.&lt;/p&gt;

&lt;p&gt;On API version &lt;code&gt;2025-03-31.basil&lt;/code&gt; or later, the Charge doesn't have an &lt;code&gt;invoice&lt;/code&gt; field anymore either. Stripe's documented path is to look the PaymentIntent up in the invoice payments list:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-G&lt;/span&gt; https://api.stripe.com/v1/invoice_payments &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-u&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$STRIPE_SECRET_KEY&lt;/span&gt;&lt;span class="s2"&gt;:"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"payment[type]=payment_intent"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"payment[payment_intent]=pi_123"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nv"&gt;status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;paid
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each result has an &lt;code&gt;invoice&lt;/code&gt;. Retrieve it, check that &lt;code&gt;parent.type&lt;/code&gt; is &lt;code&gt;subscription_details&lt;/code&gt;, and &lt;code&gt;parent.subscription_details.subscription&lt;/code&gt; is your subscription id. If &lt;code&gt;payment_intent&lt;/code&gt; was null on the dispute, retrieve the charge and use its &lt;code&gt;payment_intent&lt;/code&gt;. The same lookup works for EFWs and refunds.&lt;/p&gt;

&lt;p&gt;The events I'd subscribe to for this: &lt;code&gt;charge.dispute.created&lt;/code&gt;, &lt;code&gt;charge.dispute.closed&lt;/code&gt; (won or lost) and &lt;code&gt;radar.early_fraud_warning.created&lt;/code&gt;. Skip them and your app doesn't know about a dispute or a fraud warning until someone opens the Dashboard.&lt;/p&gt;

&lt;h2&gt;
  
  
  When one arrives anyway
&lt;/h2&gt;

&lt;p&gt;You usually get 7 to 21 days to respond depending on the card network, and if you miss the deadline you lose automatically. A few rules from the respond-to-disputes docs that catch people out:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;You get one submission.&lt;/strong&gt; You can't edit it or add files after. Gather everything first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Banks don't click links.&lt;/strong&gt; No "see our terms at...". Screenshot or PDF everything. Max 4.5 MB combined, and Mastercard caps evidence at 19 pages.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You can't refund a disputed charge&lt;/strong&gt; while the dispute is open, even if you and the customer agree. Talking to them still helps, and if they withdraw you still have to submit a response.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Amex and Discover sometimes send an inquiry first.&lt;/strong&gt; Answer it. Resolving at the inquiry stage means no dispute fee, and ignoring one can turn into a chargeback you can't win.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For a digital subscription, the evidence that matters is stuff your app already has, if you log it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Your cancellation policy as shown to the customer, and how and when you showed it (a screenshot of checkout works)&lt;/li&gt;
&lt;li&gt;Login or usage logs after the charge, with timestamps and IP addresses. Stripe lists this as &lt;code&gt;access_activity_log&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;The renewal reminder or receipt email you sent&lt;/li&gt;
&lt;li&gt;Any support emails with them&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you don't record something like a &lt;code&gt;last_seen_at&lt;/code&gt; or a simple usage log today, this is a good reason to start. "They logged in 14 times after the renewal" is the kind of thing a bank can actually check against the claim.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test it before it's real
&lt;/h2&gt;

&lt;p&gt;Stripe has test cards for all of this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;4000000000000259&lt;/code&gt;: the charge succeeds, then gets disputed as fraudulent&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;4000000000005423&lt;/code&gt;: the charge succeeds, then gets an early fraud warning&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;4000000000001976&lt;/code&gt;: the charge succeeds, then gets an inquiry&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then respond with &lt;code&gt;winning_evidence&lt;/code&gt; or &lt;code&gt;losing_evidence&lt;/code&gt; as the &lt;code&gt;uncategorized_text&lt;/code&gt; evidence (it's the "Additional information" field in the Dashboard) to simulate the outcome. Run each one once and check your webhook handler finds the right subscription, your app does what you wanted with access, and you'd actually notice the email.&lt;/p&gt;

&lt;p&gt;If you've already set up the paywall side, this fits next to &lt;a href="https://dev.to/posting-dude/stripe-has-8-subscription-statuses-your-paywall-probably-checks-one-aj1"&gt;what each of Stripe's 8 subscription statuses should mean for access&lt;/a&gt;, since a lost dispute is one more way a customer ends up not paying.&lt;/p&gt;




&lt;p&gt;If you're getting a small SaaS ready to put in front of people, I run &lt;a href="https://auraplusplus.com/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=stripe-disputes-under-100-payments" rel="noopener noreferrer"&gt;Aura++, a launch platform for indie products&lt;/a&gt;, and early-stage products are exactly who it's for.&lt;/p&gt;

&lt;p&gt;Question for anyone billing on Stripe: when you get an early fraud warning on a small charge, do you refund right away or wait to see if it turns into a dispute? And what's your cutoff amount?&lt;/p&gt;

</description>
      <category>saas</category>
      <category>indiehackers</category>
      <category>stripe</category>
      <category>payments</category>
    </item>
    <item>
      <title>Stripe Has 8 Subscription Statuses. Your Paywall Probably Checks One.</title>
      <dc:creator>Posting Dude</dc:creator>
      <pubDate>Mon, 12 Oct 2026 06:35:24 +0000</pubDate>
      <link>https://dev.to/posting-dude/stripe-has-8-subscription-statuses-your-paywall-probably-checks-one-aj1</link>
      <guid>https://dev.to/posting-dude/stripe-has-8-subscription-statuses-your-paywall-probably-checks-one-aj1</guid>
      <description>&lt;p&gt;Most tiny SaaS paywalls I've seen boil down to one line:&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="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="nf"&gt;showTheApp&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It works on day one. It keeps working right up until a card fails, or someone signs up with a card that needs 3D Secure, or a trial ends with no card on file. Then that one line either locks out a person who is about to pay you, or keeps paid features on for someone who stopped paying weeks ago.&lt;/p&gt;

&lt;p&gt;A reader on my Stripe webhook post raised exactly this in the comments, and it deserved more than a reply. So here is the boring part nobody puts in the tutorial: what each status actually means and what access I'd give for it when you have under 100 customers.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 8 statuses, in plain words
&lt;/h2&gt;

&lt;p&gt;From Stripe's own docs on how subscriptions work:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;trialing&lt;/code&gt;&lt;/strong&gt;: in a trial. Safe to give access.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;active&lt;/code&gt;&lt;/strong&gt;: in good standing. Note: &lt;code&gt;active&lt;/code&gt; doesn't promise every old invoice is paid, only the ones that govern status under your settings.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;incomplete&lt;/code&gt;&lt;/strong&gt;: the first payment failed, or needs action (3D Secure), or is still processing. The customer has &lt;strong&gt;23 hours&lt;/strong&gt; to pay.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;incomplete_expired&lt;/code&gt;&lt;/strong&gt;: those 23 hours passed without a payment. It never bills. To come back they need a new subscription.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;past_due&lt;/code&gt;&lt;/strong&gt;: payment on the latest invoice failed or wasn't attempted. Stripe &lt;em&gt;may&lt;/em&gt; retry while it sits here, but it isn't guaranteed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;unpaid&lt;/code&gt;&lt;/strong&gt;: only happens if you picked it in your failed-payment settings. Retries are done, invoices keep getting created but aren't charged.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;canceled&lt;/code&gt;&lt;/strong&gt;: terminal. Can't be updated. Coming back means a new subscription.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;paused&lt;/code&gt;&lt;/strong&gt;: a trial ended with no payment method and you set the trial to pause instead of cancel.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The part that bites people: what happens &lt;em&gt;after&lt;/em&gt; the last retry is your choice in the Dashboard. The sub can go to &lt;code&gt;canceled&lt;/code&gt;, to &lt;code&gt;unpaid&lt;/code&gt;, or just stay &lt;code&gt;past_due&lt;/code&gt;. If you never opened that settings page, go look now, because your paywall logic depends on it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd actually do with each one
&lt;/h2&gt;

&lt;p&gt;Under 100 customers, I wouldn't build anything clever. One function, one small table:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Status&lt;/th&gt;
&lt;th&gt;Access&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;trialing&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;full&lt;/td&gt;
&lt;td&gt;they're trying it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;active&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;full&lt;/td&gt;
&lt;td&gt;paid&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;past_due&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;full, with a banner, for a grace window (say 7 days)&lt;/td&gt;
&lt;td&gt;most failed renewals are an expired card, not someone leaving&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;incomplete&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;none yet, show a "finish payment" screen&lt;/td&gt;
&lt;td&gt;they never paid the first invoice&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;incomplete_expired&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;none, send them back to checkout&lt;/td&gt;
&lt;td&gt;it's dead, a new sub is needed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;unpaid&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;read-only (can log in, export data, can't use paid stuff)&lt;/td&gt;
&lt;td&gt;retries are over, but don't hold their data hostage&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;paused&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;read-only + "add a card to continue"&lt;/td&gt;
&lt;td&gt;trial ran out without a card&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;canceled&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;free plan or read-only until period end, if you allow that&lt;/td&gt;
&lt;td&gt;they chose to leave&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The grace window on &lt;code&gt;past_due&lt;/code&gt; is the one I'd fight for. Picture a customer at $29/month whose card expired over the weekend. If you hard-lock them on Monday morning, you've turned a card problem into a "this app is broken" problem, and some of them won't come back to fix the card. A banner that says "your last payment didn't go through, update your card" and still lets them work is kinder and it recovers more money.&lt;/p&gt;

&lt;p&gt;That banner is only half of it. The other half is the emails you send while the retries run. I wrote up &lt;a href="https://dev.to/posting-dude/failed-payment-emails-when-you-have-under-50-customers-2cdk"&gt;the three short emails I'd send when a payment fails&lt;/a&gt; with under 50 customers, and there's a longer &lt;a href="https://auraplusplus.com/studio/how-to-recover-failed-saas-payments-before-churn?utm_source=devto&amp;amp;utm_medium=crosslink&amp;amp;utm_campaign=stripe-subscription-statuses" rel="noopener noreferrer"&gt;playbook for recovering failed SaaS payments before they turn into churn&lt;/a&gt; on Aura++ if you want the full flow.&lt;/p&gt;

&lt;h2&gt;
  
  
  Store access, not just status
&lt;/h2&gt;

&lt;p&gt;The mistake I'd avoid is calling Stripe on every page load to check status. Slow, and it breaks when Stripe has a bad minute. Instead keep your own row:&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;create&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="n"&gt;entitlements&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;user_id&lt;/span&gt;        &lt;span class="nb"&gt;bigint&lt;/span&gt; &lt;span class="k"&gt;primary&lt;/span&gt; &lt;span class="k"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;stripe_sub_id&lt;/span&gt;  &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;stripe_status&lt;/span&gt;  &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;access&lt;/span&gt;         &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;      &lt;span class="c1"&gt;-- 'full' | 'read_only' | 'none'&lt;/span&gt;
  &lt;span class="n"&gt;grace_until&lt;/span&gt;    &lt;span class="n"&gt;timestamptz&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;updated_at&lt;/span&gt;     &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="n"&gt;now&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;Your app reads &lt;code&gt;access&lt;/code&gt;. Only your webhook handler (and a nightly check) writes it. The mapping from &lt;code&gt;stripe_status&lt;/code&gt; to &lt;code&gt;access&lt;/code&gt; lives in one function, so when you change your mind about &lt;code&gt;unpaid&lt;/code&gt; you change one place.&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;accessFor&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="nx"&gt;graceUntil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;now&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="k"&gt;switch &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="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;trialing&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;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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;full&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;past_due&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="nx"&gt;graceUntil&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;graceUntil&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;full&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;read_only&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;unpaid&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;paused&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;read_only&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;default&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="c1"&gt;// incomplete, incomplete_expired, canceled&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;none&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Set &lt;code&gt;grace_until&lt;/code&gt; the first time you see &lt;code&gt;past_due&lt;/code&gt; for that sub, and clear it when it goes back to &lt;code&gt;active&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The webhook events that matter
&lt;/h2&gt;

&lt;p&gt;You don't need to handle 40 event types. For this you need roughly:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;customer.subscription.updated&lt;/code&gt; and &lt;code&gt;customer.subscription.deleted&lt;/code&gt;: status changed, recompute &lt;code&gt;access&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;invoice.payment_failed&lt;/code&gt;: start the grace window, send the first email.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;invoice.paid&lt;/code&gt;: back to normal, clear the banner.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;invoice.payment_action_required&lt;/code&gt;: the card needs 3D Secure, send them a link to finish it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Two things that go wrong here: events arrive twice and out of order. So on every event, re-fetch the subscription from Stripe and write &lt;em&gt;its current&lt;/em&gt; status, don't trust the status inside the event payload. I went through the other ways &lt;a href="https://dev.to/posting-dude/your-stripe-webhook-works-in-test-mode-heres-how-it-breaks-in-production-198"&gt;Stripe webhooks break between test mode and production&lt;/a&gt; in an earlier post, idempotency included.&lt;/p&gt;

&lt;h2&gt;
  
  
  The nightly safety net
&lt;/h2&gt;

&lt;p&gt;Webhooks get missed. Deploys happen at the wrong moment, endpoints return 500 for ten minutes. So once a night, list your subscriptions from Stripe, recompute &lt;code&gt;access&lt;/code&gt; for each, and log any row where your table disagreed with Stripe. Under 100 customers that's one API page and a few seconds. If that log is ever non-empty, you just found a bug in your webhook handler for free.&lt;/p&gt;

&lt;h2&gt;
  
  
  A quick test before you trust it
&lt;/h2&gt;

&lt;p&gt;Stripe's test cards make this easy to check in an afternoon:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Create a sub with a card that requires authentication and don't complete it. You should see &lt;code&gt;incomplete&lt;/code&gt; and your "finish payment" screen.&lt;/li&gt;
&lt;li&gt;Create a sub with a normal test card, then swap the default payment method to a card that declines and advance a test clock past the renewal. You should land in &lt;code&gt;past_due&lt;/code&gt;, with full access and the banner.&lt;/li&gt;
&lt;li&gt;Keep advancing past your retry window. You should land wherever your Dashboard setting says (&lt;code&gt;canceled&lt;/code&gt;, &lt;code&gt;unpaid&lt;/code&gt; or still &lt;code&gt;past_due&lt;/code&gt;), and your app should match.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If step 3 surprises you, that's the whole point of doing it.&lt;/p&gt;




&lt;p&gt;If you're getting a small SaaS ready to put in front of people, I run &lt;a href="https://auraplusplus.com/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=stripe-subscription-statuses" rel="noopener noreferrer"&gt;Aura++, a launch platform for indie products&lt;/a&gt;, and early-stage stuff is exactly what it's for.&lt;/p&gt;

&lt;p&gt;Curious how others handle it: when a renewal fails, do you keep full access during retries, go read-only right away, or lock it? And how long is your grace window?&lt;/p&gt;

</description>
      <category>saas</category>
      <category>indiehackers</category>
      <category>stripe</category>
      <category>webdev</category>
    </item>
    <item>
      <title>My app was down 40 min once. Nobody complained about the 40 min. They complained that they found zero sign I knew. Now my 500 page links a status page on a different host, and I post "next update in 30 min" even when I have nothing new.</title>
      <dc:creator>Posting Dude</dc:creator>
      <pubDate>Sun, 11 Oct 2026 06:20:21 +0000</pubDate>
      <link>https://dev.to/posting-dude/my-app-was-down-40-min-once-nobody-complained-about-the-40-min-they-complained-that-they-found-5fj1</link>
      <guid>https://dev.to/posting-dude/my-app-was-down-40-min-once-nobody-complained-about-the-40-min-they-complained-that-they-found-5fj1</guid>
      <description></description>
    </item>
    <item>
      <title>My Tiny SaaS Went Down for 40 Minutes. The Outage Was Fine, the Silence Wasn't.</title>
      <dc:creator>Posting Dude</dc:creator>
      <pubDate>Sun, 11 Oct 2026 06:19:39 +0000</pubDate>
      <link>https://dev.to/posting-dude/my-tiny-saas-went-down-for-40-minutes-the-outage-was-fine-the-silence-wasnt-3bai</link>
      <guid>https://dev.to/posting-dude/my-tiny-saas-went-down-for-40-minutes-the-outage-was-fine-the-silence-wasnt-3bai</guid>
      <description>&lt;p&gt;A few months back my app was down for about 40 minutes on a weekday evening. Database host had a bad node, nothing I did. I fixed it, it came back, I went to sleep feeling ok.&lt;/p&gt;

&lt;p&gt;Next morning I had two emails. One said "is this thing abandoned?" The other was a cancellation with no reason given. Neither person was mad about the 40 minutes. They were mad that they hit an error page, checked twitter, checked my site, found nothing, and assumed nobody was home.&lt;/p&gt;

&lt;p&gt;That's the part I got wrong. Small products go down, everybody knows it. What hurts is when users can't tell if you even noticed.&lt;/p&gt;

&lt;p&gt;So here's the small setup I use now. It costs nothing and took one afternoon.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Find out before your users do
&lt;/h2&gt;

&lt;p&gt;If a customer is the one telling you the site is down, you've already lost the first 20 minutes.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;One external uptime check on the homepage AND one on a real endpoint that touches the database (I use a tiny &lt;code&gt;/health&lt;/code&gt; route that runs &lt;code&gt;select 1&lt;/code&gt;). The homepage alone lies, it's often cached or static.&lt;/li&gt;
&lt;li&gt;Alerts go to my phone as a push notification, not email. Email I see an hour later.&lt;/li&gt;
&lt;li&gt;Check every 1–3 minutes, and alert only after 2 failures in a row. Otherwise you get woken up for one flaky request and start ignoring it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I wrote about doing this while still having a day job in &lt;a href="https://sidehunt.io/studio/how-to-monitor-a-side-project-during-your-day-job?utm_source=devto&amp;amp;utm_medium=crosslink&amp;amp;utm_campaign=tiny-saas-outage-comms" rel="noopener noreferrer"&gt;how to monitor a side project during your day job&lt;/a&gt;, the short version is: fewer alerts that you actually trust.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Have one place to say "we know"
&lt;/h2&gt;

&lt;p&gt;You don't need a fancy status page product. You need one URL that you can update in 30 seconds from your phone, and that does not live on the same server as your app. (Mine was on the same server for a while. Guess how useful that was.)&lt;/p&gt;

&lt;p&gt;Options that work fine for tiny products:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a free hosted status page from your uptime tool&lt;/li&gt;
&lt;li&gt;a pinned post on whatever social account your users actually follow&lt;/li&gt;
&lt;li&gt;a static page on a different host&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then link it from the error page and the footer. My 500 page now literally says "if this keeps happening, check status here" with the link. That one line would have saved me both of those emails.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Pre-write the three messages
&lt;/h2&gt;

&lt;p&gt;When something is broken you're stressed and you write badly. So I wrote these once and keep them in a note:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Investigating:&lt;/strong&gt; "Some people can't load the app right now. I'm looking at it. Next update in 30 minutes or sooner."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fixed, watching:&lt;/strong&gt; "It's back up. Cause was X. I'm watching it for the next hour."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;After:&lt;/strong&gt; a short plain email to anyone who was active during the window. What broke, how long, whether any data was affected (say clearly if not), and one thing I changed so it's less likely next time.&lt;/p&gt;

&lt;p&gt;Notice the "next update in 30 minutes". Giving a time is the thing that calms people. Then actually post the update, even if it's "still working on it".&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Check data, not just uptime
&lt;/h2&gt;

&lt;p&gt;The scariest part of my outage wasn't downtime, it was 10 minutes of not knowing if writes during the failover got lost. They didn't, but I couldn't say that confidently until I checked.&lt;/p&gt;

&lt;p&gt;If you can't answer "did anyone lose data?" in a few minutes, that's a backup and restore problem, not a status page problem. I went through that drill in &lt;a href="https://dev.to/posting-dude/your-backups-dont-exist-until-youve-restored-one-a-monthly-drill-for-a-solo-saas-54ip"&gt;a monthly backup restore routine for a solo SaaS&lt;/a&gt;, worth doing before you need it.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Write a tiny post-mortem, even just for you
&lt;/h2&gt;

&lt;p&gt;Five lines in a doc:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;What happened and when (with timezone, future you will forget)&lt;/li&gt;
&lt;li&gt;How I found out (alert or customer?)&lt;/li&gt;
&lt;li&gt;How long until users could see a status update&lt;/li&gt;
&lt;li&gt;Root cause, in one sentence&lt;/li&gt;
&lt;li&gt;One change I'm making&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Line 3 is the one I watch now. First time it was "never". Now it's under 10 minutes.&lt;/p&gt;

&lt;h2&gt;
  
  
  What changed after
&lt;/h2&gt;

&lt;p&gt;I've had two smaller incidents since. Both times I got replies like "thanks for the heads up" instead of cancellations. Same kind of downtime, totally different reaction. Turns out people forgive a broken product way faster than a quiet founder.&lt;/p&gt;

&lt;p&gt;If you're building in public and want a few early users who are fine with this kind of honesty, launching on a small community board like &lt;a href="https://makerhunt.io/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=tiny-saas-outage-comms" rel="noopener noreferrer"&gt;MakerHunt&lt;/a&gt; is where I've found the most patient ones.&lt;/p&gt;

&lt;p&gt;How do you handle it when your thing goes down? Do you post a status update, or just fix it and hope nobody noticed? Genuinely curious.&lt;/p&gt;

</description>
      <category>saas</category>
      <category>webdev</category>
      <category>devops</category>
      <category>indiehackers</category>
    </item>
    <item>
      <title>An AI feature on a free plan bills you, not the user. Before shipping mine: hard cap, per-user token quota, per-minute rate limit. Boring, but cheaper. https://dev.to/posting-dude/put-a-meter-on-your-ai-feature-before-someone-else-runs-up-the-bill-df4</title>
      <dc:creator>Posting Dude</dc:creator>
      <pubDate>Sat, 10 Oct 2026 06:16:53 +0000</pubDate>
      <link>https://dev.to/posting-dude/an-ai-feature-on-a-free-plan-bills-you-not-the-user-before-shipping-mine-hard-cap-per-user-30c0</link>
      <guid>https://dev.to/posting-dude/an-ai-feature-on-a-free-plan-bills-you-not-the-user-before-shipping-mine-hard-cap-per-user-30c0</guid>
      <description>&lt;div class="ltag__link--embedded"&gt;
  &lt;div class="crayons-story "&gt;
  &lt;a href="https://dev.to/posting-dude/put-a-meter-on-your-ai-feature-before-someone-else-runs-up-the-bill-df4" class="crayons-story__hidden-navigation-link"&gt;Put a Meter on Your AI Feature Before Someone Else Runs Up the Bill&lt;/a&gt;


  &lt;div class="crayons-story__body crayons-story__body-full_post"&gt;
    &lt;div class="crayons-story__top"&gt;
      &lt;div class="crayons-story__meta"&gt;
        &lt;div class="crayons-story__author-pic"&gt;

          &lt;a href="/posting-dude" class="crayons-avatar  crayons-avatar--l  "&gt;
            &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3883134%2F75bed475-b966-4e63-8921-41160c6a93f3.jpg" alt="posting-dude profile" class="crayons-avatar__image" width="400" height="400"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
        &lt;div&gt;
          &lt;div&gt;
            &lt;a href="/posting-dude" class="crayons-story__secondary fw-medium m:hidden"&gt;
              Posting Dude
            &lt;/a&gt;
            &lt;div class="profile-preview-card relative mb-4 s:mb-0 fw-medium hidden m:inline-block"&gt;
              
                Posting Dude
                
                
              
              &lt;div id="story-author-preview-content-4826906" class="profile-preview-card__content crayons-dropdown branded-7 p-4 pt-0"&gt;
                &lt;div class="gap-4 grid"&gt;
                  &lt;div class="-mt-4"&gt;
                    &lt;a href="/posting-dude" class="flex"&gt;
                      &lt;span class="crayons-avatar crayons-avatar--xl mr-2 shrink-0"&gt;
                        &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3883134%2F75bed475-b966-4e63-8921-41160c6a93f3.jpg" class="crayons-avatar__image" alt="" width="400" height="400"&gt;
                      &lt;/span&gt;
                      &lt;span class="crayons-link crayons-subtitle-2 mt-5"&gt;Posting Dude&lt;/span&gt;
                    &lt;/a&gt;
                  &lt;/div&gt;
                  &lt;div class="print-hidden"&gt;
                    
                      Follow
                    
                  &lt;/div&gt;
                  &lt;div class="author-preview-metadata-container"&gt;&lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
            &lt;/div&gt;

          &lt;/div&gt;
          &lt;a href="https://dev.to/posting-dude/put-a-meter-on-your-ai-feature-before-someone-else-runs-up-the-bill-df4" class="crayons-story__tertiary fs-xs"&gt;&lt;time&gt;Oct 10&lt;/time&gt;&lt;span class="time-ago-indicator-initial-placeholder"&gt;&lt;/span&gt;&lt;/a&gt;
        &lt;/div&gt;
      &lt;/div&gt;

    &lt;/div&gt;

    &lt;div class="crayons-story__indention"&gt;
      &lt;h2 class="crayons-story__title crayons-story__title-full_post"&gt;
        &lt;a href="https://dev.to/posting-dude/put-a-meter-on-your-ai-feature-before-someone-else-runs-up-the-bill-df4" id="article-link-4826906"&gt;
          Put a Meter on Your AI Feature Before Someone Else Runs Up the Bill
        &lt;/a&gt;
      &lt;/h2&gt;
        &lt;div class="crayons-story__tags"&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/ai"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;ai&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/saas"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;saas&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/webdev"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;webdev&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/indiehackers"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;indiehackers&lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="crayons-story__bottom"&gt;
        &lt;div class="crayons-story__details"&gt;
            &lt;a href="https://dev.to/posting-dude/put-a-meter-on-your-ai-feature-before-someone-else-runs-up-the-bill-df4#comments" class="crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon-left flex items-center"&gt;
              

              &lt;span class="hidden s:inline"&gt;Add&amp;nbsp;Comment&lt;/span&gt;
            &lt;/a&gt;
        &lt;/div&gt;
        &lt;div class="crayons-story__save"&gt;
          &lt;small class="crayons-story__tertiary fs-xs mr-2"&gt;
            3 min read
          &lt;/small&gt;
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;/div&gt;


</description>
    </item>
  </channel>
</rss>
