Stripe’s documentation is good. The getting started guides work. You can get a basic Checkout flow running in an afternoon.

The gap between “Checkout works” and “billing survives normal SaaS edge cases” is where I’ve spent the most debugging time across my SaaS apps.

Here’s what I learned the hard way while building billing into MyOG.social, TheBlue.social, and Stacknaut.

Webhooks are the source of truth

The main rule: your database should reflect Stripe’s state, not the other way around. When a user completes checkout, don’t update their subscription status from the success callback. Wait for the webhook.

The Checkout Session success_url is a UI signal — “show the user a thank-you page.” The subscription creation and payment state come from Stripe events. If you update the database from the return page, you’ll eventually hit a race where the webhook arrives before or after your page handler and your data disagrees with Stripe.

// DON'T do this in your success page handler
await db.update(users).set({ plan: 'pro' }).where(eq(users.id, userId));

// DO handle it from the webhook
app.post('/webhooks/stripe', {
  config: { rawBody: true },
}, async (req, res) => {
  const event = stripe.webhooks.constructEvent(
    req.rawBody,
    req.headers['stripe-signature'],
    WEBHOOK_SECRET
  );

  switch (event.type) {
    case 'checkout.session.completed':
      const session = event.data.object;
      await activateSubscription(session.customer, session.subscription);
      break;
  }
});

One gotcha: constructEvent needs the raw request body — not the parsed JSON object. If your framework parses the body before you get to it, signature verification fails.

In Express, use express.raw({ type: 'application/json' }) for the webhook route. In Fastify, register raw body support and enable it for the route. The exact setup depends on your Fastify version and plugin, but the important bit is the same: pass Stripe the original bytes.

The webhook events that matter

Stripe fires dozens of event types. You don’t need to handle all of them. For a typical SaaS with subscriptions, these are the ones that actually matter:

  • checkout.session.completed — user finished checkout. Create/activate their subscription in your database.
  • customer.subscription.updated — plan changed, billing cycle renewed, or status changed. Sync the new state.
  • customer.subscription.deleted — subscription ended. Downgrade the user.
  • invoice.payment_failed — payment didn’t go through. Don’t immediately revoke access — Stripe retries. But notify the user.
  • invoice.paid / invoice.payment_succeeded — payment succeeded. Good for logging and notifications.
  • checkout.session.expired — user started checkout but didn’t finish. Useful for abandonment emails or analytics.

Everything else is noise for most SaaS apps. I started by trying to handle every event type. Now I handle these and ignore the rest until I need them.

Webhook order is not guaranteed

This one bit me in TheBlue.social.

I assumed the subscription flow would be:

  • checkout.session.completed
  • invoice.paid / invoice.payment_succeeded

Then I saw the reverse happen.

Stripe says not to depend on event ordering, and they mean it. If the invoice event arrives before your checkout.session.completed handler has recorded the Stripe customer ID, your invoice handler might not find a user yet.

Don’t make that an error unless it really is one. Log it with enough context and make the handler tolerant.

const user = await findUserByStripeCustomerID(stripeCustomerID);
if (!user) {
  logger.warn({ stripeCustomerID, eventID: event.id }, "No user found yet");
  return;
}

Each handler should be able to fetch what it needs. If the invoice handler needs the subscription or customer, retrieve it from Stripe instead of assuming another webhook already filled your database.

Also log the event type everywhere. I added stripeEventType to every Stripe webhook log entry because duplicate-looking logs are much easier to read when you can tell whether they came from checkout.session.completed, an invoice event, or customer.subscription.updated.

Failed payments and the grace period

When a payment fails, Stripe doesn’t cancel the subscription immediately if retries are enabled. It enters a retry cycle — Stripe calls this Smart Retries.

The retry policy is configurable. Stripe’s current recommended Smart Retries default is 8 tries within 2 weeks, but don’t hard-code that into your app. Check your Billing settings and use the subscription status.

During this time, the subscription status is past_due. The question is: do you revoke access immediately, or do you give the user a grace period?

I give a grace period. Here’s why: most failed payments are expired cards, not people trying to steal your service. If you cut access immediately on the first failure, you’ll frustrate paying customers who just need to update their card.

My approach:

function hasActiveAccess(subscription: { status: string }) {
  if (subscription.status === 'active') return true;
  if (subscription.status === 'past_due') return true; // grace period
  if (subscription.status === 'trialing') return true;
  return false;
}

I show a banner for past_due users: “Your payment failed. Please update your payment method.” But they keep full access until Stripe exhausts its retries and applies the behavior I configured — usually canceling the subscription or marking it unpaid.

Test webhooks through a tunnel

