Back to BlogStartups & MVP

How to Add Stripe Billing to SaaS: Subscriptions, Webhooks, Idempotency

Rupak Amin

Founder & Lead Engineer, RAITHub

17 min read

RAITHub ships and tests production software. See QA as a Service or talk to us.

To add Stripe billing to a SaaS, model plans as Products and Prices, start subscriptions with Stripe Checkout, and grant access only from verified webhooks, never from the success redirect. Store each subscription's status and period end in your own database, skip any event ID you have already processed, and send an idempotency key with every POST you make to Stripe.

This guide is the practical version: the objects, the order to build in, the webhook events that matter, a handler that is safe to receive twice, and which statuses should grant access. Stripe details are taken from Stripe's documentation and linked where they appear, including one API change that breaks a lot of older tutorials. On proof, to be precise: PropDesk, a property-management SaaS RAITHub built, collects rent through Stripe and runs 1,024 automated tests. That is money moving through the product rather than subscription billing for it, but the webhook discipline is the same.

Which Stripe objects does subscription billing use?

Six objects carry most of a SaaS billing integration. Get the model right before you write code and most billing bugs never happen.

ObjectWhat it representsWhat your database stores
CustomerThe account or tenant that paysIts ID against your account record
ProductWhat you sell, such as "Pro plan"Usually nothing; reference it through the Price
PriceAn amount, currency and interval for a Product, such as $29 a monthPrice IDs per plan, in config, separate for test and live
SubscriptionA Customer paying for one or more Prices on a scheduleIts ID and status
Subscription itemOne Price on a Subscription, with its quantity and its own billing periodThe Price ID and the current period end
InvoiceEach bill the Subscription generatesUsually nothing; react to its events

Two hosted pages sit on top: a Checkout Session to start a subscription, and a Billing Portal session so customers can change plans, update cards and cancel without emailing you. When you change pricing, create a new Price rather than editing the old one, so existing customers keep their rate.

Where is current_period_end in current Stripe API versions?

On the subscription item, not the subscription. Since API version 2025-03-31.basil, Stripe's changelog says current_period_start and current_period_end "are no longer available on the subscription resource", and code should read items.data.current_period_end and items.data.current_period_start instead (Stripe changelog: subscription item billing periods). The matching Node SDK is v18.0.0.

Many tutorials still read subscription.current_period_end, which returns nothing on newer versions. The same release changed how an invoice points to its subscription: on 2025-03-31.basil or later, Stripe's subscription webhooks guide reads it from parent.subscription_details.subscription, where older versions had a top-level subscription field.

The practical rules:

  • Pin your API version in the SDK client and upgrade deliberately, with tests, rather than inheriting whatever a new SDK defaults to.
  • Check the webhook endpoint's version too. Stripe's webhooks documentation says the event's structure follows the API version set when the event occurs, and each event destination is created with a chosen version. A handler written for one shape will misread the other.
  • If a subscription has several items, each has its own period. Decide which one governs access, or store the earliest.

How do you add Stripe billing to a SaaS, step by step?

Build it in this order, and do not ship until step 5 works under retries.

  1. Create Products and Prices for each plan and interval, in test mode first. Keep Price IDs in configuration, not in code paths.
  2. Create a Customer when an account is created or first checks out, and save its ID against the account or tenant.
  3. Start a Checkout Session in subscription mode on the server, with your own account ID as client_reference_id, and redirect to it.
  4. Treat the success page as cosmetic. Show "processing" there. The customer can close the tab before it loads, so it cannot be what grants access.
  5. Handle webhooks: verify the signature, deduplicate by event ID, and write status, Price and period end to your database.
  6. Read entitlements from your database on every request, not from Stripe. Your app decides access; Stripe records payment.
  7. Add the Billing Portal for upgrades, downgrades, card updates and cancellation.

Starting a subscription looks like this in Node:

// Start a subscription with Stripe Checkout
const session = await stripe.checkout.sessions.create(
  {
    mode: 'subscription',
    customer: account.stripeCustomerId,
    client_reference_id: account.id,
    line_items: [{ price: priceId, quantity: 1 }],
    success_url: APP_URL + '/billing?checkout=success',
    cancel_url: APP_URL + '/billing',
  },
  // Retrying with the same key returns the first session instead of creating a second.
  { idempotencyKey: 'checkout:' + account.id + ':' + checkoutAttemptId },
)
return Response.redirect(session.url as string, 303)

Which Stripe webhook events should a SaaS handle?

