A production-ready Proof of Concept for Stripe subscription billing with Supabase Edge Functions, React frontend, and webhook-driven state sync.
A fully working end-to-end billing system POC built on:
- Frontend — React 19 + Vite + TypeScript
- Backend — 14 Supabase Edge Functions (Deno runtime)
- Database — Supabase PostgreSQL with Row-Level Security
- Payments — Stripe (subscriptions, checkout, webhooks, customer portal)
- State — TanStack Query (server state) + Zustand (client state)
The core architectural principle: Stripe is the billing source of truth. Supabase stores a read-model cache of subscription state, kept in sync via webhooks.
| Feature | Status |
|---|---|
| Subscription creation with free trial (one-time per user) | ✅ |
| Stripe Checkout (hosted payment page) | ✅ |
| Custom payment UI via SetupIntent + PaymentElement | ✅ |
| Plan upgrade / downgrade with proration | ✅ |
| Cancel at period end or immediately | ✅ |
| Pause / resume subscription | ✅ |
| Invoice list with status + PDF link | ✅ |
| Default payment method management | ✅ |
| Stripe Customer Portal (self-service) | ✅ |
| Webhook processing (idempotent, signature-verified) | ✅ |
| Row-Level Security on all billing tables | ✅ |
| Postman collection for API testing | ✅ |
┌─────────────────────────────────┐
│ React Frontend │
│ (billing page, checkout, modals)│
└────────────┬────────────────────┘
│ JWT (Supabase Auth)
▼
┌─────────────────────────────────┐
│ Supabase Edge Functions │ ← All secrets live here only
│ (14 Deno functions) │
└────────┬────────────────────────┘
│ │
▼ ▼
┌──────────┐ ┌────────────┐
│ Stripe │ │ Supabase │
│ API │ │ Postgres │
└──────────┘ └────────────┘
│
│ Webhook (signature verified)
▼
┌─────────────────────────────────┐
│ stripe-webhook function │
│ (idempotent, updates DB state) │
└─────────────────────────────────┘
├── src/
│ ├── features/billing/ # Billing UI feature
│ │ ├── components/ # BillingPage, UpdateCardModal, ChangePlanModal, PauseModal
│ │ ├── hooks/useBilling.ts # Centralized billing state (TanStack Query)
│ │ └── api/ # Edge Function client wrappers
│ ├── features/onboarding/ # Registration + checkout flow
│ ├── pages/
│ │ ├── billing.tsx # Main billing page
│ │ ├── billing-checkout-return.tsx # Stripe Checkout return handler
│ │ └── checkout.tsx # Custom payment UI (SetupIntent)
│ └── shared/
│ ├── constants/stripe-plans.ts # Plan definitions + price IDs
│ ├── lib/stripe.ts # Stripe.js initialization
│ └── types/index.ts # TypeScript types
│
├── supabase/
│ ├── functions/ # 14 Edge Functions (see below)
│ └── migrations/ # 3 SQL migrations
│
└── docs/
├── stripe-supabase-poc-plan.md # Architecture + sprint plan
├── supabase-stripe-poc-phase1.md # Step-by-step deployment guide
├── backend-billing-api-reference.md # API endpoint contracts
└── postman/ # Postman collection (13 requests)
| Function | Purpose | Auth |
|---|---|---|
billing-subscribe |
Create subscription (with one-time trial logic) | JWT |
billing-cancel |
Cancel at period end or immediately | JWT |
billing-change-plan |
Upgrade/downgrade with proration | JWT |
billing-pause |
Pause or resume subscription | JWT |
billing-overview |
Fetch subscription + profile summary | JWT |
billing-invoices |
List recent invoices from Stripe | JWT |
billing-payment-method |
Fetch default payment method details | JWT |
billing-portal |
Generate Stripe Customer Portal URL | JWT |
billing-create-checkout-session |
Create Stripe Checkout session | JWT |
billing-sync-checkout |
Sync DB after Checkout (pre-webhook, POC only) | JWT |
billing-ensure-customer |
Create Stripe customer if missing | JWT |
billing-create-setup-intent |
SetupIntent for custom payment UI | JWT |
billing-update-default-pm |
Set a payment method as default | JWT |
stripe-webhook |
Process Stripe events (no JWT, signature-verified) | Stripe Sig |
Extends auth.users. Stores the Stripe customer ID and trial state.
id uuid PRIMARY KEY -- matches auth.users.id
email text
full_name text
stripe_customer_id text UNIQUE
has_used_trial boolean DEFAULT false
created_at timestamptz DEFAULT now()Read model cached from Stripe. Updated by Edge Functions + webhooks.
id uuid PRIMARY KEY
user_id uuid REFERENCES profiles(id)
stripe_subscription_id text UNIQUE
stripe_price_id text
status text -- trialing | active | past_due | canceled | ...
current_period_start timestamptz
current_period_end timestamptz
cancel_at_period_end boolean DEFAULT false
trial_start timestamptz
trial_end timestamptz
paused boolean DEFAULT false
created_at timestamptz
updated_at timestamptzWebhook audit trail. stripe_event_id unique constraint ensures idempotency.
id uuid PRIMARY KEY
stripe_event_id text UNIQUE -- idempotency key
type text
payload jsonb
processed_at timestamptz DEFAULT now()1. POST billing-create-checkout-session → { url }
2. Redirect user to Stripe hosted page
3. User enters card (test: 4242 4242 4242 4242)
4. Stripe redirects to CHECKOUT_SUCCESS_URL?session_id=...
5. POST billing-sync-checkout → DB updated (POC sync)
6. Webhook confirms subscription asynchronously
1. POST billing-create-setup-intent → { clientSecret }
2. Render Stripe PaymentElement with clientSecret
3. User confirms payment in your own UI
4. POST billing-update-default-pm → payment method saved
5. POST billing-subscribe with { priceId, paymentMethodId }
- One-time per user, tracked by
profiles.has_used_trial - 15-day free trial on first subscription only
- Subsequent subscriptions start immediately without trial
- Trial status flagged in DB via webhook
subscription.trial_startfield
The stripe-webhook function:
- Verifies
Stripe-Signatureheader usingSTRIPE_WEBHOOK_SECRET - Inserts
billing_eventsrow first — ifstripe_event_idalready exists (error code23505), returns early (idempotent) - Processes event, rolls back
billing_eventsrow on failure so retries work correctly
Handled events:
checkout.session.completedcustomer.subscription.created/updated/deletedinvoice.paidinvoice.payment_failed
- Stripe account (test mode)
- Supabase project
- Supabase CLI
- Node.js 20+
git clone https://github.com/YOUR_USERNAME/stripe-supabase-subscription-kit
cd stripe-supabase-subscription-kit
npm installcp .env.example .env.developmentEdit .env.development:
VITE_SUPABASE_URL=https://YOUR_PROJECT_REF.supabase.co
VITE_SUPABASE_ANON_KEY=eyJ...your-anon-key
VITE_STRIPE_PUBLISHABLE_KEY=pk_test_...your-publishable-keyNote:
VITE_SUPABASE_ANON_KEYandVITE_STRIPE_PUBLISHABLE_KEYare safe to expose in the frontend. Never putSTRIPE_SECRET_KEYorSUPABASE_SERVICE_ROLE_KEYhere.
supabase link --project-ref YOUR_PROJECT_REF
supabase db pushIn the Stripe Dashboard (Test mode):
- Create a Product (e.g. "Pro Plan")
- Add recurring prices — monthly and yearly
- Note the
price_xxxIDs
Update src/shared/constants/stripe-plans.ts with your actual price IDs:
{ priceId: 'price_YOUR_SIMPLE_MONTHLY', ... }
{ priceId: 'price_YOUR_SIMPLE_YEARLY', ... }
{ priceId: 'price_YOUR_PRO_MONTHLY', ... }
{ priceId: 'price_YOUR_PRO_YEARLY', ... }Update each Edge Function's ALLOWED_PRICE_IDS set with the same IDs.
In Supabase Dashboard → Project Settings → Edge Functions → Secrets:
STRIPE_SECRET_KEY sk_test_...
STRIPE_WEBHOOK_SECRET whsec_...
CHECKOUT_SUCCESS_URL https://yourdomain.com/billing/return?session_id={CHECKOUT_SESSION_ID}
CHECKOUT_CANCEL_URL https://yourdomain.com/plans
BILLING_PORTAL_RETURN_URL https://yourdomain.com/billing
These secrets are injected at runtime — never committed to the repository.
supabase functions deploy billing-subscribe
supabase functions deploy billing-cancel
supabase functions deploy billing-change-plan
supabase functions deploy billing-pause
supabase functions deploy billing-overview
supabase functions deploy billing-invoices
supabase functions deploy billing-payment-method
supabase functions deploy billing-portal
supabase functions deploy billing-create-checkout-session
supabase functions deploy billing-sync-checkout
supabase functions deploy billing-ensure-customer
supabase functions deploy billing-create-setup-intent
supabase functions deploy billing-update-default-pm
supabase functions deploy stripe-webhook --no-verify-jwt
stripe-webhookmust be deployed with--no-verify-jwt— it uses Stripe signature verification instead.
Stripe Dashboard → Developers → Webhooks → Add endpoint:
URL: https://YOUR_PROJECT_REF.supabase.co/functions/v1/stripe-webhook
Select events:
checkout.session.completedcustomer.subscription.createdcustomer.subscription.updatedcustomer.subscription.deletedinvoice.paidinvoice.payment_failed
Copy the Signing secret (whsec_...) → save as STRIPE_WEBHOOK_SECRET Edge Function secret.
npm run dev# Install Stripe CLI: https://stripe.com/docs/stripe-cli
stripe listen --forward-to https://YOUR_PROJECT_REF.supabase.co/functions/v1/stripe-webhook
# Trigger a test event
stripe trigger customer.subscription.created| Scenario | Card Number |
|---|---|
| Successful payment | 4242 4242 4242 4242 |
| Payment declined | 4000 0000 0000 9995 |
| 3D Secure required | 4000 0025 0000 3155 |
Use any future expiry date and any 3-digit CVV.
See docs/backend-billing-api-reference.md for full request/response contracts.
A Postman collection with 13 pre-configured requests is available at docs/postman/.
| Layer | Approach |
|---|---|
| Frontend secrets | Only anon key + publishable key (both safe for public) |
| Backend secrets | Supabase Edge Function secrets (never in git) |
| API auth | JWT verified on every request via supabase.auth.getUser() |
| Webhook auth | Stripe-Signature header verified via stripe.webhooks.constructEvent() |
| Price validation | All price IDs validated against server-side whitelist |
| Database access | Row-Level Security — users can only read their own records |
| Service role key | Used only inside Edge Functions, never exposed to frontend |
| Idempotency | stripe_event_id unique constraint prevents duplicate webhook processing |
- Multi-currency support
- Tax calculation (Stripe Tax)
- Enterprise custom invoicing
- Metered billing
- Team/seat-based billing
- Usage-based pricing
MIT