DEV Community

ahmed isam
ahmed isam

Posted on

I Shipped a Pixel-Pet SaaS for OpenAI Codex — and the Rate-Limit + Payment Setup That Made It Safe

I kept wanting a coding companion with a bit of personality — a little pixel creature that lives in my OpenAI Codex setup while I work. The ones floating around were either locked into one ecosystem or just a static PNG. So I built PetGen: upload a photo, get a pixel-art "base" character, approve it, and it spits out a 9-state animated spritesheet (.webp) plus a pet.json describing each frame. Drop the zip into your Codex flow, or use the sprite as a Discord avatar.

This post is the build story — the stack, and the two engineering problems worth writing down: rate limiting without a separate service, and granting paid quota without trusting the client.

Try it: https://codexpetgenerator.com (free tier, no account needed for the instant demo).

The stack

Nothing exotic, on purpose:

  • Next.js (App Router) for the app + API routes
  • Supabase for auth, the Postgres-backed usage ledger, and storage
  • PayPal for payments (webhook + capture)
  • Vercel for hosting, GA4 for analytics

The whole point of the product is that the output is just files — a .webp and a small .json. No lock-in, no runtime dependency on me. That decision shaped everything downstream.

The generation pipeline

The flow is intentionally decoupled so a slow AI call never blocks the request:

  1. POST /api/pets/generate validates the image, creates a pets row with status: processing, increments quota, then fires-and-forgets the image work.
  2. A background async block calls the image model, uploads the result, and flips the row to awaiting_approval.
  3. The client polls GET /api/pets/[taskId] until it sees a base image, approves, and we generate the full 9-state spritesheet.
  4. GET /api/pets/[taskId]/download returns a zip.

The key trick is counting the quota only after the row exists:

const { data: pet, error: dbError } = await supabase
  .from('pets')
  .insert({ status: 'processing', style: 'pixel', email: user.email ?? email ?? null })
  .select()
  .single()

if (dbError || !pet) {
  return NextResponse.json({ error: 'DB_ERROR', message: 'Failed to create task' }, { status: 500 })
}

// Count the generation only after the row is safely created.
await supabase
  .from('user_usage')
  .update({ generations: genCount + 1 })
  .eq('user_id', user.id)
Enter fullscreen mode Exit fullscreen mode

If the insert fails, nobody loses a generation. Small thing, but it's the kind of invariant that keeps support tickets at zero.

Engineering win #1: rate limiting with zero extra infrastructure

I did not want to stand up Redis just to say "free users get 3 generations." The usage ledger was already in Postgres via Supabase, so the rate limit is just a query:

const plan = usageRow?.plan || 'free'
const genCount = usageRow?.generations || 0
const maxGen = plan === 'unlimited' ? 999 : plan === 'pro' ? 15 : 3

if (genCount >= maxGen) {
  return NextResponse.json(
    { error: 'QUOTA_EXCEEDED', message: 'You have used all your generations for this plan.' },
    { status: 403 }
  )
}
Enter fullscreen mode Exit fullscreen mode

The important part: the server is authoritative. The client shows a soft counter for UX, but the real gate is here. A user can hack their local state all they want — the API still rejects at genCount >= maxGen. For a launch-scale product (not 10k req/s yet), a single indexed row per user is plenty fast and removes an entire moving part from the architecture.

Engineering win #2: granting paid quota without trusting the client

This is the one I'd flag for anyone shipping payments. When PayPal redirects back, the naive capture route reads the plan from the request body:

// DO NOT DO THIS
const { orderID, plan } = body
// ...grant `plan` to the user
Enter fullscreen mode Exit fullscreen mode

That's a bug you can exploit: buy the $9 "pro" order, but send plan: "unlimited" in the body, and you just upgraded yourself to the $29 tier for free.

Instead, the order-creation route persists a server-side mapping (paypal_orders table: order_id → user_id + plan). The capture route only takes orderID and resolves the plan from that mapping:

const { orderID } = body                 // plan is NOT read from the body
const { data: orderRow } = await supabase
  .from('paypal_orders')
  .select('user_id, plan, status')
  .eq('order_id', orderID)
  .maybeSingle()

// The captured order must belong to the authenticated user.
if (orderRow.user_id !== user.id) {
  return NextResponse.json({ error: 'Order does not belong to this user' }, { status: 403 })
}

const plan = orderRow.plan               // single source of truth
Enter fullscreen mode Exit fullscreen mode

Two checks here: the plan comes from the database, not the client, and the order must belong to the user who's calling. Even if someone guesses another user's orderID, they can't capture it. The same plan source of truth feeds the idempotent grant, so a double-capture can't double-credit quota.

What I'd do differently

  • Ship the Supabase RLS policies on day one. I enforced auth in app code from the start, but row-level security is defense-in-depth that should've been in the first migration, not a "later" item.
  • Add SoftwareApplication + AggregateRating JSON-LD earlier. Once I had real reviews, structured data for the product became a quiet SEO win — should've been wired before launch.
  • Treat i18n, theming, and mobile scaffolding as table stakes in the initial scaffold, not retrofits. The cost of bolting them on later is an order of magnitude higher.

Wrapping up

The product is live at https://codexpetgenerator.com, and the launch writeup is on Hacker News if you want to argue with my architecture choices there. If there's appetite, I'll follow up with a post on the spritesheet generation itself — the prompt engineering and frame-layout tricks that make the 9 states actually look like the same creature.

Happy to answer anything about the Supabase rate-limit setup or the PayPal capture flow in the comments.

Top comments (0)