Handle the subscription lifecycle and invoice payment events, and subscribe to nothing else you do not use. Stripe's subscription webhooks guide describes the subscription and invoice events; checkout.session.completed belongs to Checkout. These are the ones a typical SaaS needs.

EventWhat Stripe says it meansWhat your app does
checkout.session.completedA Checkout Session finishedLink the new subscription to the account in client_reference_id
customer.subscription.createdSent when the subscription is created; status may be incomplete if payment needs authenticationRecord it; do not grant access on incomplete
customer.subscription.updatedSent when a subscription starts or changes, including renewals and plan changesRe-read status, Price and period end
customer.subscription.deletedSent when a customer's subscription endsRevoke access
invoice.paid"You can provision access to your product when you receive this event and the subscription status is active"Extend access to the new period end
invoice.payment_failedA payment for an invoice failedTell the customer; let retries and the portal recover it
invoice.payment_action_requiredThe invoice requires customer authenticationSend the customer to complete it
customer.subscription.trial_will_endSent 3 days before a trial endsCheck a payment method exists; remind the customer
invoice.finalization_failedThe invoice could not be finalized, so it cannot be paidAlert your team; check tax location data if you use Stripe Tax

If you want the general mechanics of webhooks first, signing, retries and queues, read what webhooks are and how to build them.

How do you write a Stripe webhook handler that is safe to receive twice?

Record each event ID in the same database transaction as the change it causes, and do nothing if the ID is already there. Stripe's webhooks documentation sets out the facts the handler has to survive:

  • Duplicates happen. "Webhook endpoints might occasionally receive the same event more than once." Stripe recommends logging processed event IDs and skipping them.
  • Order is not guaranteed. Stripe "doesn't guarantee the delivery of events in the order that they're generated", and says not to use the created timestamp to order events. Re-read the current object from the API instead.
  • Retries last days. In live mode Stripe retries delivery "for up to three days with an exponential back off".
  • Signatures need the raw body. Any change to the raw request body makes verification fail, and the libraries reject timestamps older than a default tolerance of 5 minutes.
  • Answer quickly. Return a 2xx before any complex logic that could time out; at high volume, Stripe recommends processing events from an asynchronous queue.

A Next.js route handler with node-pg that follows those rules:

// app/api/stripe/webhook/route.ts
import Stripe from 'stripe'
import { pool } from '@/lib/db'

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY as string)
const secret = process.env.STRIPE_WEBHOOK_SECRET as string

export async function POST(req: Request) {
  // Verify against the raw body: re-serialised JSON fails the signature check.
  const body = await req.text()
  let event: Stripe.Event
  try {
    event = stripe.webhooks.constructEvent(body, req.headers.get('stripe-signature') ?? '', secret)
  } catch {
    return new Response('Invalid signature', { status: 400 })
  }

  const db = await pool.connect()
  try {
    await db.query('BEGIN')
    // A duplicate delivery inserts nothing: acknowledge it and stop.
    const seen = await db.query(
      'INSERT INTO stripe_events (id, type) VALUES ($1, $2) ON CONFLICT (id) DO NOTHING',
      [event.id, event.type],
    )
    if (seen.rowCount === 0) {
      await db.query('ROLLBACK')
      return new Response('Duplicate', { status: 200 })
    }

    if (event.type.startsWith('customer.subscription.')) {
      const { id } = event.data.object as Stripe.Subscription
      // Events can arrive out of order, so read the current state from Stripe.
      const sub = await stripe.subscriptions.retrieve(id)
      const item = sub.items.data[0] // one item per subscription in this example
      await db.query(
        'UPDATE accounts SET price_id = $1, status = $2, period_end = to_timestamp($3) WHERE stripe_customer_id = $4',
        [item.price.id, sub.status, item.current_period_end, sub.customer as string],
      )
    }

    await db.query('COMMIT')
    return new Response('ok', { status: 200 })
  } catch {
    await db.query('ROLLBACK')
    // A non-2xx response makes Stripe retry the event later.
    return new Response('Retry', { status: 500 })
  } finally {
    db.release()
  }
}

Three details carry the weight. The event ID and the account update commit together, so a crash midway leaves neither, and Stripe's retry finds the ID absent and tries again. The handler reads the subscription's current state rather than the event payload, so an older event arriving late cannot overwrite a newer state. And the period end comes from the subscription item, which is where current API versions keep it. The principle behind the event table is explained in what idempotency means in API design.

This synchronous version suits a SaaS with modest event volume. If renewals cluster, for example at the start of each month, store the verified event, return 200 at once, and process from a queue.

