The Problem — A Wave of Stripe Webhook Failures
One morning, we received identical warning emails from Stripe across all four of our sites: "We've had a problem sending webhook events to your endpoint." Every webhook endpoint was returning HTTP 500, with 34 failed retry attempts and counting.
When you run subscription-based services, webhook failures are a revenue emergency. Left unresolved, invoice processing can be delayed by up to three days, and Stripe will eventually disable the endpoint entirely.
In this article, we walk through how we used Claude in Chrome — the browser automation feature of Cowork — to diagnose the root cause, fix the code across all four sites, deploy the changes, and verify everything was working in production, all within a single session.
Our Stack
Here's the environment where the issue occurred:
- Framework: Next.js 16 (App Router)
- Hosting: Cloudflare Workers (via OpenNext adapter)
- Payments: Stripe (Webhook + Checkout)
- Storage: Cloudflare KV (premium access management)
- Language: TypeScript
- Affected sites: Claude Lab, Gemini Lab, Antigravity Lab, Rork Lab
Root Cause Analysis with Claude in Chrome
Using Claude in Chrome, we performed a cross-repository analysis of all four sites' webhook implementations at src/app/api/webhook/route.ts. A side-by-side comparison quickly revealed the issues.
Issue 1: Explicit Edge Runtime Declaration
// ❌ The problematic code
export const runtime = "edge";
export async function POST(request: NextRequest) {
// ...
}The webhook route explicitly declared export const runtime = "edge", while the checkout route — which was working perfectly — had no such declaration.
On Cloudflare Workers with OpenNext, all routes run in the Workers (Edge) runtime by default. Adding export const runtime = "edge" changes how OpenNext bundles the route, which caused the entire Worker to crash on initialization.
Issue 2: Synchronous constructEvent in Edge Runtime
// ❌ Synchronous — depends on Node.js crypto module
event = stripe.webhooks.constructEvent(body, sig, secret);
// ✅ Asynchronous — uses Web Crypto API (Edge compatible)
event = await stripe.webhooks.constructEventAsync(body, sig, secret);The Stripe SDK's constructEvent() is a synchronous method that relies on Node.js crypto.createHmac. In Edge runtimes where only the Web Crypto API is available, constructEventAsync() must be used instead.
Issue 3: Missing Error Handling for KV Operations
KV (Key-Value storage) read/write operations weren't wrapped in try-catch blocks. Any KV-side errors would surface as unhandled exceptions, resulting in HTTP 500 responses.
The Fix
We applied three changes across all four sites:
import { NextRequest, NextResponse } from "next/server";
import Stripe from "stripe";
interface KVNamespace {
get(key: string): Promise<string | null>;
put(key: string, value: string, options?: { expirationTtl?: number }): Promise<void>;
delete(key: string): Promise<void>;
}
// ✅ Removed: export const runtime = "edge"
// On Cloudflare Workers, all routes are Edge by default
export async function POST(request: NextRequest) {
try {
const body = await request.text();
const sig = request.headers.get("stripe-signature");
if (!sig || !process.env.STRIPE_WEBHOOK_SECRET) {
return NextResponse.json(
{ error: "Missing signature or secret" },
{ status: 400 }
);
}
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: "2026-02-25.clover",
httpClient: Stripe.createFetchHttpClient(),
});
let event: Stripe.Event;
try {
// ✅ Use constructEventAsync for Web Crypto API
event = await stripe.webhooks.constructEventAsync(
body,
sig,
process.env.STRIPE_WEBHOOK_SECRET
);
} catch {
return NextResponse.json(
{ error: "Webhook signature invalid" },
{ status: 400 }
);
}
let kv: KVNamespace | null = null;
try {
kv = (process.env as unknown as { PREMIUM_ACCESS: KVNamespace })
.PREMIUM_ACCESS;
} catch {
// KV not available
}
if (!kv) {
return NextResponse.json({ received: true, note: "KV not available" });
}
// ✅ Wrap KV operations in try-catch
try {
switch (event.type) {
case "customer.subscription.updated": {
const sub = event.data.object as Stripe.Subscription;
const email = (sub as unknown as { customer_email?: string })
.customer_email;
if (email) {
const kvKey = `site:claudelab:email:${email}`;
const existing = await kv.get(kvKey);
if (existing) {
const record = JSON.parse(existing);
record.expires_at = new Date(
Date.now() + 31 * 24 * 3600 * 1000
).toISOString();
await kv.put(kvKey, JSON.stringify(record), {
expirationTtl: 31 * 24 * 3600,
});
}
}
break;
}
case "customer.subscription.deleted": {
const sub = event.data.object as Stripe.Subscription;
const email = (sub as unknown as { customer_email?: string })
.customer_email;
if (email) {
await kv.delete(`site:claudelab:email:${email}`);
}
break;
}
}
} catch {
return NextResponse.json({
received: true,
note: "KV operation error",
});
}
return NextResponse.json({ received: true });
} catch {
// Top-level catch prevents unhandled errors from returning 500
return NextResponse.json({ received: true, note: "Internal error" });
}
}Key changes:
- Removed
export const runtime = "edge"— Let OpenNext handle bundling naturally constructEvent→constructEventAsync— Web Crypto API compatible- Wrapped KV operations in
try-catch— Graceful handling of storage errors - Added top-level
try-catch— Always return HTTP 200 to Stripe, even on unexpected errors
Live Verification with Claude in Chrome
After pushing the fixes to all four sites, we used Claude in Chrome to verify the endpoints in real-time. By executing fetch calls directly in the browser context of each site, we could confirm the API responses without any additional tooling.
// Verification code executed by Claude in Chrome
const res = await fetch('/api/webhook', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: '{}'
});
console.log(res.status, await res.text());
// Before fix: 500 "Internal Server Error"
// After fix: 400 {"error":"Missing signature or secret"}All four sites returned HTTP 400 (correct rejection of unsigned requests), and clicking the checkout buttons on each site's support page confirmed successful redirects to Stripe Checkout.
Lessons Learned for Cloudflare Workers + Next.js + Stripe
Be careful with export const runtime = "edge"
On Cloudflare Workers with OpenNext, all routes already run in Edge runtime. Explicitly declaring export const runtime = "edge" can change the bundling behavior and cause crashes. Keep your API routes consistent — if your checkout route works without it, your webhook route shouldn't need it either.
Always use constructEventAsync in Edge environments
Any Edge runtime — Cloudflare Workers, Vercel Edge Functions, Deno — lacks the Node.js crypto module. The Stripe SDK's constructEventAsync (available since v10) uses the Web Crypto API and works everywhere.
Design webhook handlers to always return 200
When a webhook handler returns 500 repeatedly, Stripe retries aggressively and eventually disables the endpoint. Design your handler with a top-level try-catch that always returns HTTP 200. Log internal errors separately through your monitoring infrastructure.
Wrapping Up
The Stripe Webhook HTTP 500 errors were caused by a combination of Cloudflare Workers-specific issues: a conflicting Edge runtime declaration and the use of a synchronous API that depends on Node.js crypto. Using Claude in Chrome, we were able to diagnose, fix, deploy, and verify the solution across all four sites efficiently.
If you're running a similar stack — Next.js on Cloudflare Workers with Stripe — we hope this guide helps you avoid the same pitfalls.