WeWeb membership sites combine Supabase Auth for user accounts, role-based User Groups for gated content pages, and Stripe for subscription billing. The critical piece: Stripe webhook events must update subscription status server-side via a Supabase Edge Function — WeWeb cannot receive incoming webhooks. Once status updates in Supabase, your WeWeb role check gates or unlocks content automatically.
Build a Membership and Subscription Site with WeWeb and Stripe
A membership site has three layers: authentication (who you are), authorization (what plan you're on), and billing (how you pay). WeWeb handles the frontend UI for all three. Supabase provides authentication and stores subscription status. Stripe handles all payment processing and recurring billing. The key architectural constraint is that WeWeb is a frontend-only SPA — it cannot receive incoming Stripe webhooks. All billing events (subscription created, renewed, cancelled, payment failed) must be handled by a Supabase Edge Function that acts as your webhook endpoint. This tutorial builds the complete flow: user signs up, chooses a plan, pays via Stripe Checkout, webhook confirms payment and upgrades the user's role in Supabase, and the user gains access to gated content.
Prerequisites
- WeWeb project with Supabase Auth and Stripe plugins installed
- Supabase project with auth configured and Edge Functions enabled
- Stripe account with at least one Product and Price created (monthly subscription)
- WeWeb Essential+ plan (required for custom domain, needed for Stripe production)
- Stripe CLI installed locally for webhook testing during development (optional but recommended)
Step-by-step guide
Create the Subscription Data Model in Supabase
Create the Subscription Data Model in Supabase
In the Supabase Dashboard → SQL Editor, create a subscriptions table to track each user's plan status. This table is the source of truth for access control — Stripe is the payment source, but your database determines what content users can access. Required columns: id (uuid, primary key), user_id (uuid, references auth.users, unique), stripe_customer_id (text), stripe_subscription_id (text), plan_id (text — matches your Stripe Price IDs, e.g., 'price_basic', 'price_pro'), status (text — values: 'active', 'trialing', 'past_due', 'cancelled', 'none'), current_period_end (timestamptz), updated_at (timestamptz). Enable RLS: users can only read their own subscription row. The service role key (used in Edge Functions) can read and write all rows.
1-- Injection point: Supabase Dashboard → SQL Editor23CREATE TABLE public.subscriptions (4 id UUID PRIMARY KEY DEFAULT gen_random_uuid(),5 user_id UUID REFERENCES auth.users(id) UNIQUE NOT NULL,6 stripe_customer_id TEXT,7 stripe_subscription_id TEXT,8 plan_id TEXT DEFAULT 'none',9 status TEXT DEFAULT 'none'10 CHECK (status IN ('active', 'trialing', 'past_due', 'cancelled', 'none')),11 current_period_end TIMESTAMPTZ,12 updated_at TIMESTAMPTZ DEFAULT NOW()13);1415ALTER TABLE public.subscriptions ENABLE ROW LEVEL SECURITY;1617-- Users can read their own subscription18CREATE POLICY "Users read own subscription"19ON public.subscriptions FOR SELECT20USING ((SELECT auth.uid()) = user_id);2122-- Only service role can write (via Edge Functions)23-- No INSERT/UPDATE policy for anon/authenticated role intentionally2425-- Insert empty subscription row when user signs up26CREATE OR REPLACE FUNCTION public.handle_new_user()27RETURNS TRIGGER LANGUAGE plpgsql SECURITY DEFINER AS $$28BEGIN29 INSERT INTO public.subscriptions (user_id)30 VALUES (NEW.id)31 ON CONFLICT (user_id) DO NOTHING;32 RETURN NEW;33END;34$$;3536CREATE TRIGGER on_auth_user_created37AFTER INSERT ON auth.users38FOR EACH ROW EXECUTE FUNCTION public.handle_new_user();Expected result: Subscriptions table created with RLS. A trigger automatically creates an empty subscription row when a new user signs up.
Configure User Groups for Plan-Based Access
Configure User Groups for Plan-Based Access
In WeWeb, navigate to Plugins → Authentication → Supabase Auth → click Generate if you haven't already (creates the roles and users_roles tables). For membership, you will not use the standard roles table — instead, use subscription status from the subscriptions table. However, WeWeb's User Groups require roles from the auth system. The cleanest approach: when a subscription becomes active, also update the user's role in the users_roles table to 'member' (or 'pro_member', 'basic_member' etc.). Do this from your Stripe webhook Edge Function. In WeWeb, go to the Users section of Plugins → Authentication panel and create User Groups: 'Free Users' (no roles or role = 'free'), 'Basic Members' (role = 'basic_member'), 'Pro Members' (role = 'pro_member'). Navigate to each gated page's settings → Page settings → Access → set Restrict access → select the appropriate member group.
Expected result: User Groups defined. Premium pages are access-controlled. Free users see an upgrade prompt when trying to access gated pages.
Build the Pricing and Upgrade Page
Build the Pricing and Upgrade Page
Create a 'Pricing' page accessible to everyone (no access restriction). Design three pricing tier cards side by side (free, basic at $9/mo, pro at $29/mo) using a horizontal flexbox container. Each card has: plan name heading, price, feature list (Text elements with checkmarks), and a Subscribe button. For the free tier, bind the button to a Navigate action pointing to the signup page. For paid tiers, the Subscribe button's On click workflow calls a Supabase Edge Function 'create-checkout-session' passing: plan_id (the Stripe Price ID), user_id, user_email, and return URLs. The Edge Function creates or retrieves the Stripe customer, creates a Checkout Session in 'subscription' mode, and returns the session URL. WeWeb's workflow then navigates to that URL. Add a Conditional Rendering check: if the user is already on a given plan, show 'Current Plan' text instead of the Subscribe button.
Expected result: Pricing page shows three plans. Clicking a paid plan redirects to Stripe Checkout. Current plan is highlighted for logged-in subscribers.
Create the Stripe Checkout Edge Function
Create the Stripe Checkout Edge Function
In Supabase Dashboard → Edge Functions → New Function, create 'create-checkout-session'. Add your Stripe secret key as an Edge Function secret: Supabase Dashboard → Settings → Edge Functions → Secrets → add STRIPE_SECRET_KEY. Also add STRIPE_WEBHOOK_SECRET for Step 5. This function receives plan_id, user_id, and user_email, creates or retrieves a Stripe customer, creates a Checkout Session with mode: 'subscription', and returns the session URL. The success_url should include ?session_id={CHECKOUT_SESSION_ID} so you can look up the subscription after redirect. Store the Edge Function URL — you will call it from WeWeb workflows via the Supabase Invoke Edge Function action.
1// Injection point: Supabase Edge Function → supabase/functions/create-checkout-session/index.ts2import Stripe from 'https://esm.sh/stripe@14';3import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';45const stripe = new Stripe(Deno.env.get('STRIPE_SECRET_KEY')!);6const supabase = createClient(7 Deno.env.get('SUPABASE_URL')!,8 Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!9);1011const corsHeaders = {12 'Access-Control-Allow-Origin': '*',13 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type'14};1516Deno.serve(async (req) => {17 if (req.method === 'OPTIONS') {18 return new Response('ok', { headers: corsHeaders });19 }2021 const { plan_id, user_id, user_email, success_url, cancel_url } = await req.json();2223 // Get or create Stripe customer24 const { data: sub } = await supabase25 .from('subscriptions')26 .select('stripe_customer_id')27 .eq('user_id', user_id)28 .single();2930 let customerId = sub?.stripe_customer_id;31 if (!customerId) {32 const customer = await stripe.customers.create({ email: user_email, metadata: { user_id } });33 customerId = customer.id;34 await supabase.from('subscriptions').update({ stripe_customer_id: customerId }).eq('user_id', user_id);35 }3637 const session = await stripe.checkout.sessions.create({38 customer: customerId,39 mode: 'subscription',40 line_items: [{ price: plan_id, quantity: 1 }],41 success_url: success_url + '?session_id={CHECKOUT_SESSION_ID}',42 cancel_url,43 metadata: { user_id }44 });4546 return new Response(JSON.stringify({ url: session.url }), {47 headers: { ...corsHeaders, 'Content-Type': 'application/json' }48 });49});Expected result: Edge Function deployed. Calling it from WeWeb returns a Stripe Checkout URL that starts the subscription flow.
Handle Stripe Webhooks to Update Subscription Status
Handle Stripe Webhooks to Update Subscription Status
This is the most critical step. Create a second Edge Function named 'stripe-webhook'. Register this as a webhook endpoint in your Stripe Dashboard: Developers → Webhooks → Add endpoint → URL: your Edge Function URL (format: https://[project-ref].supabase.co/functions/v1/stripe-webhook). Subscribe to events: customer.subscription.created, customer.subscription.updated, customer.subscription.deleted, invoice.payment_failed. This function receives Stripe events, verifies the signature using the STRIPE_WEBHOOK_SECRET, and updates the subscriptions table accordingly. When status becomes 'active' or 'trialing', it also updates the user's role in users_roles to grant access. When status becomes 'cancelled' or 'past_due', it removes the role. WeWeb automatically picks up the role change on the next page load because the Supabase Auth plugin re-reads user data.
1// Injection point: Supabase Edge Function → supabase/functions/stripe-webhook/index.ts2import Stripe from 'https://esm.sh/stripe@14';3import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';45const stripe = new Stripe(Deno.env.get('STRIPE_SECRET_KEY')!);6const supabase = createClient(7 Deno.env.get('SUPABASE_URL')!,8 Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!9);1011Deno.serve(async (req) => {12 const signature = req.headers.get('stripe-signature')!;13 const body = await req.text();1415 let event: Stripe.Event;16 try {17 event = await stripe.webhooks.constructEventAsync(18 body, signature, Deno.env.get('STRIPE_WEBHOOK_SECRET')!19 );20 } catch (err) {21 return new Response(`Webhook signature failed: ${err.message}`, { status: 400 });22 }2324 const subscription = event.data.object as Stripe.Subscription;25 const userId = subscription.metadata?.user_id26 || (await supabase.from('subscriptions').select('user_id')27 .eq('stripe_customer_id', subscription.customer).single()).data?.user_id;2829 if (!userId) return new Response('User not found', { status: 400 });3031 const status = subscription.status;32 const planId = subscription.items.data[0]?.price?.id || 'none';33 const periodEnd = new Date(subscription.current_period_end * 1000).toISOString();3435 await supabase.from('subscriptions').upsert({36 user_id: userId,37 stripe_subscription_id: subscription.id,38 plan_id: planId,39 status,40 current_period_end: periodEnd,41 updated_at: new Date().toISOString()42 }, { onConflict: 'user_id' });4344 return new Response(JSON.stringify({ received: true }), { status: 200 });45});Expected result: Stripe webhook events update the subscriptions table in real time. Subscription status is always current in Supabase.
Build the Member Portal with Billing Management
Build the Member Portal with Billing Management
Create a 'Member Portal' page accessible only to users with active subscriptions. In the Data panel, create a 'mySubscription' collection: Supabase → subscriptions table → filter user_id = plugins['supabase-auth'].user.id. Add elements showing: plan name (bound to mySubscription.data[0].plan_id), status badge (color-coded by status: green for 'active', yellow for 'past_due', grey for 'cancelled'), and next billing date (mySubscription.data[0].current_period_end, formatted as a date). Add a 'Manage Billing' button whose On click workflow invokes a 'create-portal-session' Edge Function that calls stripe.billingPortal.sessions.create({ customer: stripeCustomerId, return_url }) and navigates to the returned URL. The Stripe Customer Portal allows users to update payment methods, download invoices, and cancel their subscription — all handled by Stripe's hosted UI without any additional WeWeb work.
Expected result: Member portal shows current plan and billing details. Clicking Manage Billing redirects to Stripe's hosted billing portal.
Implement Content Tier Gating
Implement Content Tier Gating
For sites with multiple subscription tiers (basic vs pro), use Conditional Rendering to show/hide content blocks based on the user's plan. Create a page-level variable 'userPlan' on each premium page. On page load workflow: read mySubscription.data[0].plan_id and set userPlan. For pro-only content sections, set Conditional Rendering condition to: userPlan == 'price_pro_monthly'. For content that requires only an active subscription (any paid plan): userPlan != 'none' AND collections['mySubscription'].data[0].status == 'active'. For users on a lower tier trying to access pro content, show a teaser container (the first 20% of content) with a locked overlay. The locked overlay has a Conditional Rendering condition that is the inverse: NOT (userPlan == 'price_pro_monthly'). Add an 'Upgrade to Pro' button on the overlay. Security reminder: this is UX-layer enforcement — combine with Supabase RLS to prevent the API from returning pro-only data to basic users.
Expected result: Pro content is conditionally rendered only for pro subscribers. Basic members see a locked teaser with an upgrade CTA.
Complete working example
1// Injection point: Supabase Edge Function → supabase/functions/create-portal-session/index.ts2// Called from WeWeb Member Portal page via Supabase Invoke Edge Function action3// Returns a Stripe Customer Portal session URL45import Stripe from 'https://esm.sh/stripe@14';6import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';78const stripe = new Stripe(Deno.env.get('STRIPE_SECRET_KEY')!, {9 apiVersion: '2023-10-16',10 httpClient: Stripe.createFetchHttpClient()11});1213const supabase = createClient(14 Deno.env.get('SUPABASE_URL')!,15 Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!16);1718const corsHeaders = {19 'Access-Control-Allow-Origin': '*',20 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type'21};2223Deno.serve(async (req) => {24 if (req.method === 'OPTIONS') {25 return new Response('ok', { headers: corsHeaders });26 }2728 const { user_id, return_url } = await req.json();2930 // Get Stripe customer ID from subscriptions table31 const { data: sub, error } = await supabase32 .from('subscriptions')33 .select('stripe_customer_id')34 .eq('user_id', user_id)35 .single();3637 if (error || !sub?.stripe_customer_id) {38 return new Response(39 JSON.stringify({ error: 'No Stripe customer found for this user' }),40 { status: 404, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }41 );42 }4344 // Create Stripe Customer Portal session45 const session = await stripe.billingPortal.sessions.create({46 customer: sub.stripe_customer_id,47 return_url: return_url || 'https://yourapp.com/member'48 });4950 return new Response(51 JSON.stringify({ url: session.url }),52 { status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }53 );54});Common mistakes
Why it's a problem: Updating subscription status from the WeWeb success URL redirect instead of from a Stripe webhook
How to avoid: The Stripe Checkout success URL can be reached by anyone who knows the URL format — it does not guarantee payment was completed. Always use Stripe webhooks (customer.subscription.created event) handled by a server-side Edge Function to update subscription status. The success URL redirect should only show a 'Thank you' page and trigger a collection refresh.
Why it's a problem: Storing Stripe Price IDs in WeWeb variables and referencing them directly in checkout calls
How to avoid: Pass Price IDs only server-side in your Edge Function. If you pass plan_id from WeWeb to your Edge Function, validate it against an allowlist of known Price IDs in the function before creating the checkout session. This prevents users from substituting a lower-priced plan ID in the request.
Why it's a problem: Using CSS display:none or conditional visibility (not Conditional Rendering) for premium content
How to avoid: Any content hidden via CSS is still loaded in the browser and readable in DevTools source. Use Conditional Rendering (element is removed from the DOM) for all gated content. For data, add RLS policies in Supabase so premium data is not returned to non-subscribers at the API level.
Best practices
- Always verify subscription status from your Supabase database, not from Stripe directly in the frontend — your database is the source of truth after webhook processing
- Handle the grace period for 'past_due' subscriptions — give users a 3-7 day window to update payment before fully revoking access
- Use Supabase Realtime on the subscriptions table so WeWeb re-fetches the subscription collection automatically when webhook updates the status
- Configure Stripe's automatic payment retry logic in your Stripe Dashboard (Billing settings) to attempt failed payments 2-3 times before marking past_due
- Test your entire payment flow using Stripe test mode and the test card 4242 4242 4242 4242 before going live
- Send email notifications for billing events (payment failed, subscription cancelled) from your webhook Edge Function using Supabase's email integration or Resend API
- Include a free trial option (Stripe trialing period) to reduce signup friction — store the trial end date and show a countdown banner to convert trial users
Still stuck?
Copy one of these prompts to get a personalized, step-by-step explanation.
I'm building a membership site with WeWeb frontend and Supabase backend. I have a subscriptions table with columns: user_id, status, plan_id, current_period_end. Write a Supabase PostgreSQL RLS policy that allows users to read their own subscription row, and a trigger function that automatically creates an empty subscription row (status: 'none') when a new user signs up.
In my WeWeb membership site, I have a 'mySubscription' collection and a 'userPlan' page variable. Help me write an On page load workflow that reads the subscription status and plan_id, then uses conditional logic to show a 'Trial ends in X days' banner if status is 'trialing' and the trial ends within 7 days. What formula should I use to calculate days remaining?
Frequently asked questions
Can I offer a free trial before charging for a subscription in WeWeb?
Yes. When creating the Stripe Checkout Session in your Edge Function, add trial_period_days: 14 to the checkout session or subscription_data object. Stripe will not charge the user during the trial. The subscription status in Stripe will be 'trialing', which your webhook handler should map to 'active' access in your application.
How do I restrict specific content sections (not whole pages) to paid members?
Use Conditional Rendering on individual containers or sections. Set the condition to check the user's subscription status from your 'mySubscription' collection: collections['mySubscription'].data[0]?.status == 'active'. This removes the element from the DOM entirely for non-subscribers. Wrap a teaser version of the content in an adjacent container with the inverse condition to show a preview to free users.
What happens if a user cancels their Stripe subscription mid-period?
By default, Stripe cancels the subscription at the end of the current billing period (the user keeps access until current_period_end). The subscription status remains 'active' until that date, then changes to 'cancelled'. Your webhook handler receives a customer.subscription.updated event at cancellation time, and a customer.subscription.deleted event when the period ends. Update your subscriptions table at the deleted event to revoke access.
How do I let users switch between Basic and Pro subscription plans?
The Stripe Customer Portal (accessed via the 'Manage Billing' button in your member portal) handles plan switching natively. Users can upgrade or downgrade, and Stripe prorates the charges automatically. Your webhook handler receives a customer.subscription.updated event with the new plan_id — update the subscriptions table and the user's role accordingly. No additional WeWeb code is needed for the switching logic itself.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation