I Vibe-Coded a SaaS in a Weekend. Here's What Broke in Week Two.
Saturday: I shipped a working SaaS with AI tools and almost no manual code. Sunday: 14 paying users. The following Wednesday: my Stripe webhook had been creating duplicate subscriptions for 36 hours.
The blog posts about shipping a SaaS in 48 hours are real. So is the part those posts skip. The second week, when your idempotency assumptions get tested by real users and a slightly flaky payment provider. This is the part I wish someone had written before I learned it the hard way.
I learned it the hard way.
What actually broke
The Saturday code worked. The bug was in the seams. The parts where my system met someone else's system. Specifically:
- Stripe retried a webhook three times in 90 seconds because my handler took too long to ack. The AI-generated code processed the event each time. Result: three subscriptions, one customer.
- The signup flow assumed unique emails. My Supabase migration didn't add a unique constraint. Two people signed up with the same email. Things got weird.
- A retry loop on OpenAI 429s used no jitter. When the provider rate-limited me, every retry hit at the same time and got rate-limited again. Beautiful synchronisation.
None of these bugs are exotic. All three are things a senior would have flagged on day one. The AI didn't flag them because nobody asked it to. That's the lesson, really.
The class of bug, and one fix
# Vibe-coded webhook handler (the version I shipped):
@app.post("/stripe/webhook")
async def webhook(req: Request):
event = stripe.Webhook.construct_event(...)
if event.type == "checkout.session.completed":
await create_subscription(event.data.object) # not idempotent!
return {"ok": True}
# What I wish I'd shipped:
@app.post("/stripe/webhook")
async def webhook(req: Request):
event = stripe.Webhook.construct_event(...)
# Idempotency key from the event itself — Stripe guarantees stability.
seen = await db.execute(
"INSERT INTO processed_events(id) VALUES ($1) ON CONFLICT DO NOTHING RETURNING id",
event.id,
)
if not seen:
return {"ok": True} # we've already processed this one
if event.type == "checkout.session.completed":
await create_subscription(event.data.object)
return {"ok": True}
Why the fix works
The processed-events table is a one-row, one-column idempotency log. ON CONFLICT DO NOTHING RETURNING is the atomic "have I seen this before" check. No race conditions, no application-level locks. Same event arriving three times in 90 seconds? Two of them no-op. The model didn't know to add this because I didn't tell it I'd be talking to Stripe webhooks, which retry aggressively by design.
When to vibe-code your way to launch
Exploratory products where the goal is to find out if anyone wants the thing. Internal tools. Side projects. Prototypes you're prepared to throw away. Anything where the cost of being wrong is "I lose a weekend" and not "I lose a customer's data."
When to stop and write it properly
The moment you take money. The moment you store something a user would be upset to lose. The moment a third user signs up. At that point the seams matter (webhooks, payments, auth, data integrity) and "the AI wrote it" is not a defensible position when things go sideways.
Where bugs lived (in my case)
flowchart LR
subgraph Internal ["My app - AI-generated"]
Sign[Signup] --> Db[(Supabase)]
Pay[Payment trigger] --> Db
Use[Use feature] --> AI[OpenAI]
end
subgraph External
Stripe((Stripe))
OAI((OpenAI))
Email((Resend))
end
Stripe -. retries .-> Pay
AI -. 429s .-> OAI
Sign -. confirmations .-> Email
style Pay fill:#fee2e2,stroke:#dc2626
style AI fill:#fee2e2,stroke:#dc2626
style Sign fill:#fee2e2,stroke:#dc2626Conclusion
Before launch, list every external service your app talks to. For each one, ask: what does this look like if the call retries, times out, or arrives twice? Five-minute exercise. Would have saved my week two. Probably saves yours.