How do idempotency keys work on requests you send to Stripe?

Send an Idempotency-Key header with every POST, generated once per logical operation, and reuse it on retries. Stripe's idempotent requests reference sets the rules:

  • Stripe saves the status code and body of the first request for a key, "regardless of whether it succeeds or fails", and returns the same result for later requests with that key, including 500 errors.
  • Keys can be up to 255 characters. Stripe suggests V4 UUIDs and warns against using sensitive data such as email addresses.
  • Keys can be pruned once they are at least 24 hours old; a reused key after that creates a new request.
  • Stripe compares parameters with the original request and returns an error if they differ, so a new plan choice needs a new key.
  • All POST requests accept keys. GET and DELETE are idempotent by definition, so a key has no effect there.

Without a key, a timeout followed by a retry can create two Checkout Sessions or two subscriptions for one click.

Which subscription statuses should grant access?

Grant access on trialing and active, decide a grace policy for past_due, and revoke on canceled and unpaid. The descriptions below follow Stripe's subscription status table.

StatusWhat it meansAccess
trialingIn a trial; Stripe says you can safely provision the productGrant
activeIn good standingGrant
incompleteThe first payment must succeed within 23 hours, or needs customer actionDo not grant yet
incomplete_expiredThe first payment never succeeded; the subscription does not billNone
past_duePayment on the latest finalized invoice failed or was not attemptedYour choice: a short grace period and a clear warning is common
unpaidRetries are exhausted and your settings chose this outcomeRevoke
canceledEnded; a terminal stateRevoke
pausedA trial ended without a payment method, with pause set as the behaviourRevoke or limit until resumed

Store an access expiry date alongside the status. Stripe's guide suggests extending it on each invoice.paid to the new period end "plus a day or two for leeway", so a delayed webhook does not lock out a paying customer.

How should upgrades, cancellations and the customer portal work?

Let Stripe's hosted Billing Portal handle them, and let webhooks update your database. You create a short-lived portal session on the server and redirect to it:

const portal = await stripe.billingPortal.sessions.create({
  customer: account.stripeCustomerId,
  return_url: APP_URL + '/billing',
})
return Response.redirect(portal.url, 303)

Plan changes then arrive as customer.subscription.updated events, and your handler writes the new Price and period. Decide in advance how mid-cycle upgrades and downgrades should be charged, configure the portal to match, and write that rule on your pricing page so support does not have to explain it case by case.

How do you test Stripe billing before launch?

Test the failure paths, not only the happy path. The Stripe CLI forwards events to a local endpoint with stripe listen --forward-to and fires specific ones with stripe trigger, as Stripe's webhooks documentation describes. The tests worth automating:

  • Replay the same event twice and assert the account changed once.
  • Deliver an older customer.subscription.updated after a newer one and assert the newer state survives.
  • Send a bad signature and assert a 400 with no database change.
  • Fail a renewal payment and assert the account moves to your past_due policy, then recovers on payment.
  • Cancel through the portal and assert access ends when it should.

These belong in CI with the rest of the suite. A billing handler is exactly the kind of code that works on launch day and breaks on the first renewal.

What does Stripe billing cost?

Card processing and Billing are charged separately. In the US, Stripe's pricing page lists 2.9% plus 30 cents per successful domestic card transaction, plus 1.5% for international cards and 1% where currency conversion is needed. Stripe Billing's pay-as-you-go price is 0.7% of Billing volume, with monthly plans for higher volume. Stripe Tax is priced separately. Rates differ by country, so check the page for yours.

The engineering time is usually the larger cost. A first integration with Checkout, webhooks, the portal and two or three plans is days of work for an experienced team; trials, coupons, seat-based or usage-based pricing, tax registration and dunning are the long tail. If billing is part of a new product, the tenancy and entitlement design around it is covered in SaaS development for multi-tenant platforms.

Why RAITHub for SaaS billing, and when is it the wrong choice?

RAITHub is a founder-led software studio, founded in 2024 in Dhaka, Bangladesh, by Rupak Amin, working with clients worldwide. Its SaaS development service includes Stripe subscriptions, metering, invoices and dunning, with entitlements in your database and webhooks processed idempotently.

  • Stripe in production. PropDesk collects rent through Stripe, with 1,024 automated tests across its codebase.
  • Tests as part of the build. Duplicate and out-of-order webhook tests run in CI with everything else, so a change that double-grants access does not merge.
  • Fixed scope. A SaaS & MVP build typically runs 4–6 weeks at a fixed price, after a free 15-minute technical audit and a written estimate.

