Twenty-eight failed payments in four days, all marked "succeeded" in the dashboard
I run a SaaS product that handles appointment scheduling for tradespeople—plumbers, electricians, gardeners—across the UK and Ireland. We charge £29/month or €34/month depending on the customer's location, and we process everything through Stripe. In late January, four days after I pushed what I thought was a minor update to our webhook handler, I got an email from a customer in Cork: "Your system says my payment went through, but my account's still locked."
I checked Stripe. Payment succeeded. I checked our database. Subscription status: payment_failed. Then I checked our logs and found twenty-seven more accounts in the same state. All had paid. None had been activated. Our webhook handler had recorded the payment_intent.succeeded event, then failed silently before updating the subscription record. No error emails, no Sentry alerts, nothing.
I'd been using Claude Sonnet 4.5 for most coding tasks—refactoring, writing tests, explaining gnarly SQL queries. I'd heard o1 was better at deep reasoning, the kind where you need to trace execution paths through async code and spot timing bugs. I decided to use both models on all twenty-eight failures, one bug at a time, and keep a log of which model caught what. I worked through the list over three evenings, sitting at my kitchen table in Hackney with a pot of tea and a Google Sheet tracking every false start.
The idempotency key collision that Claude caught in twelve seconds
The first bug looked simple. Stripe sent payment_intent.succeeded, our handler fired, then crashed with a database constraint violation. I pasted the error log and the webhook handler code into Claude Sonnet 4.5 and asked it to explain what happened.
It came back in twelve seconds: "You're generating idempotency keys using payment_intent.id alone. If Stripe retries the same webhook—which it does if your endpoint doesn't return 200 within ten seconds—you try to insert the same payment record twice. Your database has a unique constraint on payment_intent_id, so the second insert fails. The subscription update happens after the insert, so it never runs."
Correct. I'd assumed idempotency keys only mattered for outbound Stripe API calls, not for my own database writes. Claude suggested adding a UNIQUE constraint on (payment_intent_id, event_id) and wrapping the insert in a transaction with the subscription update. I tested it on the staging webhook endpoint with Stripe's CLI, and the duplicate event was ignored cleanly.
I tried the same query in o1. It took two minutes to respond and gave me a longer explanation of idempotency in distributed systems, including a digression about two-phase commit protocols. Accurate, but I didn't need a lecture—I needed to know which line was broken.
Score after bug one: Claude 1, o1 0.
The retry timing window that o1 found after Claude missed it twice
Bug seven was harder. A customer in Edinburgh had paid, the webhook fired, the payment record was written, but the subscription status stayed trialing. No error in the logs. I pasted the code into Claude and asked what could cause the subscription update to be skipped silently.
Claude suggested three possibilities: the subscription ID might be null, the Stripe API call might have timed out, or the user might have cancelled between payment and webhook delivery. I checked all three. Subscription ID was valid, no timeout in the Stripe logs, no cancellation. I asked Claude to look again. It suggested adding more logging. Not helpful.
I switched to o1 and pasted the same code with the full webhook event JSON. o1 took four minutes, then came back: "Your webhook handler queries the subscription status from Stripe before updating it. If the payment succeeds but Stripe hasn't finished updating the subscription object on their side—which can take 200–400 milliseconds—your query returns trialing, you compare it to trialing, and you skip the update because you think nothing changed. Stripe retries the webhook three times over ten minutes, but by then your handler has already decided the subscription is current, so it returns 200 and does nothing."
I added a 500ms delay before querying the subscription, tested it with stripe trigger payment_intent.succeeded, and the subscription updated correctly. Claude had assumed Stripe's data would be consistent immediately. o1 considered the replication lag.
Score after bug seven: Claude 1, o1 1.
The VAT-rate edge case that neither model caught until I changed the prompt
Bug fourteen involved a customer in Northern Ireland who paid £29, but our system recorded £24.17 as the net amount and marked the subscription payment_failed because the amounts didn't match. I pasted the webhook payload and the validation logic into Claude. It said the validation was correct: we were checking that payment_intent.amount divided by 1.2 (the 20% VAT rate) equalled the plan price. The maths looked right.
I tried o1. Same conclusion. The logic was sound.
I stared at the webhook JSON for ten minutes, then noticed the customer's billing address had a postcode starting with BT. Northern Ireland. I Googled "VAT rate Northern Ireland 2025" and found that while the standard UK rate is 20%, some services are zero-rated for customers in Northern Ireland under the Windsor Framework if they're sold digitally and consumed locally. Our service wasn't zero-rated, but the payment processor had applied a reduced rate somewhere in the chain, and Stripe recorded the final amount as £24.17 plus £4.83 VAT at a blended rate.
I went back to Claude and said, "The customer is in Northern Ireland. Could VAT be calculated differently?" Claude responded immediately: "Yes. Northern Ireland has special VAT rules for digital services under the Windsor Framework. If the customer's billing address is in NI, you need to check payment_intent.charges.data[0].billing_details.address.postal_code and handle rates separately."
o1 gave a similar answer when I added the Northern Ireland detail to the prompt. Neither model knew to ask about location until I told them. The lesson wasn't that the models failed—it was that I needed to include business context, not just code, in the prompt.
Score after bug fourteen: Claude 2, o1 2, me 1 for reading the postcode.
The async race condition that o1 traced through five function calls
Bug twenty-two was the nastiest. A customer in Dublin paid €34, the webhook fired, the payment was recorded, the subscription was updated to active, then five seconds later a second event—customer.subscription.updated—arrived and overwrote the status back to trialing. I pasted both webhook payloads and the handler code into Claude and asked why the second event would revert the status.
Claude said, "You're not checking the event timestamp. The customer.subscription.updated event might be stale—Stripe doesn't guarantee ordering." I added a timestamp check. The bug still happened. The second event had a later timestamp than the first.
I switched to o1 and included the full call stack: webhook receiver → event dispatcher → subscription updater → Stripe API client → database write. o1 took six minutes, then walked me through the execution: "When payment_intent.succeeded arrives, you call updateSubscription(), which fetches the subscription from Stripe, updates your database, then triggers a Stripe API call to add metadata. That API call triggers customer.subscription.updated, which arrives while your first handler is still running. Your database write from the second event happens after the first write, so it wins. You need to add a distributed lock keyed on subscription_id so only one handler can update a given subscription at a time."
I added a Redis lock with a five-second TTL. The race condition disappeared. Claude had assumed events would be serialised by the queue. o1 considered what happens when they're not.
Score after bug twenty-two: Claude 2, o1 3.
The false positive from o1 that cost me an hour
Not every o1 answer was gold. Bug eighteen involved a webhook that returned 500 because our database connection pool was exhausted. I pasted the error into o1 and it spent eight minutes generating a response about optimistic locking, event sourcing, and CQRS patterns. It suggested rewriting the webhook handler to use an event log and a separate worker process.
I asked, "Is there a simpler fix?" o1 doubled down: "For production resilience, you should implement event sourcing to ensure exactly-once processing semantics."
I pasted the same error into Claude. It said, "Your connection pool has a max size of ten. You're opening a new connection in the webhook handler but not closing it. Increase the pool size or use a connection manager." I added pool.release() after each query, and the 500s stopped.
o1's answer wasn't wrong—event sourcing would work—but it was like suggesting I demolish the house because the kitchen tap was dripping. Claude fixed the tap.
What I'd use for each type of bug now
After twenty-eight bugs, the pattern was clear. Claude Sonnet 4.5 was faster and better at surface-level mistakes: syntax errors, missing null checks, incorrect API parameters, simple logic inversions. If I could describe the bug in one sentence, Claude usually nailed it in under thirty seconds.
o1 was better at bugs that required tracing execution across multiple systems: race conditions, retry logic, async timing issues, anything where the failure mode wasn't visible in a single function. It was slower—responses took two to eight minutes—but when Claude gave me three guesses and none of them worked, o1 often found the answer by simulating the full execution path.
Neither model was good at bugs that required business context I hadn't provided: VAT rules, webhook ordering guarantees from Stripe's documentation, or assumptions I'd made about how customers would use the product. For those, I had to do the research, then come back with a better prompt.
If I were debugging this again, I'd start with Claude for the first pass on every bug. If Claude's fix didn't work after two attempts, I'd switch to o1 and give it the full context: code, logs, webhook payloads, and a description of what I'd already tried. I'd also keep a checklist of business rules—VAT rates, currency handling, retry policies—and include them in the prompt upfront, because neither model will ask for details you didn't mention.
I built this workflow in Kryotta because I could paste a webhook payload into Claude, get an answer, then paste the same payload into o1 without retyping it, and compare the responses side by side. The Compare arena feature let me run the same prompt through both models at once, which saved me from switching tabs and losing track of which model said what.
Questions people ask
How much did it cost to debug 28 bugs with two models?
I didn't track costs per query, but my Kryotta bill for those three evenings was under £8. Most of the cost was o1's longer responses. Claude's answers were fast enough that the per-token cost was negligible.
Would you use o1 for every bug now that you know it's better at race conditions?
No. o1 is slower, and most bugs aren't race conditions. I'd still start with Claude and escalate to o1 only when Claude's fix doesn't work. Speed matters when you're debugging at 11pm and you want to go to bed.
Did you fix all 28 bugs, or are some still open?
Twenty-six are fixed and deployed. Two are still open because they require changes to how Stripe handles webhooks on their side, and I'm waiting for their support team to confirm whether the behaviour I'm seeing is a bug or intended.
Can I use this workflow for webhooks from other payment providers, like GoCardless or Mollie?
Yes. The same logic applies: paste the webhook payload and your handler code into Claude, try the fix, and escalate to o1 if it doesn't work. The only difference is that each provider has its own retry policy and event ordering guarantees, so you'll need to include those in your prompt.
I'm still using both models for debugging, and I've started logging which model I used for each fix so I can see the pattern over time. If you're handling Stripe webhooks—or any async event processing—and you've hit a bug that doesn't make sense after twenty minutes of staring at the logs, try pasting it into Kryotta and running it through Claude first, then o1 if Claude doesn't get there. One of them will spot what you missed.



