How to build a Stripe subscription membership site on Webflow

Learn how to gate Webflow content on Stripe subscription state, handling failed payments and mid-period cancellations without locking out members who paid.

How to build a Stripe subscription membership site on Webflow

Ismail Ajagbe
Technical Author
View author profile
Ismail Ajagbe
Technical Author
View author profile
Table of contents

Gate Webflow content on Stripe subscription state, handling failed payments and mid-period cancellations without locking out members who paid.

How to build a subscription membership site with Stripe and Webflow

Launching a subscription site is easy. The trouble is the long tail of states a membership passes through afterward.

Without this foundation, simple features like mid-period cancellations or failed card retries often break because code relies on static flags that drift from Stripe's reality. By syncing subscription data directly from webhooks, your application creates a single source of truth that stays accurate even as membership statuses evolve.

This guide builds the subscription layer on Webflow Cloud so access comes from subscription state, not memory.

What do you need to build a Stripe subscription site on Webflow?

You need a Stripe account with a recurring price, a way to identify signed-in members, and a place to store the subscription state webhooks send you. The account layer matters here more than the billing one.

Here is the list before you take a first payment:

  • A Stripe account with a product and a recurring price, plus a webhook signing secret
  • Authentication already working, so every member maps to a stable user record
  • A Webflow Cloud project running Next.js 15 or higher, with Node.js 22 or later locally
  • A place to persist subscription state, such as the Webflow Cloud SQLite binding, keyed by Stripe customer ID

If you haven't built sign-in yet, our authentication and payments guide covers that foundation, and this one builds on it. Everything below rests on one rule: Stripe owns the truth about billing, and your database keeps a copy it never invents. Here's how.

Which Stripe subscription states should grant access?

Stripe subscriptions move through a defined set of statuses, and entitlement depends on the status plus the period end, not a single flag. Two of these states are where most membership sites get it wrong.

All eight statuses, and what each one should mean for access:

Data table
Subscription status What it means What access should be
incomplete First invoice not paid yet; the customer has 23 hours None, until it becomes active
incomplete_expired Those 23 hours elapsed; the invoice is voided, and this is terminal None; the member has to start a new subscription
trialing Trial running, no payment taken yet Full, for the trial tier
active Paid and current Full
past_due A payment failed, and Stripe is retrying Your call: usually keep access while the retries run
unpaid Retries are exhausted; invoices still generate but payment is not attempted Revoke, which is what Stripe advises for this status
canceled Ended, either immediately or at period end; terminal Revoke, but only once the paid period has actually elapsed
paused A trial ended with no payment method, and end behavior is set to pause None, until a card is added and the subscription resumes

The two rows worth arguing about are past_due and canceled. When a payment fails, Stripe retries according to your Dashboard settings, and the subscription then moves to canceled, moves to unpaid, or stays in past_due, depending on those settings. Cutting access on the first failure locks out members whose card simply expired, turning a recoverable billing event into a cancellation.

The canceled row generates support tickets. A member who cancels mid-period has usually paid through the end, so revoking immediately takes away something they bought. Checking the period end, not the status alone, is what makes it behave that way.

5 steps to build subscription membership with Stripe and Webflow

The build is a Checkout session that names your member, a webhook that records subscription state, an entitlement function everything else calls, the customer portal for self-service, and a reconciliation path for events that never arrive.

Each piece exists to maintain a single source of truth, so start with the one that creates it.

1. Connect the Stripe customer to your user record

Before any money moves, decide how a Stripe customer maps to a member. Create the Stripe customer when the account is created, not at checkout and store its ID on your user row.

This join underpins everything else. Webhooks arrive identified by Stripe customer ID, so without that mapping stored in advance you are left guessing from an email address, which breaks the moment somebody changes theirs.

Pass the customer ID into the Checkout session when the member subscribes rather than letting Stripe create a second customer for the same person. Duplicate customers are the most tedious thing to unpick later, because the subscription is on one record and your mapping points at the other.

You finish this step with every member having exactly one Stripe customer ID stored against them.

2. Record subscription state from a verified webhook

The subscription lifecycle reaches you as events, and those events are the only reliable signal that anything changed. A member upgrading, downgrading, canceling or failing a payment all arrive here.

Verification has one Workers-specific requirement worth knowing before it fails:

// app/api/stripe-webhook/route.ts
import { NextRequest, NextResponse } from 'next/server'
import Stripe from 'stripe'

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY as string, {
  maxNetworkRetries: 2,
})

