◉RORK LABJP
●GPT-6.1 SOL — Added to Rork's model menu (09-29) with Low, Medium and High effort; included on Pro and Max●SONNET 5.5 — Claude Sonnet 5.5 is selectable: strong at UI design and faster than Sonnet 5 (09-28)●SONNET 4.5 — Retires on the Claude API on 11/30, 27 days left. If your app calls it, check the migration target●SDK 58 — Moves to React Native 0.88. R8 is now on by default in Android release builds, and iOS prebuild generates a SceneDelegate●OBSERVE — EAS Observe now records native crashes too (SDK 57.0.21 or later, 10/07)●NEW — My App Open Ad Only Showed When the App…●GPT-6.1 SOL — Added to Rork's model menu (09-29) with Low, Medium and High effort; included on Pro and Max●SONNET 5.5 — Claude Sonnet 5.5 is selectable: strong at UI design and faster than Sonnet 5 (09-28)●SONNET 4.5 — Retires on the Claude API on 11/30, 27 days left. If your app calls it, check the migration target●SDK 58 — Moves to React Native 0.88. R8 is now on by default in Android release builds, and iOS prebuild generates a SceneDelegate●OBSERVE — EAS Observe now records native crashes too (SDK 57.0.21 or later, 10/07)●NEW — My App Open Ad Only Showed When the App…
Articles/Dev Tools
⬡ Dev Tools/2026-06-16Advanced

Why Your Rork App's Stripe Webhooks Drop Events Only in Production — Field Notes on Idempotency, Retries, and Out-of-Order Delivery

A field-tested four-layer design for stabilizing Stripe webhooks that pass locally but silently misfire in production: signature verification on Workers, event-ID idempotency, fast-2xx-then-process, and a reconciliation job that survives out-of-order delivery. Built around Cloudflare Workers and KV.

Stripe20webhookCloudflare Workers26idempotency3payments5

✦ Premium Article

The payment went through, but the user's access never changed. Stripe's dashboard shows Payment succeeded. Recent deliveries shows 200 OK. And yet the app still treats them as a free user. If you do payments as a solo indie developer, you eventually hit this exact state: everything looks correct, but the numbers don't line up.

The frustrating part is that it passes locally. A single stripe trigger works on the first try, and then production quietly drops events. The obvious failures — a broken signature — surface fast. The ones that eat hours are the failures that still log as success: the same event processed twice, an event that arrived out of order, a handler that ran too slowly and got retried. None of those look like errors in your logs.

These are field notes from stabilizing webhook handling for an app I built with Rork, backed by Cloudflare Workers and KV. I'll keep the signature-verification part short and spend the time on the more interesting question: once the event is in your hands, how does it break?

Why "200 OK but still wrong" happens

The premise to internalize is that Stripe delivers webhooks at least once. That means the same event can arrive more than once. Network jitter, a slow response from your side, a retry on Stripe's side — for any of these reasons, checkout.session.completed can show up twice. It's infrequent, but it absolutely happens.

If your handler assumes "process whatever arrives, every time," the second delivery grants access again or re-runs the subscription-start logic. Without idempotency, that second run is recorded as a perfectly normal success — two 200 OK lines, no error in sight. That's the real identity of most "everything looks right but it's wrong" cases.

There's a second trap: subscription lifecycle events are not ordered. customer.subscription.updated can arrive before customer.subscription.created. If you treat the payload as the timeline of truth and drive a state machine from it, you'll overwrite current state with stale data.

So stabilization is less about signature verification and more about designing a receiver that is resilient to duplicates, to reordering, and to delay.

Layer 1: Verify the signature with the async API on Workers

You still need verification as a foundation. There's one Cloudflare-specific gotcha worth a paragraph. The synchronous constructEvent() used in Node doesn't work in the Workers Web Crypto environment. Switch to the async variant, and always take the body as raw text.

// src/app/api/webhook/route.ts
import Stripe from 'stripe';
import { NextRequest, NextResponse } from 'next/server';
 
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
 
export async function POST(req: NextRequest) {
  // text(), not json(). Parsing changes the bytes and verification will always fail.
  const body = await req.text();
  const signature = req.headers.get('stripe-signature') ?? '';
 
  let event: Stripe.Event;
  try {
    const cryptoProvider = Stripe.createSubtleCryptoProvider();
    event = await stripe.webhooks.constructEventAsync(
      body,
      signature,
      process.env.STRIPE_WEBHOOK_SECRET!,
      undefined,
      cryptoProvider,
    );
  } catch (err) {
    // A bad signature is a config mistake, not a transient fault. 400 = don't retry.
    console.error('signature verification failed:', err);
    return NextResponse.json({ error: 'invalid signature' }, { status: 400 });
  }
 
  return handleEvent(event);
}

The thing to remember is to return 400 when verification fails. A signature mismatch is a configuration problem, not a network glitch, so retrying won't fix it. Conversely, for the transient processing failures below, you return 500 to make Stripe retry. This distinction — when to invite a retry and when to give up — is the starting point for retry-storm prevention.

✦

Thank you for reading this far.

Continue Reading

What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.

WHAT YOU'LL LEARN
✦A concrete KV idempotency pattern keyed on event.id that stops double-granting caused by Stripe's at-least-once retries
✦How to switch to a fast-2xx-then-process handler so slow work never triggers Stripe's retry storm
✦An operational pattern that stops trusting subscription event payloads and reconciles against a fresh retrieve instead
Secure payment via Stripe · Cancel anytime
✦

Unlock This Article

Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.

or
Unlock all articles with Membership →
Share

Thank You for Reading

Rork Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • ✦Copy-paste ready implementation code
  • ✦New advanced guides published daily
  • ✦$5/mo or $10 for lifetime access
View Membership →

Related Articles

⬡ Dev Tools2026-04-26
Usage-Based Billing for Rork AI Apps with Stripe Meter — Charge by API Calls and Tokens, Not Flat Rates
Implement usage-based billing in your Rork AI app with Stripe Meter — meter setup, Cloudflare Workers reporting, retry queues, event aggregation, and how to choose between metered billing and prepaid credits.
⬡ Dev Tools2026-10-20
Why Redeploying Doesn't Fix the Application Error You See Right After a Deploy
Right after I swapped a secret, every page showed Application error. Stale HTML was calling a JS file that no longer existed. Here are a detection script, a build-version check for the edge cache, and a once-only auto-reload, all run and verified.
⬡ Dev Tools2026-05-09
Building a Rork A/B Testing Platform with GrowthBook and PostHog from Scratch
Drop Firebase and pair GrowthBook with PostHog to ship a production-grade A/B testing stack for your Rork apps. SDK wiring, edge evaluation on Cloudflare Workers, Bayesian statistics interpretation, and the operational pitfalls I learned the hard way.