Everyone is shipping AI-generated code right now. Most of it is going straight to main.

Quick verdict: Qodo catches production-grade bugs in AI-generated code before they ship. Claude Code generated a Stripe webhook handler that passed TypeScript, looked clean, and had six real bugs — an ack-before-processing pattern that would silently drop fulfilled orders, no replay protection, a non-atomic rate limiter, a DoS-prone body read, a timing-unsafe signature compare, and a shared rate-limit bucket for null IPs. Qodo flagged all six in 90 seconds. Two of them I hadn't planted; the review reasoned them out from how the code behaves at runtime, not what it says.

I'm not going to tell you that's always wrong. A lot of it is fine. But I've been building production systems on Cloudflare Workers for six years, and I know exactly how "fine" can turn into a 2am incident. The subtle bugs — the ones that pass a quick read, pass TypeScript, pass your linter — are the ones that hurt.

So I ran an experiment. I asked Claude Code to generate a Stripe webhook handler for a Cloudflare Worker. I did not edit it. I did not second-guess it. I opened a PR and let Qodo run a code review on it.

This is what happened.


the setup

The feature: a Cloudflare Worker that receives Stripe webhooks, validates HMAC signatures, rate-limits by IP using KV, and processes checkout.session.completed and payment_intent.payment_failed events.

That's a real thing. It's the kind of feature an AI tool generates confidently and completely. Looks clean. Passes TypeScript strict mode. The logic flow makes sense on a first read.

It also had six bugs.

Here's the repo: github.com/dannwaneri/stripe-webhook-worker

The code is 173 lines of TypeScript. Signature validation, rate limiting, event dispatch, KV writes. Nothing exotic. Exactly the kind of thing you'd ship on a deadline without a second look.


the code Claude generated

The entry point looks like this:

export default {
  async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
    const ip = request.headers.get('CF-Connecting-IP') ?? 'unknown';

    const limited = await checkRateLimit(ip, env);
    if (limited) {
      return new Response(JSON.stringify({ error: 'Rate limit exceeded' }), { status: 429 });
    }

    const rawBody = await request.text();
    const signature = request.headers.get('stripe-signature');

    if (!signature) {
      return new Response(JSON.stringify({ error: 'Missing stripe-signature header' }), { status: 400 });
    }

    const isValid = await validateSignature(rawBody, signature, env.STRIPE_WEBHOOK_SECRET);
    if (!isValid) {
      return new Response(JSON.stringify({ error: 'Invalid signature' }), { status: 401 });
    }

    // ... parse JSON, dispatch event

    ctx.waitUntil(processEvent(event, env));

    return new Response(JSON.stringify({ received: true }), { status: 200 });
  },
};
Enter fullscreen mode Exit fullscreen mode

Reads cleanly. Validates the signature. Rate-limits. Returns 200. Done.

Except no.


running qodo

Installing Qodo took about five minutes. Go to the GitHub Marketplace, install the app, scope it to the repo. Then connect at app.qodo.ai. Once that's linked, you trigger a review by commenting on the PR:

/agentic_review
Enter fullscreen mode Exit fullscreen mode

Ninety seconds later, Qodo's response appeared in the PR thread. Six bugs. Zero rule violations.


what it found

Finding 1: ack before processing (action required — reliability)

This was the top-priority finding, and it's the one that would have caused a real incident.

The code calls ctx.waitUntil(processEvent(event, env)) and immediately returns 200 OK. Stripe sees the 2xx and stops retrying. But processEvent runs in the background — if it fails (KV timeout, unhandled exception, runtime termination), Stripe never knows. The order goes unfulfilled. No alert fires. The customer waits.

Qodo's fix: either await processEvent(event, env) and return non-2xx on failure so Stripe retries, or persist the event to a durable queue before returning 200, then process with retries separately.

I knew there was no try/catch in processEvent. I hadn't framed it as an acknowledgment problem — that framing is sharper and explains the real-world failure mode directly.

Finding 2: no replay protection (action required — security)

The validateSignature function parses Stripe's t=timestamp from the header and uses it to reconstruct the signed payload. That's correct. What it doesn't do is check whether the timestamp is recent.

Stripe's own documentation says to reject any webhook where the timestamp is more than five minutes old. Without that check, a valid captured webhook can be replayed indefinitely. Same valid signature, same event ID, processed again.

The fix is four lines:

const age = Math.floor(Date.now() / 1000) - Number(timestamp);
if (age > 300) return false; // reject events older than 5 minutes
Enter fullscreen mode Exit fullscreen mode

Finding 3: non-atomic rate limiting (remediation recommended — reliability)

The rate limiter reads the current count from KV, checks if it's under the limit, then writes the incremented count back. Two concurrent requests both read count = 0, both pass the check, both write count = 1. Under any real burst the rate limiter is trivially bypassed.