export async function POST(request: NextRequest) {
  const signature = request.headers.get('stripe-signature')
  if (!signature) {
    return NextResponse.json({ error: 'Unsigned request' }, { status: 400 })
  }

  // Raw text. Parsing first changes the bytes the signature covers.
  const body = await request.text()

  let event: Stripe.Event
  try {
    // Async form: the Workers build uses Web Crypto, which cannot
    // verify synchronously.
    event = await stripe.webhooks.constructEventAsync(
      body,
      signature,
      process.env.STRIPE_WEBHOOK_SECRET as string
    )
  } catch {
    return NextResponse.json({ error: 'Bad signature' }, { status: 400 })
  }

  switch (event.type) {
    case 'customer.subscription.created':
    case 'customer.subscription.updated':
    case 'customer.subscription.deleted': {
      const sub = event.data.object as Stripe.Subscription

      // Store the state, not a boolean. "Is this member allowed in"
      // is a question you answer at read time from these fields.
      await saveSubscriptionState({
        customerId: sub.customer as string,
        status: sub.status,
        // Removed from the subscription resource in 2025-03-31.basil
        // and moved onto the item. cancel_at_period_end stayed put.
        currentPeriodEnd: sub.items.data[0]?.current_period_end ?? null,
        cancelAtPeriodEnd: sub.cancel_at_period_end,
        priceId: sub.items.data[0]?.price.id ?? null,
      })
      break
    }
    default:
      break
  }

  return NextResponse.json({ received: true })
}

Note what you're storing, too. Not a boolean, but the status, period end, cancellation flag and price. Those four fields let you answer any entitlement question later, including ones you have not thought of yet, whereas a boolean throws that information away at the moment it arrives.

The async verification form is required, not stylistic. On the Workers runtime, the Stripe library selects a Web Crypto provider that only operates asynchronously. Hence, the synchronous constructEvent throws instead of verifying, and Stripe's own Cloudflare Worker template uses the async call for the same reason.

Reading the body as raw text matters for the same reason it does everywhere: re-serializing changes the bytes the signature covers.

3. Derive access instead of storing it

With the state recorded, entitlement becomes a pure function. One place answers whether a member is allowed in, and every page and route asks it rather than reimplementing the rule.

The logic is short, and the comments are the interesting part:

// lib/entitlement.ts
type SubscriptionState = {
  status: string
  currentPeriodEnd: number | null // unix seconds, from items.data[0]
  cancelAtPeriodEnd: boolean
}

export function hasAccess(state: SubscriptionState | null): boolean {
  if (!state || state.currentPeriodEnd === null) return false

  const periodStillRunning = state.currentPeriodEnd * 1000 > Date.now()

  switch (state.status) {
    case 'active':
    case 'trialing':
      // A cancellation scheduled for period end is still active today.
      return true

    case 'past_due':
      // Stripe is still retrying. Cutting access here punishes people
      // whose card expired, so keep them in while the retries run.
      return periodStillRunning

    case 'canceled':
      // Cancelled at period end means they paid for this period.
      return periodStillRunning

    default:
      // incomplete, incomplete_expired, unpaid, paused, and later additions.
      return false
  }
}

Stripe does offer a purpose-built primitive for this. When a subscription becomes active, it creates an active entitlement for each feature on the subscribed product, and you can read those instead of deriving access yourself.

It is worth knowing about, and worth skipping here: awkward cases like mid-period cancellations are policy decisions you want visible in your own code rather than inferred.

Keeping this in one function is what makes the policy changeable. If you later decide to give past_due members a shorter grace period, or to keep trialists out of one premium area, you edit a single file rather than auditing every route that ever checked a flag.

The default case matters too. Treating unknown statuses as no access means a status Stripe introduces in future fails closed rather than silently granting entry. After this step, you can explain, for any member, exactly why they can or cannot see a page.

4. Hand billing changes to the customer portal

Members will want to update a card, change plan, download an invoice and cancel. Building those flows takes weeks, and Stripe already hosts them.

Create a portal session and redirect:

// app/api/billing-portal/route.ts
import { NextResponse } from 'next/server'
import Stripe from 'stripe'
import { getSessionCustomerId } from '@/lib/session'

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

export async function POST() {
  // Derive the customer from the signed-in session, never from
  // the request body: an ID taken from the client lets anyone
  // open anyone else's billing portal.
  const customerId = await getSessionCustomerId()

  if (!customerId) {
    return NextResponse.json({ error: 'Not signed in' }, { status: 401 })
  }

  const session = await stripe.billingPortal.sessions.create({
    customer: customerId,
    return_url: `${process.env.NEXT_PUBLIC_SITE_URL}/account`,
  })

  // The URL is short-lived, so redirect now rather than storing it.
  return NextResponse.json({ url: session.url })
}

Two things here are load-bearing. The customer ID comes from the signed-in session rather than the request body, because accepting it from the client means anyone can open anyone else's billing portal by editing a value. And the session URL is short-lived by design, so create one per request rather than caching it anywhere.

The Dashboard configures what the portal can do, not the code, so decide there whether members can switch plans themselves or only cancel. Finish this step with an account page whose billing button links to something you didn't have to build.

5. Reconcile, because webhooks are not guaranteed to arrive

Everything above assumes events arrive. Almost always they do, but a deploy during delivery or an endpoint returning errors for an hour leaves your copy of the truth behind Stripe's.

Add a reconciliation path: when a member's account page loads and their stored period end is in the past, re-read the subscription from Stripe and update the record. This costs one API call on a page nobody loads often, and it self-heals the exact case where a missed event would otherwise lock out a paying member.

The same read helps support. When somebody says their access is wrong, comparing your stored state against a fresh read tells you immediately whether the problem is a missed event or a policy decision, which are very different bugs.

You finish this step with a system that corrects itself, not one that needs a human to notice.