I test Stripe webhooks through Cloudflare Tunnel, not stripe listen forwarding to localhost.

I want to test the same flow I use in development. Stripe calls a real HTTPS URL, my app sees the same host it sees in normal browser testing, and I don’t have to swap webhook secrets just because one terminal is running a forwarding command.

I wrote about my Cloudflare Tunnel setup here: Configuring Cloudflare Tunnel to Expose Servers for Local Development, Webhooks etc.

My dev setup looks like this:

tunnel: dev
credentials-file: /Users/you/.cloudflared/<tunnel-id>.json

ingress:
  - hostname: dev-myapp.example.com
    service: http://localhost:5173
  - hostname: dev-myapp-backend.example.com
    service: http://localhost:3000
  - service: http_status:404

Then the Stripe webhook endpoint for development is:

https://dev-myapp-backend.example.com/webhooks/stripe

That endpoint gets its own signing secret in the Stripe Dashboard. I put that in the local backend env as STRIPE_SIGNING_SECRET.

This is better than the CLI + localhost trick because it tests the same path a real user takes: browser to Stripe, Stripe back to the dev app, Stripe webhook to the dev backend.

It catches bugs the CLI path hides: wrong callback URLs, wrong cookies, wrong CORS assumptions, wrong environment config, and anything else that depends on the request host being a real dev domain.

Keep this in Stripe test mode. Don’t mix local testing with live events unless you are deliberately testing live mode and know why.

For real subscription scenarios like “this renews after 30 days,” use Stripe’s test clocks feature.

# Create a test clock for simulating time
curl https://api.stripe.com/v1/test_helpers/test_clocks \
  -u "$STRIPE_SECRET_KEY:" \
  -d frozen_time=$(date -u +%s) \
  -d name="Monthly renewal test"

# Advance time to trigger renewal on macOS
curl https://api.stripe.com/v1/test_helpers/test_clocks/clock_xxx/advance \
  -u "$STRIPE_SECRET_KEY:" \
  -d frozen_time=$(date -v+32d +%s)

Test clocks let you fast-forward time. Create a customer attached to a test clock, subscribe them, then advance the clock past the billing period. Stripe advances the clock asynchronously, then sends the billing events. Without test clocks, you’re waiting 30 actual days to test renewal flows.

The customer portal saves weeks

Stripe’s Customer Portal is a hosted page where users can manage their subscription — update payment method, change plans, cancel, view invoices. It’s pre-built and handles all the edge cases.

const portalSession = await stripe.billingPortal.sessions.create({
  customer: stripeCustomerId,
  return_url: 'https://myapp.com/settings',
});

// Send user to portalSession.url

Before I used this properly, I was building custom UI for plan changes, card updates, and cancellation flows. That’s weeks of work handling edge cases that Stripe already handles. The portal isn’t perfect — it’s Stripe-branded and the customization options are limited — but it covers most billing management needs.

You configure what users can do in the portal in the Stripe Dashboard: which plans they can switch to, whether they can cancel immediately or at period end, whether subscription updates prorate, and whether downgrades are scheduled for the end of the billing period. Configure it once and let Stripe own that flow.

One small detail: generate the portal session when the user clicks the button. Don’t pre-generate it at login and store the URL in your frontend state. Stripe portal sessions are short-lived. I made that mistake in TheBlue.social and had to move portal URL creation behind a /stripe/billing-portal-url endpoint.

Proration is more confusing than it should be

When a user upgrades mid-cycle, Stripe prorates by default. They get credit for the unused portion of their current plan and are charged the difference for the new plan.

This is fine in theory. In practice, it creates confusing invoices. A user upgrades from $19/month to $49/month halfway through the cycle and sees a charge around $15 — the prorated difference. They email you asking why they weren’t charged $49.

My approach: I configure upgrades to prorate (user gets credit for the old plan) but downgrades to take effect at the end of the billing cycle (no proration, no refund). This matches user expectations — you get the upgrade immediately, and a downgrade means “next month I’ll pay less.”

// Upgrade now and let Stripe create the prorations.
await stripe.subscriptions.update(subscriptionId, {
  items: [{ id: subscriptionItemId, price: newPriceId }],
  proration_behavior: 'create_prorations',
});

For downgrades at renewal, don’t just set proration_behavior: 'none'. That changes the subscription immediately without creating prorations. Use the Customer Portal’s scheduled downgrade setting, or use a subscription schedule if you are doing it through the API.

One-time purchases vs subscriptions

Stacknaut uses one-time purchases (Checkout Sessions in payment mode), not subscriptions. Different model, different webhook events. For one-time purchases:

  • Use checkout.session.completed — that’s your signal to deliver the product
  • Store the Checkout Session ID and Payment Intent ID for reference
  • There are no renewal events, no subscription lifecycle to manage