The correct implementation uses Durable Objects for atomic counters, or pushes the rate logic to Cloudflare's native rate limiting API.

Finding 4: body read before header check (remediation recommended — security)

The code reads rawBody = await request.text() before checking whether the stripe-signature header even exists. That means any request without a signature — a scanner, a bot, a misconfigured service — forces the worker to consume and buffer the full request body before being rejected.

For most requests that's noise. For a large payload flood it's a real DoS surface. The fix is to check the header first.

Finding 5: timing-unsafe signature compare (advisory — security)

The computed HMAC is compared to the expected hash with ===. JavaScript string comparison short-circuits on the first mismatched character, which leaks timing information an attacker can use to recover the expected hash byte-by-byte.

The fix is crypto.subtle.timingSafeEqual on the raw byte arrays before hex-encoding:

const computedBytes = new Uint8Array(mac);
const expectedBytes = hexToBytes(expectedHash);
return computedBytes.length === expectedBytes.length &&
  crypto.subtle.timingSafeEqual(computedBytes, expectedBytes);
Enter fullscreen mode Exit fullscreen mode

This is a real CVE class. Qodo ranked it advisory.

Finding 6: shared 'unknown' IP bucket (advisory — reliability)

When CF-Connecting-IP is null, the fallback is the string 'unknown'. Every request that arrives without the header — health checks, misconfigured proxies, certain load balancer configurations — shares the same rate limit bucket. One noisy service can lock out all other headerless traffic.

I did not put this one in the code intentionally. Qodo caught it by reading the fallback on line 15, cross-referencing the rate limiter, and reasoning about what happens at runtime with a null header. That's not a pattern matcher. That's contextual analysis.


what surprised me

Two things.

The prioritization surprised me more than the findings did. Timing-unsafe comparison — the CVE-class security bug — ranked advisory. The architectural reliability issue ranked first. That's a judgment call, not a checklist. Qodo's reasoning: if the timing attack succeeds, an attacker can forge requests. But if the ack-before-processing architecture silently drops fulfilled orders, that's production-down-right-now. I don't entirely agree with the weighting, but I understand the reasoning and it's defensible.

Two findings weren't bugs I planted. Finding 4 (body before header) and Finding 6 ('unknown' IP bucket) — both required the review to understand what the code does, not just what it says. The 'unknown' bucket catch in particular required multi-line reasoning — fallback value on line 15, rate limiter logic in a separate function, runtime behavior with a missing header. That's what Qodo calls the Context Engine: the codebase is indexed so reviews understand architecture, not just the diff.

What it missed is worth naming. The KV namespace is reused for two semantically different key types: rl:* keys with a 60-second TTL and order:* keys with no TTL. If you ever add a TTL policy to the namespace globally, order records start expiring. Qodo didn't catch this — it would require knowing the intent of the two key types, not just observing they share a namespace. That's a fair miss. It's also exactly the kind of thing that bites you six months later when someone touches the KV config.


the generation / review distinction

Claude Code generated this. It generated it well — the code is structured, typed, readable, and handles the happy path correctly. That's what generation tools are for.

Qodo reviewed it. It found six bugs, two of them action-required, without knowing I'd planted any of them. It surfaced findings I didn't anticipate. It prioritized by real-world impact, not severity labels.

These are different jobs. Cursor and Claude are good at one. Qodo is built for the other. The reason this matters specifically for AI-generated code: AI tools write confidently. They don't flag their own assumptions. They don't know what they don't know about your production environment. The code looks reviewed because it looks clean.

Qodo is an AI code review platform. It runs as parallel agents on each PR — separate agents for critical issues, duplicated logic, breaking changes, ticket compliance, and rule enforcement, each running independently. The Context Engine indexes your codebase so it can reason about cross-file implications and architectural consistency, not just the lines in the diff. What came back on this PR wasn't a list of style nits. It was a structural critique of how the handler handles failure.

That's the gap between generating and reviewing. The PR looked fine. It wasn't.


takeaway

Run the code review. Not because you don't trust the tool that generated it. Because the tool that generated it isn't the right tool for the job.

Six bugs in 173 lines. Two of them action-required. One I hadn't thought of. That's not a failure of the generator — it's an argument for the review step.

If you're shipping AI-generated PRs without a structured review pass, you're not moving faster. You're just moving the incident to later.


The full code is at github.com/dannwaneri/stripe-webhook-worker. Qodo runs on the free tier for public repos — qodo.ai.


If you want to go deeper on AI code review, Qodo's AI Code Review Academy has a few useful reads:

Sponsored by Qodo.

This article was written with AI assistance for research and editing. All arguments, examples, and opinions are my own.