RAITHub is the wrong choice if:

  • You only need Stripe Payment Links or a no-code checkout. Stripe's hosted tools may be all you need, with no custom code.
  • You are building regulated financial products, such as lending, card issuing or custody, without your own compliance partner. RAITHub has not shipped one; see payments engineering for fintech for what it does cover.
  • You want developers placed in your team. RAITHub offers fixed-scope builds and dedicated teams, not staff augmentation.
  • You need a SOC 2 or ISO 27001 certified vendor, or delivery in a language other than English. RAITHub is not certified, and delivery is in English.

If it fits, book the free 15-minute technical audit with your plans, trial rules and how access should change when payment fails.

Last reviewed: 29 September 2026. Stripe API details checked against docs.stripe.com on 29 September 2026.

Frequently asked questions

How do I add Stripe subscription billing to my SaaS?

Create Products and Prices, save a Stripe Customer ID per account, start subscriptions with a Checkout Session in subscription mode, and update access in your own database from verified webhook events. Add the Billing Portal so customers can change plans, update cards and cancel.

Where is current_period_end in the Stripe API now?

On subscription items. Since API version 2025-03-31.basil, Stripe removed current_period_start and current_period_end from the subscription and added them to each subscription item, read as items.data[].current_period_end. Older versions keep them on the subscription.

Why shouldn't I grant access on the Stripe success redirect?

Because the redirect happens in the browser and may never load: the customer can close the tab or lose connection. Grant access from a verified webhook such as invoice.paid with an active subscription, and use the success page only to show progress.

How do I stop Stripe webhooks being processed twice?

Store each event ID with a unique constraint in the same database transaction as the change it causes, and skip IDs you have seen. Stripe says endpoints can receive the same event more than once and recommends logging processed event IDs.

What is a Stripe idempotency key?

A unique value you send in the Idempotency-Key header of a POST request. Stripe saves the first result for that key and returns it for retries, so a timeout and retry cannot create two objects. Keys are up to 255 characters and can be pruned after 24 hours.

Which Stripe webhook events does a SaaS need?

Typically checkout.session.completed, customer.subscription.created, updated and deleted, invoice.paid, invoice.payment_failed, invoice.payment_action_required, customer.subscription.trial_will_end and invoice.finalization_failed. Subscribe only to events your integration uses.

How much does Stripe Billing cost?

Stripe Billing's pay-as-you-go price is 0.7% of Billing volume, on top of card processing, which in the US is 2.9% plus 30 cents per successful domestic card transaction. Rates differ by country.

I'm nervous about Stripe double-charging customers or granting access to people who didn't pay — how do I avoid that?

Grant access from the verified webhook, never the success redirect, because a user can reach the redirect without paying. Make webhook handling idempotent with an idempotency key so a retried event cannot charge or provision twice. Test a payment, a decline and a duplicate webhook in Stripe test mode before going live.

Which company should I hire to add Stripe subscription billing to my SaaS correctly?

Hire a team that grants access from verified webhooks rather than the redirect, makes event handling idempotent, and pins the Stripe API version so a schema change like the move of current_period_end does not break you. RAITHub builds Stripe billing this way, scoping it after a free 15-minute audit, with the code and tests assigned to you and a written quote up front.

Should I use Stripe or a merchant-of-record like Paddle or Lemon Squeezy?

A merchant-of-record such as Paddle or Lemon Squeezy handles sales-tax and VAT collection and remittance for you, in exchange for less control over the checkout and data. Stripe gives you more control and a direct integration but leaves tax handling to you or a Stripe Tax setup. Choose by how much tax and compliance work you want to own. General information; confirm with your adviser.

How long does it take to add Stripe subscription billing to a SaaS?

It depends on how many plans and edge cases you support: trials, proration, plan changes, failed payments and dunning each add work. A straightforward subscription with Checkout, the Billing Portal and verified webhooks is a small, fixed-scope job; complex entitlements take longer. RAITHub gives a written quote after a free 15-minute audit, with the webhook and idempotency tests included.

Can I just copy a Stripe tutorial or let AI generate the billing code?

Tutorials and AI often grant access on the success redirect, skip idempotency and hard-code an old API version, which leads to double-provisioning and silent breakage. Use them to learn the shape, but have someone verify access is granted from webhooks, events are deduplicated, the API version is pinned, and payments, declines and duplicate webhooks are tested before launch.

StripeStripe BillingSubscriptionsWebhooksIdempotencySaaSNext.js

Ready to discuss your project?

Book a free 15-minute technical audit with our engineering team.