july was my worst month in 2 years. -$2,180 MRR. here is all of it, including the part where i double charged 143 people
the numbers, the billing bug, the launch that died, and the 6 things i am changing
Contents
i almost didn't post this one. which is exactly why i'm posting it.
the numbers
- invoice PDF API: $16,910 (-$1,510). churn 6.8% 📉
- shopify order tagging: $10,610 (-$570). churn 5.2%
- uptime monitor: $2,240 (-$100)
total $29,760. june was $31,940. that's back below april.
part 1: my biggest customer left
a payroll startup paying $1,100/month moved their PDF generation in-house. nice email, no drama, "we're consolidating vendors". that's $1,100 of the $1,510 right there. i knew one customer was 6% of the API revenue. i told myself it was fine. it was fine until it wasn't.
part 2: i double charged 143 people
base plans are normal stripe subscriptions. overages (PDFs above your plan) i calculate myself and add as invoice items once a month. a nightly job does it.
on july 9 at 11pm (yes. i know.) i shipped a migration that, among other things, reset a last_billed_at column on 143 subscriptions. the overage job ran at 2am, saw 143 subscriptions that had "never been billed" for june, and billed them. again.
$6,412 in overage charges, twice. i found out at 7:40am from an email that started with "are you kidding me".
what i did
- turned off the job
- refunded all 143 within 3 hours
- emailed every single one myself. not a template. ok, half a template
- gave every affected account a free month
cost: stripe doesn't give back the processing fees on refunds, about $4,100 of free months, 11 of the 143 cancelled anyway, and 2 one-star reviews on the shopify app from people who weren't even affected but were angry on principle. fair enough honestly.
the fix (the part you can steal)
the job had no memory of what it had already done. it trusted a timestamp column that any migration could touch. now:
ALTER TABLE overage_charges
ADD CONSTRAINT one_charge_per_period UNIQUE (subscription_id, period_start);
the job inserts the charge row first, and only calls stripe if the insert succeeded. a second run fails loudly on the constraint instead of quietly charging people. i also send stripe an idempotency key built from the subscription id and the period, but stripe only keeps idempotency keys for about 24 hours, so the database constraint is the real guard. the key just covers retries on the same night.
and one dumb alarm: before the job charges anyone, it adds up the total and compares it with last month. over 130%, it stops and texts me. it would have caught july.
part 3: the launch that died
status pages for the uptime monitor. 5 weeks of work. launched july 22, here, on product hunt and in my newsletter (4,100 people).
3 signups. one was my mom. one was a bot. the third cancelled the next day and said "i thought this was something else".
i killed it on july 31. the code lives in a branch called rip-status-pages. i'm not linking it.
6 things i'm changing
- no deploys after 6pm. not "try not to". no.
- billing code gets a dry run and a diff against last month, every single time
- no single customer above 3% of a product's revenue without a contract
- no new product until the existing ones stop leaking
- i ask 10 customers before i build anything. for status pages i asked 0
- i stop pretending i'm fine at 11pm. i'm not fine at 11pm. nobody is
that's july. biscuit doesn't know. biscuit thinks we're doing great.
Written by
Jo Brennan
solo founder. no VC, no cofounder, no employees. 3 tiny SaaS products, $38.2k MRR last month (I post the numbers every month, good and bad). ex agency PM. denver. dog: Biscuit 🐶
Get the best of #bootstrapped_and_broke weekly
One email each Monday with the most useful posts. No spam; unsubscribe in one click.
Comments
Sign in to join the conversation.
Sign in to comment