What causes Stripe subscription access to fail on Webflow Cloud?

Subscription bugs are rarely loud. The member sees the wrong thing, the logs show a successful request, and nothing surfaces until somebody complains or an invoice looks wrong.

These four account for most of them, and the first two reach support inboxes.

A canceled member loses access immediately

Cause: The entitlement logic incorrectly relies solely on the subscription status, causing immediate revocation when a status is marked as "canceled." In reality, when a member cancels mid-period, they have usually already paid for the remainder of their billing cycle.

Stripe records this as a cancellation effective at the end of the period, not an immediate termination, so your system is prematurely locking out paying customers.

Fix: Check the current period end alongside the status, and keep access until that timestamp passes. The same applies to cancel_at_period_end being true on an otherwise active subscription: it means a decision has been made about the future, not about today.

Test it by canceling a test subscription in the Dashboard and confirming the member keeps access until the period actually elapses.

A failed payment locks out a paying member

Cause: Treating past_due as equivalent to canceled. When a card fails, Stripe retries on the schedule set in your Dashboard, and the subscription remains recoverable for that window rather than being over.

Fix: Keep access during the retry window and use the time to prompt the member, which is what the customer portal exists for. Revoke on unpaid or canceled, whichever your Dashboard sends exhausted retries to, rather than on the first failure.

Stripe is direct about the first: revoke access when a subscription is unpaid, because it already attempted and retried payments while it was past due.

Decide this deliberately rather than by default, because the difference between the two policies is measured in involuntary churn. The retry schedule is a Dashboard setting, not something your code controls, so check what yours is set to before choosing a grace period, or the two will disagree.

A member keeps access after their period ended

Cause: Stored state that never got updated. The subscription ended in Stripe, but the event that would have told you either failed delivery, arrived during a deploy, or hit an endpoint that was returning errors at the time. Your database still holds the last state it was told about, and entitlement is being derived from a period end that has since passed.

Fix: Add the reconciliation read from step 5, so a stored period end in the past triggers a fresh look at Stripe rather than being trusted. Check the webhook endpoint's delivery history in the Dashboard for the affected customer, since that distinguishes a missed event from a policy decision working as designed.

If deliveries are failing broadly rather than for one member, fix the endpoint before repairing individual records, or you will be repairing them all week.

The build fails after adding the billing routes

Cause: An export const runtime = 'edge' directive in one of the new files. Webflow Cloud deploys Next.js through the OpenNext Cloudflare adapter, which does not support the Next.js edge runtime.

Fix: Remove the line and redeploy, since Route Handlers already run on the Workers runtime without it. Search the whole project, not just the file you last edited, because a single directive anywhere will fail the build.

Check this first when a build breaks right after new routes land, since Webflow's own bring-your-own-app page still tells Next.js readers to add it, and payment examples from other hosts often include it too.

What you can build next with Stripe and Webflow

Once entitlement is derived rather than stored, the additions get easier: tiered access by price ID, seat-based plans where one subscription covers several logins, or a grace period that degrades features instead of cutting them off.

If your pricing is metered rather than flat, our usage-based billing guide covers the metering side and which of Stripe's usage products to build on. For the no-code connection routes, see the Webflow and Stripe integration.

For deeper customization beyond what those cover, Webflow's developer docs set out Route Handlers, storage bindings and the rest of the Webflow Cloud runtime.

Frequently asked questions

Should I store a boolean for whether someone is a member?

No. Store the subscription status, the current period end, the cancellation flag and the price, then derive access from those. A boolean discards the information you need for the awkward cases and drifts from Stripe the first time a payment fails.

What should happen when a payment fails?

Usually keep access while Stripe retries. A failed card is a recoverable event, and the subscription then moves to canceled, moves to unpaid, or stays in past_due, depending on your Dashboard settings. Revoking at the first failure turns an expired card into a cancellation.

Does a canceled member lose access straight away?

No, if they canceled mid-period. A cancellation is normally scheduled for the end of the paid period, so check the period end alongside the status and keep access until that timestamp passes.

Do I need to build billing management screens?

Rarely. Stripe's customer portal handles payment method updates, invoices, plan changes and cancellation, and what it allows is configured in the Dashboard. Create a portal session per request, since the returned URL is short-lived.

What if a webhook never arrives?

Reconcile on read. When a member's stored period end has passed, re-read the subscription from Stripe and update your record. That single call repairs the missed-event case without anyone noticing it happened.


Last Updated
August 28, 2026
Category

Related articles


verifone logomonday.com logospotify logoted logogreenhouse logoclear logocheckout.com logosoundcloud logoreddit logothe new york times logoideo logoupwork logodiscord logo
verifone logomonday.com logospotify logoted logogreenhouse logoclear logocheckout.com logosoundcloud logoreddit logothe new york times logoideo logoupwork logodiscord logo

Get started for free

Try Webflow for as long as you like with our free Starter plan. Purchase a paid Site plan to publish, host, and unlock additional features.

Get started — it’s free
Watch demo

Try Webflow for as long as you like with our free Starter plan. Purchase a paid Site plan to publish, host, and unlock additional features.