const session = await stripe.checkout.sessions.create({
  mode: 'payment', // not 'subscription'
  line_items: [{ price: priceId, quantity: 1 }],
  success_url: 'https://myapp.com/success?session_id={CHECKOUT_SESSION_ID}',
  cancel_url: 'https://myapp.com/pricing',
});

If you’re selling a starter kit, a template, or any digital product — one-time purchase is simpler. No subscription lifecycle, no failed payment handling, no churn. The tradeoff is obvious: no recurring revenue.

Validate price IDs in both directions

Once you have more than one Stripe product, price IDs become a normal source of bugs.

TheBlue.social has subscriptions, one-time purchases, and experiments that came and went. I eventually added an isKnownPriceID() check in two places:

  • when creating a Checkout Session
  • when processing Stripe webhooks

The first one blocks bad frontend config before the user reaches Stripe. The second one protects your webhook handler when you create a new payment link or product in Stripe that your app should ignore.

function isKnownPriceID(priceID: string) {
  return [
    STRIPE_WEEKLY_PRICE_ID,
    STRIPE_MONTHLY_PRICE_ID,
    STRIPE_YEARLY_PRICE_ID,
    STRIPE_LIFETIME_PRICE_ID,
  ].includes(priceID);
}

If an unknown price ID shows up, log it and skip your subscription logic. Don’t let a payment link for one product accidentally grant access to another.

Idempotency in webhook handlers

Stripe can send the same webhook event more than once. Your handlers must be idempotent — processing the same event twice should produce the same result.

case 'checkout.session.completed': {
  const session = event.data.object;
  
  // Check if we already processed this
  const existing = await db.query.orders.findFirst({
    where: eq(orders.stripeSessionId, session.id)
  });
  if (existing) break; // already processed
  
  await createOrder(session);
  break;
}

Store the event ID or a relevant Stripe object ID and check before processing. Without this, a retried webhook can double-create subscriptions, send duplicate welcome emails, or grant double credits.

If you grant credits, make the credit grant idempotent too. I use a request ID based on the Stripe object, like checkout:cs_... or invoice:in_..., and let the database ignore duplicates. The webhook handler can run twice; the ledger should not double-grant.

Store more Stripe data than you think you need

I store the full Stripe customer ID, customer email, price ID, current period start/end, and subscription status in my database. If you use subscriptions heavily, store the subscription ID too. Early on, I stored just the customer ID and fetched everything else from the Stripe API on demand. That was a mistake.

API calls to Stripe add latency and can fail. Having the subscription state in your database means your app works even if Stripe is having a bad day. Sync the state via webhooks and treat your database as a read-optimized cache of Stripe’s data.

Also put your own user ID in Checkout metadata.

const session = await stripe.checkout.sessions.create({
  mode: 'subscription',
  line_items: [{ price: priceId, quantity: 1 }],
  success_url: 'https://myapp.com/success?session_id={CHECKOUT_SESSION_ID}',
  cancel_url: 'https://myapp.com/pricing',
  metadata: {
    customerID: user.customerID,
  },
});

The Stripe customer email is useful for support and logs, but I don’t want it to be the only join key. Emails can change. Your internal user ID is the thing you control.

The success page should wait for your backend

The success URL is not proof that your app has processed the subscription yet.

In TheBlue.social, the pricing page checks the backend a few times after ?subscriptionSuccess=true because the webhook might not have fired or finished by the time the user returns from Stripe. Only then does it show the paid-state UI.

for (let i = 0; i < 2; i++) {
  await new Promise((resolve) => setTimeout(resolve, 2000));
  await refreshUserFromBackend();
  if (user.hasPaidSubscription) {
    showConfetti();
    break;
  }
}

Also remove the success query parameter after handling it. Otherwise a refresh can fire duplicate analytics events or show success UI again.

The development vs production split

Use Stripe’s test mode for development. This seems obvious, but I’ve seen developers accidentally create real charges during testing.

  • Test API keys start with sk_test_ and pk_test_
  • Production keys start with sk_live_ and pk_live_
  • Test mode has its own customers, subscriptions, and webhooks
  • Use Stripe’s test card numbers: 4242424242424242 for success, 4000000000000341 for decline

Keep your webhook endpoints separate too. Your staging environment should have its own webhook endpoint pointing to a staging URL, not your production endpoint. I’ve seen webhook events from staging corrupt production subscription states.

Set up separate webhook endpoints in the Stripe Dashboard for each environment. Each gets its own signing secret.