●TOOLING — Rork's developer repos keep moving: rork-xcode was updated on July 16, rork-device on July 15, and rork-plist on July 13●OPUS46 — Claude Opus 4.6 is live in Rork, and Rork Max is built to assemble apps on top of Claude Code●SIM — A cloud iOS simulator runs in the browser, with one click to install on a device and two clicks to publish to the App Store●MAX — Rork Max emits pure Swift rather than React Native, reaching iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and even iMessage●NATIVE — That opens up HealthKit, ARKit and LiDAR, NFC, Dynamic Island, Live Activities, 3D through Metal, and on-device inference with Core ML●SEED — Rork raised a $15M seed led by Left Lane Capital, with Peak XV and a16z Speedrun joining the round●TOOLING — Rork's developer repos keep moving: rork-xcode was updated on July 16, rork-device on July 15, and rork-plist on July 13●OPUS46 — Claude Opus 4.6 is live in Rork, and Rork Max is built to assemble apps on top of Claude Code●SIM — A cloud iOS simulator runs in the browser, with one click to install on a device and two clicks to publish to the App Store●MAX — Rork Max emits pure Swift rather than React Native, reaching iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and even iMessage●NATIVE — That opens up HealthKit, ARKit and LiDAR, NFC, Dynamic Island, Live Activities, 3D through Metal, and on-device inference with Core ML●SEED — Rork raised a $15M seed led by Left Lane Capital, with Peak XV and a16z Speedrun joining the round
Taking Rork Max × Stripe Subscriptions to Production — Webhook Idempotency, State Transitions, and Reconciliation
Beyond the basic setup: the Stripe subscription failures that only surface in production—duplicate webhooks, out-of-order events, subscription state machines, and keeping your database in sync with Stripe—each with working implementation code.
Everything passed in test mode—then a duplicate-charge email arrived
The scariest part of shipping subscriptions isn't the checkout flow. It's the email that lands a few days after you've ticked every box in test mode: "I was charged twice." Running membership billing on my own projects as an indie developer, this is the lesson that stuck with me hardest. Wiring up the payment flow is straightforward. What's hard is that your code quietly assumes Stripe events arrive exactly once, in order—and neither of those things is guaranteed.
Stripe can deliver the same event more than once. Network timing can land subscription.updated before subscription.created. While you're clicking through test mode by hand, none of this happens. It only happens in production, once real users arrive and retries and timeouts become routine.
This article covers the basic Rork Max + Stripe setup, then focuses on the layer that only breaks in production. If your integration already works, skip ahead to "Making webhooks idempotent."
Prerequisites
Before you start, make sure you have:
A Rork Max account with basic familiarity. New to it? Begin with the Stripe payment integration guide.
A Stripe account with access to your publishable and secret keys.
Comfort with REST APIs and JSON, which you'll need to follow the signature verification and event handling.
A database for user and subscription data—Supabase or Firebase both work (backend guide).
✦
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
✦An idempotency pattern that absorbs duplicate and out-of-order webhook deliveries using an event-ID ledger
✦Handling the full subscription state machine (past_due, incomplete) plus a reconciliation job that treats Stripe as the source of truth
✦Local verification with the Stripe CLI and a break-even table that accounts for processing fees
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.
Before building in Rork Max, set up products, prices, and keys in Stripe.
Create products and prices
Open Products under Catalog
Click Add product, enter a name (e.g., "Pro Plan") and description
Under Add price, set the amount, billing period (monthly), and recurring
When you need to change a price later, don't edit the existing price object—create a new one and switch to it. That keeps the prices your current subscribers reference intact.
API keys and the webhook signing secret
Under Developers → API Keys, copy your publishable and secret keys. Then note the location of the signing secret (it starts with whsec_) in the Webhooks section. That signing secret is what later verifies a request genuinely came from Stripe.
Connecting Rork Max and designing tables
Connect Stripe
In Rork Max, go to Integrations → Payment Services → Stripe, paste your secret key, and connect.
Subscription tables
Store subscription state in your database—but do not make the database your source of truth. The truth always lives in Stripe; your database is a projection of it. Deciding this up front makes the reconciliation logic later far simpler.
-- Supabase exampleCREATE TABLE subscriptions ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, stripe_subscription_id TEXT UNIQUE NOT NULL, stripe_customer_id TEXT NOT NULL, plan_id TEXT NOT NULL, status TEXT NOT NULL, -- 'active','trialing','past_due','canceled','incomplete' current_period_end TIMESTAMP NOT NULL, cancel_at_period_end BOOLEAN DEFAULT FALSE, updated_at TIMESTAMP DEFAULT NOW());-- Event ledger: powers webhook idempotencyCREATE TABLE stripe_events ( event_id TEXT PRIMARY KEY, -- Stripe's evt_xxx type TEXT NOT NULL, processed_at TIMESTAMP DEFAULT NOW());
That stripe_events table is the heart of this article. Making the event ID the primary key lets a database uniqueness constraint reject duplicate deliveries for you.
Making webhooks idempotent
This is the core of running in production. A naively written handler that receives the same invoice.payment_succeeded twice will award points or log a charge twice. The fix is simple in concept: before doing anything, check whether this event ID has already been processed, and if so, return 200 immediately and do nothing.
// Rork Max Webhook Handler (idempotent)// Endpoint: /webhooks/stripeexports.handler = async (event) => { const sig = event.headers['stripe-signature']; // 1. Verify signature against the RAW body (parsed JSON fails) let stripeEvent; try { stripeEvent = stripe.webhooks.constructEvent( event.body, sig, process.env.STRIPE_WEBHOOK_SECRET ); } catch (err) { // Return 400 on failure. A 200 tells Stripe to STOP retrying. return { statusCode: 400, body: `Signature verification failed` }; } // 2. Idempotency guard: already processed -> return 200, do nothing const inserted = await insertEventIfNew(stripeEvent.id, stripeEvent.type); if (!inserted) { return { statusCode: 200, body: JSON.stringify({ duplicate: true }) }; } // 3. Handle by type (side effects only happen here) try { switch (stripeEvent.type) { case 'customer.subscription.created': case 'customer.subscription.updated': await upsertSubscription(stripeEvent.data.object); break; case 'customer.subscription.deleted': await markCanceled(stripeEvent.data.object); break; case 'invoice.payment_failed': await handlePaymentFailed(stripeEvent.data.object); break; } } catch (e) { // On failure, remove from ledger and let Stripe's retry re-deliver await deleteEvent(stripeEvent.id); return { statusCode: 500, body: 'processing failed' }; } return { statusCode: 200, body: JSON.stringify({ received: true }) };};// insertEventIfNew: returns true only when the row is newly insertedasync function insertEventIfNew(id, type) { const res = await db.query( `INSERT INTO stripe_events (event_id, type) VALUES ($1, $2) ON CONFLICT (event_id) DO NOTHING RETURNING event_id`, [id, type] ); return res.rowCount === 1; // 1 = new, 0 = duplicate}
Three decisions live here. Returning 400 on a failed signature matters because a 200 tells Stripe the request was accepted and it stops retrying. Placing the idempotency guard before the side effects keeps duplicates from ever reaching them. And when processing fails, we delete the ledger row and return 500 so Stripe's automatic retry re-processes it. Leave the row in place and return 500, and the next retry is wrongly treated as "already processed" and never runs.
Guard against out-of-order events
subscription.updated can arrive before subscription.created. The defense is to not trust the individual event's payload too much—inside upsertSubscription, always re-fetch the latest state from Stripe. Treat the event as a "something changed" signal and pull the actual values via the API.
async function upsertSubscription(subObject) { // Fetch the latest from Stripe, not the values in the event const sub = await stripe.subscriptions.retrieve(subObject.id); // Period fields moved to the item in newer API versions const periodEnd = sub.items.data[0]?.current_period_end ?? sub.current_period_end; await db.query( `INSERT INTO subscriptions (user_id, stripe_subscription_id, stripe_customer_id, plan_id, status, current_period_end, cancel_at_period_end) VALUES ($1,$2,$3,$4,$5,$6,$7) ON CONFLICT (stripe_subscription_id) DO UPDATE SET status=$5, current_period_end=$6, cancel_at_period_end=$7, updated_at=NOW()`, [sub.metadata.userId, sub.id, sub.customer, sub.items.data[0].price.id, sub.status, new Date(periodEnd * 1000), sub.cancel_at_period_end] );}
Watch how current_period_end is read. As of API version 2025-03-31, Stripe moved current_period_start / current_period_end off the subscription object and onto the subscription item. Copy an older tutorial's code verbatim and you'll get undefined in production, breaking your "next billing date" display. Reading the item first with the body as a fallback keeps you safe even when accounts run mixed API versions.
Handling the subscription state machine
If you decide access with a simple "is status active?" check, the payment-failure path will break you. A Stripe subscription is a state machine, and you need to understand at least these transitions.
Status
Meaning
How your app should treat it
trialing
In free trial
Grant access; show trial end date
active
Billing normally
Grant access
past_due
Payment failed, retrying
Grant a grace window; prompt to update payment
incomplete
Initial payment unfinished
Do not grant yet
canceled
Canceled
Grant until period end, then stop
Concentrate the decision—"is this a state we grant access for?"—in one place. Scatter it and you'll fix one copy and forget another, leaving permissions inconsistent.
const GRANT_STATES = new Set(['trialing', 'active', 'past_due']);function canAccess(subscription) { if (GRANT_STATES.has(subscription.status)) return true; // A scheduled cancellation still has access until period end if (subscription.status === 'canceled' && subscription.current_period_end > Date.now() / 1000) { return true; } return false;}
Not cutting off past_due immediately gives the customer room to update their card. Make this too strict and a temporary decline costs you a good, long-standing subscriber. Pair it with Stripe's billing settings (Smart Retries and dunning emails) and keep access alive through the few days of retries—that's the practical middle ground.
Create, change, and cancel
The basic operations are below. For plan changes, make the proration behavior explicit.
// Change plan (with proration)async function updatePlan(subscriptionId, newPriceId) { const sub = await stripe.subscriptions.retrieve(subscriptionId); return stripe.subscriptions.update(subscriptionId, { items: [{ id: sub.items.data[0].id, price: newPriceId }], proration_behavior: 'create_prorations' });}// Cancel at period end (a scheduled cancel, not an immediate stop)async function cancelAtPeriodEnd(subscriptionId) { return stripe.subscriptions.update(subscriptionId, { cancel_at_period_end: true });}
I'd avoid immediate cancellation (stripe.subscriptions.cancel) as a rule. Users strongly resent losing features mid-period after they've paid, and it adds refund handling on top. Scheduling a period-end cancel with cancel_at_period_end: true—and offering a path to reconsider in the meantime—is gentler on churn, too.
Verify locally with the Stripe CLI
Running your webhook for the first time in production is risky. The Stripe CLI forwards real events to your local server, letting you reproduce duplicates and failure cases.
# Forward events to your local endpoint (prints a signing secret)stripe listen --forward-to localhost:3000/webhooks/stripe# In another terminal, fire any eventstripe trigger invoice.payment_failedstripe trigger customer.subscription.deleted
Set the whsec_ that stripe listen prints into your local environment before testing. The quickest way to test the idempotency guard is to trigger the same event twice and confirm the second returns duplicate: true. Ship only after this, and you'll head off the email from the intro.
Break-even, with fees factored in
Subscriptions are often described as "revenue = retention × price," but what actually lands in your account is heavily shaped by processing fees—and the smaller and more frequent the charge, the more the fee ratio bites. Using a rough domestic-card rate (3.6%), here's the fee and net per plan:
Monthly price
Fee (3.6%)
Net / charge
Charges for ¥1,000 margin
¥500
¥18
¥482
~2.1
¥1,000
¥36
¥964
~1.1
¥3,000
¥108
¥2,892
~0.35
What the table shows is that on low-priced plans, the cost of failed retries and chargebacks erodes profit more than the fee's absolute size does. That's exactly why how you handle past_due and dunning isn't merely a courtesy—it's tied directly to revenue. Sitting with these numbers, I keep concluding that keeping an already-paying user comfortable for as long as possible matters more than building the next feature.
Troubleshooting
Webhooks aren't arriving: Check the registered URL, then read the response codes in Stripe's Webhooks → Events log. A run of 400s points at signature verification; 500s point at your processing code.
Signature verification always fails: Confirm you're verifying against the raw request body, not parsed JSON. If middleware auto-parses to JSON, this single point fails every time.
Access lingers after cancellation: canAccess granting until current_period_end may be correct behavior. If it persists past period end, check whether the subscription.deleted handler is erroring.
Close the last gap with a reconciliation job
However carefully you write your webhooks, a delivery can still be lost entirely. As a final safety net, run a once-a-day reconciliation job that treats Stripe as the truth and compares it against your database. It catches drift—active in Stripe but still past_due in your DB, or the reverse—and corrects it. That single job quietly resolves most of those hard-to-reproduce "permissions are sometimes wrong" bugs.
A subscription isn't something you build and walk away from; it's the work of keeping a quiet system running. Stumble once in production and the meaning of each small decision here really sinks in. If this article spares you even one of those first stumbles, I'll be glad. Thank you for reading.
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.