Day 64: When one person signs up ten times to dodge your rate limit
A long time ago we put rate limits on our AI reply generator. Each tier gets a cap: Noob 5 per month, Chad 50 per hour and 200 per day, GigaChad 80 per hour and 500 per day. The reasoning was simple — we pay Claude API for every reply we generate, so we cap usage per customer to keep our costs predictable.
There's a hole in that.
The cap is per business account. One human with one account stays inside their cap. But one human with ten Noob accounts has 50 free replies a month instead of 5 — and our per-business counter has no idea that's happening, because each account is technically within its own limit.
This is the kind of abuse vector that's invisible until you actually look for it. Most early-stage SaaS founders never check, because nobody's done it yet. By the time someone does, you've already paid for a lot of Claude calls you didn't need to.
So Day 64's main task was adding a second rate limit layer — per IP, on top of per business. 150 requests per hour and 800 per day from any single IP, across all accounts behind that IP. That's roughly 1.9× our highest tier's hourly cap, set so a legitimate GigaChad customer can't hit it on their own, but four Noob accounts hammering from one home connection will.
When the limit fires, we log a row to a new ip_rate_limit_events table. The user gets a generic 429 — "Too many requests from your network. Try again in an hour." The vagueness is deliberate. We don't want to teach automation how to pace itself just under the threshold.
But that's the easy part. The harder part is what to do with the data.
The admin problem
When the rate limit fires, the admin tab needs to show what happened. Day 64's first cut showed exactly that — an IP address, a list of business accounts behind it, and a hit count. Block button per business.
I dogfooded it. Looked at a test IP with 4 hits across two accounts. And realized I had no idea whether to block.
"4 hits from this IP" is a number, not a decision. To actually decide whether to ban a business, I need:
- Is this a paying customer? Noob abusing costs me nothing. GigaChad abusing costs me $79 per month — double-check before pulling the trigger.
- How much did they actually consume? The 429 count is blocked requests. The number that matters to my costs is successful requests in the last 30 days — the ones I actually paid Claude for.
- Is this a brand new account? Signed up two days ago plus hammering equals obvious. Six months old with a sudden spike could be a real business growing.
- Did they sign up from this same IP? Cross-references our signup abuse detection from Day 63. If the same IP made the account and is now hammering the API, that's strong correlation. If they signed up from a different IP and only the abuse comes from this one, the signal is weaker.
So I scrapped the first cut and rebuilt the tab. Each business row in the admin now shows a traffic-light dot — red, yellow, or green — plus a tier badge (NOOB, CHAD, GIGACHAD, LIFETIME), account age, AI replies in the last 30 days, and a "from this IP" tag when the business signed up from the same IP that's now triggering the limit.
The traffic light is the single most useful thing. Red means block. Green means do not block — contact them or raise their cap. Yellow means read the context before deciding. The logic is locked server-side so I'm not interpreting it differently in different sessions.
- Red — Noob tier AND (signed up from this IP OR account younger than 7 days)
- Green — Paid tier AND account 30 days or older AND NOT signed up from this IP
- Yellow — everything else
I also added a callout at the IP group level — "X signups from this IP in last 24h" — pulled from the signup abuse layer. If the same IP shows up in both the Suspicious Signups tab and the IP Abuse tab, that's the strongest signal available. Two independent layers pointing at the same actor.
The bug I found by clicking my own button
The block flow reuses the existing block infrastructure from Day 54 — /api/admin/block-business, the same canned reason templates, the same suspension email, the same appeal flow at /appeal/[token]. No parallel system. Just an 8th template added for "Rate limit abuse."
I wrote the template text. Five sentences explaining what happened, why it's a TOS violation, that no refund will be issued, and how to appeal. Long enough to be clear, short enough to fit in an email.
I clicked Block on a fake test row to verify the flow. Got back — Block failed: reason must be 5-500 chars.
My template was 535 characters.
The block-business route validates the reason field between 5 and 500 chars. Zero database writes happened because validation fired at the top of the route. But if I hadn't dogfooded, the Block button would have been broken in production from day one. The entire rate limit tab works fine — the blocking part of it wouldn't.
Trimmed the template to 385 chars (kept the four essentials: cause, TOS section reference, no-refund clause, 30-day appeal window). Pushed the hotfix. Clicked Block again — full chain ran: Stripe cancel attempt (no-op since the test business is free tier with no subscription), blocked_businesses row inserted, business marked canceled with risk_level flipped to red, suspension email sent via Resend. Verified each step in Supabase, then restored the test business so it's available for future testing.
This is now the fourth time something has shipped on Ominvo where the build was clean, TypeScript passed, the deployment went READY — and a real bug only surfaced when I actually used the feature. Hard No #70 in my own playbook — never assume code paths work because they compiled. Day 64 reinforced it.
What's next
Day 65 is fixing the Admin Health tab, which I also noticed broken while dogfooding today. UptimeRobot is connected and pinging, but the data isn't displaying in the admin UI. Different bug, same lesson.
After that — Stripe live-mode cutover around August 5, the PRE_LAUNCH flag flip on July 23 when GBP API approval lands, and the actual launch on August 10.
Forty-five days. See the full changelog for everything Day 64 shipped.
Written by
The founder of Ominvo
Building review management for single-location small businesses. Join the waitlist →