·Vibe Coding·3 min read·Junior developers

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:#dc2626

Conclusion

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.