SaaS Subscription Helper is an open-source Node.js package designed to streamline Stripe and Supabase integration in your SaaS applications. It focuses on handling subscription updates, cancellations, and syncing data with your database.
View this project on GitHub: https://github.com/richardsondx/saas-subscription-helper
- Webhook handling for subscription events
- Subscription status syncing between Stripe and Supabase
- Plan changes (upgrades/downgrades)
- Subscription cancellation
- Trial period support
- Debug logging
const result = await subscriptionHelper.changeUserPlan('[email protected]', 'price_new');
- Changes a user's subscription plan
- Handles both upgrades and downgrades
- Preserves trial periods if configured
- Returns detailed result object
Upgrades a user's subscription to a new plan
const result = await subscriptionHelper.cancelUserSubscription('[email protected]');
- Install the package 📦
- Add environment variables for Stripe and Supabase 🔑
- Set Up Webhook Endpoint 🔄
- Create Payment Links in Stripe, add the links to your app 💳
- Test the webhook locally using the Stripe CLI 🔄 DONE ✅ ☕️
The experience is seamless for you and your users:
- User Pays via Payment Link: You can easily create payment links in Stripe, where the user's email is captured.
- Stripe Webhook Triggers: Stripe sends updates (e.g., subscription.updated) to your webhook endpoint which the package handles for you.
- Helper Syncs Supabase: The package updates your Supabase table with the user's subscription details, keeping your app in sync.
- Subscriptions Management: Adding upgrade, dowgrade and cancellation logic is as easy as adding a link to your app.
npm install saas-subscription-helper
Make sure you have the following dependencies installed:
npm install stripe
npm install @supabase/supabase-js
Create a shared configuration file that you'll use throughout your application:
// lib/subscription.js
import { SubscriptionHelper } from 'saas-subscription-helper';
export const subscriptionHelper = new SubscriptionHelper({
stripeApiKey: process.env.STRIPE_SECRET_KEY,
supabaseUrl: process.env.SUPABASE_URL,
supabaseKey: process.env.SUPABASE_SERVICE_KEY,
table: 'users',
emailField: 'email',
subscriptionField: 'subscription_status',
});
Add the webhook endpoint to your app:
// app/api/webhooks/route.js
import { NextResponse } from 'next/server';
import { subscriptionHelper } from '@/lib/subscription';
export async function POST(req) {
try {
await subscriptionHelper.handleWebhooks({
rawBody: await req.text(),
stripeSignature: req.headers.get("stripe-signature"),
headers: Object.fromEntries(req.headers)
});
return NextResponse.json({ received: true });
} catch (err) {
return NextResponse.json({ error: err.message }, { status: 400 });
}
}
Test your webhook locally using the Stripe CLI:
stripe login
stripe listen --forward-to localhost:3000/api/
webhooks
Stripe Payment Links allow you to generate URLs for your subscription plans.
Here’s how to set them up for development and production environments.
- Go to the Stripe Dashboard (Test Mode).
- Create a Payment Link:
- Set up your products and pricing.
- Generate a Payment Link.
- Set the Success URL:
- Success URL: http://localhost:3000/subscription-callback
- Switch to Live Mode in the Stripe Dashboard.
- Create a Payment Link:
- Use your production products and pricing.
- Generate a Payment Link.
- Set the Success URLx:
- Success URL: https://yourdomain.com/ subscription-callback
Success URL is the URL that Stripe will redirect to after the user has completed the payment. You can send it wherever you want, but it's best to send it to your app, where users can complete onboarding after payment.
In both environments, the success URL should redirect to your app, where users can complete onboarding after payment.
The package automatically handles the following Stripe webhook events:
subscription.created
: When a new subscription is createdsubscription.updated
: When a subscription is modifiedsubscription.deleted
: When a subscription is removedcustomer.subscription.updated
: When customer subscription details changecustomer.subscription.deleted
: When a customer's subscription is cancelled
payment_intent.succeeded
: When a payment is successfulinvoice.paid
: When an invoice is paidinvoice.payment_failed
: When a payment attempt fails
All these events automatically sync the subscription state with your Supabase database. See handleWebhooks for more details.
These helper functions make subscription management straightforward. Here's how to use each one:
Perfect for when users want to cancel their subscription:
// app/api/subscription/cancel/route.js
export async function POST(req) {
const { email } = await req.json();
await subscriptionHelper.cancelUserSubscription(email);
return NextResponse.json({ message: "Subscription cancelled" });
}
Ideal for upgrades or downgrades:
// app/api/subscription/change-plan/route.js
export async function POST(req) {
const { email, newPriceId } = await req.json();
await subscriptionHelper.changePlan(email, newPriceId);
return NextResponse.json({ message: "Plan updated" });
}
Useful for displaying current subscription status:
// app/api/subscription/details/route.js
export async function GET(req) {
const email = req.nextUrl.searchParams.get('email');
const subscription = await subscriptionHelper.fetchSubscription(email);
return NextResponse.json(subscription);
}
Helpful when you need to manually sync Stripe with Supabase:
// app/api/subscription/sync/route.js
export async function POST(req) {
const { email } = await req.json();
await subscriptionHelper.syncSubscription(email);
return NextResponse.json({ message: "Subscription synced" });
}
Note: Remember to implement proper authentication before exposing these endpoints.
All subscription management operations must be performed server-side for security. Here's how to implement upgrades and cancellations in different setups:
// lib/subscription.js
import { SubscriptionHelper } from 'saas-subscription-helper';
export const subscriptionHelper = new SubscriptionHelper({
stripeApiKey: process.env.STRIPE_SECRET_KEY,
supabaseUrl: process.env.SUPABASE_URL,
supabaseKey: process.env.SUPABASE_SERVICE_KEY,
table: 'users',
emailField: 'email',
subscriptionField: 'subscription_status',
});
// app/api/subscription/upgrade/route.js
import { NextResponse } from 'next/server';
import { subscriptionHelper } from '@/lib/subscription';
export async function POST(req) {
try {
const { email, newPriceId } = await req.json();
await subscriptionHelper.changeUserPlan(email, newPriceId);
return NextResponse.json({ success: true });
} catch (error) {
return NextResponse.json({ error: error.message }, { status: 400 });
}
}
// app/api/subscription/downgrade/route.js
import { NextResponse } from 'next/server';
import { subscriptionHelper } from '@/lib/subscription';
export async function POST(req) {
try {
const { email, newPriceId } = await req.json();
await subscriptionHelper.changeUserPlan(email, newPriceId);
return NextResponse.json({ success: true });
} catch (error) {
return NextResponse.json({ error: error.message }, { status: 400 });
}
}
// app/api/subscription/cancel/route.js
export async function POST(req) {
try {
const { email } = await req.json();
await subscriptionHelper.cancelUserSubscription(email);
return NextResponse.json({ success: true });
} catch (error) {
return NextResponse.json({ error: error.message }, { status: 400 });
}
}
// server.js
import { subscriptionHelper } from './lib/subscription.js';
app.post('/api/subscription/upgrade', async (req, res) => {
try {
const { email, newPriceId } = req.body;
await subscriptionHelper.changeUserPlan(email, newPriceId);
res.json({ success: true });
} catch (error) {
res.status(400).json({ error: error.message });
}
});
app.post('/api/subscription/downgrade', async (req, res) => {
try {
const { email, newPriceId } = req.body;
await subscriptionHelper.changeUserPlan(email, newPriceId);
res.json({ success: true });
} catch (error) {
res.status(400).json({ error: error.message });
}
});
app.post('/api/subscription/cancel', async (req, res) => {
try {
const { email } = req.body;
await subscriptionHelper.cancelSubscription(email);
res.json({ success: true });
} catch (error) {
res.status(400).json({ error: error.message });
}
});
// supabase/functions/lib/subscription.ts
import { SubscriptionHelper } from 'saas-subscription-helper'
export const subscriptionHelper = new SubscriptionHelper({
stripeSecretKey: Deno.env.get('STRIPE_SECRET_KEY'),
supabaseUrl: Deno.env.get('SUPABASE_URL'),
supabaseKey: Deno.env.get('SUPABASE_SERVICE_KEY'),
table: 'users',
emailField: 'email',
subscriptionField: 'subscription_status',
});
// supabase/functions/subscription-manage/index.ts
import { serve } from 'https://deno.land/[email protected]/http/server.ts'
import { subscriptionHelper } from '../lib/subscription.ts'
serve(async (req) => {
try {
const { action, email, newPriceId } = await req.json()
if (action === 'upgrade') {
await subscriptionHelper.upgradeUserSubscription(email, newPriceId)
} else if (action === 'cancel') {
await subscriptionHelper.cancelUserSubscription(email)
}
return new Response(JSON.stringify({ success: true }))
} catch (err) {
return new Response(
JSON.stringify({ error: err.message }),
{ status: 400 }
)
}
})
// React component example
function SubscriptionManager({ userEmail }) {
const handleUpgrade = async (newPriceId) => {
try {
const res = await fetch('/api/subscription/upgrade', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: userEmail, newPriceId })
});
const data = await res.json();
if (!data.success) throw new Error(data.error);
// Handle success (e.g., show toast, redirect)
} catch (error) {
// Handle error
}
};
const handleCancel = async () => {
try {
const res = await fetch('/api/subscription/cancel', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: userEmail })
});
const data = await res.json();
if (!data.success) throw new Error(data.error);
// Handle success
} catch (error) {
// Handle error
}
};
const handleDowngrade = async (newPriceId) => {
try {
const res = await fetch('/api/subscription/downgrade', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: userEmail, newPriceId })
});
const data = await res.json();
if (!data.success) throw new Error(data.error);
// Handle success
} catch (error) {
// Handle error
}
};
return (
<div>
<button onClick={() => handleUpgrade('price_premium')}>
Upgrade to Premium
</button>
<button onClick={() => handleDowngrade('price_basic')}>
Downgrade to Basic
</button>
<button onClick={handleCancel}>
Cancel Subscription
</button>
</div>
);
}
For best practices and advanced configuration tips, see our Best Practices Guide.
Important Security Notes:
- Never expose Stripe or Supabase keys on the client side
- Always verify user authentication before processing subscription changes
- Use environment variables for sensitive configuration
- Implement rate limiting on your subscription management endpoints
Field | Description | Example |
---|---|---|
stripeSecretKey |
Your private Stripe API key used for server-side operations | sk_test_... |
stripeWebhookSecret |
Secret used to verify Stripe webhook signatures | whsec_... |
supabaseUrl |
Your Supabase project URL | https://xxx.supabase.co |
supabaseKey |
Your Supabase service role key for database operations | eyJhbGci... |
table |
Name of the Supabase table storing user data | "users" |
emailField |
Column name for user email in your table | "email" |
subscriptionField |
Column name for subscription status | "subscription_status" |
Field | Description | Default | Options |
---|---|---|---|
planField |
Column name for storing plan/price IDs | "plan" |
Any valid column name |
createUserIfNotExists |
Auto-create user records if not found | false |
true /false |
debug |
Enable detailed debug logging | false |
true /false |
debugHeaders |
Log webhook headers (not recommended in production) | false |
true /false |
prorationBehavior |
How Stripe handles plan change proration | "always_invoice" |
"always_invoice" , "create_prorations" , "none" |
All fields default to false
unless explicitly enabled in configuration.
Field | Description | Data Type |
---|---|---|
stripe_customer_id |
Stripe's unique customer identifier | text |
default_payment_method |
ID of default payment method | text |
payment_last4 |
Last 4 digits of payment card | text |
payment_brand |
Card brand (visa, mastercard, etc.) | text |
payment_exp_month |
Card expiration month | integer |
payment_exp_year |
Card expiration year | integer |
current_period_start |
Start date of current billing period | timestamp |
current_period_end |
End date of current billing period | timestamp |
cancel_at_period_end |
Whether subscription will cancel at period end | boolean |
canceled_at |
Timestamp of cancellation | timestamp |
trial |
Whether subscription is in trial period | boolean |
trial_start |
Trial period start date | timestamp |
trial_end |
Trial period end date | timestamp |
subscription_created_at |
When subscription was initially created | timestamp |
Example configuration:
const subscriptionHelper = new SubscriptionHelper({
// Required fields
stripeSecretKey: process.env.STRIPE_SECRET_KEY,
stripeWebhookSecret: process.env.STRIPE_WEBHOOK_SECRET,
supabaseUrl: process.env.SUPABASE_URL,
supabaseKey: process.env.SUPABASE_KEY,
table: "profiles",
emailField: "email",
subscriptionField: "subscription_status",
// Optional fields
planField: "stripe_plan",
createUserIfNotExists: true, // Will create a new user record if email not found
debug: true,
debugHeaders: false,
prorationBehavior: 'create_prorations',
// OPTIONAL – Sync additional Stripe fields with your database
syncedStripeFields: {
stripe_customer_id: true, // Store Stripe Customer ID
payment_last4: true, // Store last 4 digits of card
payment_brand: true, // Store card brand
trial: true, // Track trial status
current_period_end: true, // Store subscription end date
cancel_at_period_end: true, // Store cancellation status
trial_end: true // Store trial end date
}
});
syncedStripeFields are all set to false by default, unless explicitly set to true in the configuration.
Note: When using syncedStripeFields
, make sure your database table has the corresponding columns:
ALTER TABLE users
ADD COLUMN stripe_customer_id text,
ADD COLUMN payment_last4 text,
ADD COLUMN payment_brand text,
ADD COLUMN payment_exp_month integer,
ADD COLUMN payment_exp_year integer,
ADD COLUMN trial boolean,
ADD COLUMN current_period_end timestamp with time zone,
ADD COLUMN cancel_at_period_end boolean,
ADD COLUMN trial_end timestamp with time zone;
All syncedStripeFields
default to false
unless explicitly set to true
in the configuration.
By default, the library expects a table with the following structure (using default names):
CREATE TABLE users (
-- Required columns
email text PRIMARY KEY, -- User's email (emailField)
subscription_status text, -- Subscription status (subscriptionField)
plan text, -- Stripe plan/price ID (planField)
-- Other columns can be added as needed
created_at timestamp with time zone DEFAULT timezone('utc'::text, now())
);
Some fields are required, but you can customize the table and column names in the configuration: You can customize the table and column names in the configuration:
const subscriptionHelper = new SubscriptionHelper({
// ... other config
table: "profiles", // Custom table name (default: 'users')
emailField: "email", // Custom email column (default: 'email')
subscriptionField: "sub_status", // Custom status column (default: 'subscription_status')
planField: "stripe_plan", // Custom plan column (default: 'plan')
});
email
: Stores the user's email address (used as identifier)subscription_status
: Stores the Stripe subscription status (e.g., 'active', 'canceled')plan
: Stores the Stripe Price ID of the current subscription plan
There are two ways to let your users manage their subscriptions through Stripe's Customer Portal:
The simplest approach is to use Stripe's hosted billing portal login page. Users will receive a secure link via email to access their billing settings.
// Add this link to your app's UI
<a href="https://billing.stripe.com/p/login/YOUR_PORTAL_ID">Manage Billing</a>
When users click the link:
- They enter their email
- Stripe sends them a secure login link
- They can manage their subscription, update payment methods, and view invoices
The subscription helper automatically handles any changes made through the portal via webhooks.
For a more seamless experience, you can create a portal session for logged-in users:
// app/api/create-portal-session/route.js
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
export async function POST(req) {
const { email } = await req.json();
// Get Stripe customer ID for the user
const customer = await stripe.customers.list({
email: email,
limit: 1
});
// Create portal session
const session = await stripe.billingPortal.sessions.create({
customer: customer.data[0].id,
return_url: 'https://your-site.com/account'
});
return new Response(JSON.stringify({ url: session.url }));
}
// Client component
function BillingPortalButton({ userEmail }) {
const openPortal = async () => {
const res = await fetch('/api/create-portal-session', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: userEmail })
});
const { url } = await res.json();
window.location.href = url;
};
return (
<button onClick={openPortal}>
Manage Billing
</button>
);
}
Both approaches are fully supported by the subscription helper - any changes made in the portal will trigger webhooks that automatically update your Supabase database.
const subscriptionHelper = new SubscriptionHelper({
stripeSecretKey: process.env.STRIPE_SECRET_KEY,
stripeWebhookSecret: process.env.STRIPE_WEBHOOK_SECRET,
supabaseUrl: process.env.SUPABASE_URL,
supabaseKey: process.env.SUPABASE_KEY,
table: "profiles",
emailField: "email",
subscriptionField: "subscription_status",
debug: true // Enable debug logging
});
When debug mode is enabled, you'll see detailed logs about:
- Stripe API calls and responses
- Supabase operations
- Webhook processing
- Subscription updates and cancellations
- Error details
This project is licensed under the MIT License. See the LICENSE.md file for details.
Contributions are welcome! Feel free to fork the repository and submit pull requests.
Author Created by Richardson Dackam. Follow me on GitHub.