Payments Recipe ‐ Stripe - spinningideas/resources GitHub Wiki
Other General Guides For Reference:
- Initial Setup
- Backend Implementation
- Frontend Implementation
- Webhook Integration
- Testing and Deployment
- Create a Stripe account at stripe.com
- Navigate to the Developers section in your Stripe dashboard
- Get your API keys (both publishable and secret keys)
- Store these keys securely in your environment variables:
-
STRIPE_PUBLISHABLE_KEY
: For frontend use -
STRIPE_SECRET_KEY
: For backend use only
-
- In your Stripe dashboard, navigate to Products → Create
- Create monthly subscription product:
- Set name, description, and pricing
- Configure billing cycle (monthly)
- Set up any trial periods if needed
- Create annual subscription product:
- Set name, description, and pricing
- Configure billing cycle (annual)
- Consider adding a discount compared to monthly plan
- Note the
price_id
values for each product (you'll need these later)
- In Stripe dashboard, go to Developers → Webhooks
- Add an endpoint URL (your backend webhook URL)
- Generate a webhook signing secret
- Select events to listen for:
checkout.session.completed
customer.subscription.created
customer.subscription.updated
customer.subscription.deleted
invoice.payment_succeeded
invoice.payment_failed
- Save webhook configuration
- Store the webhook secret as
STRIPE_WEBHOOK_SECRET
in your environment variables
Create or update your .env
file with:
STRIPE_PUBLISHABLE_KEY=pk_test_your_publishable_key
STRIPE_SECRET_KEY=sk_test_your_secret_key
STRIPE_WEBHOOK_SECRET=whsec_your_webhook_secret
-- Store user subscription information
CREATE TABLE subscriptions (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES users(id),
stripe_subscription_id TEXT NOT NULL,
stripe_customer_id TEXT NOT NULL,
stripe_price_id TEXT,
product_id TEXT,
status TEXT NOT NULL,
current_period_start TIMESTAMP WITH TIME ZONE,
current_period_end TIMESTAMP WITH TIME ZONE,
cancel_at_period_end BOOLEAN DEFAULT FALSE,
amount INTEGER,
currency TEXT,
interval TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT now(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT now()
);
-- For webhook event auditing
CREATE TABLE webhook_events (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
type TEXT NOT NULL,
stripe_event_id TEXT,
created_at TIMESTAMP WITH TIME ZONE NOT NULL,
data JSONB NOT NULL,
processed_at TIMESTAMP WITH TIME ZONE DEFAULT now()
);
-
subscriptions - Stores all subscription data from Stripe
-
stripe_subscription_id
: The unique ID from Stripe for this subscription -
stripe_customer_id
: The Stripe customer ID -
user_id
: Reference to your user -
status
: Current status (active, canceled, etc.) -
current_period_start/end
: Current billing period -
cancel_at_period_end
: Whether subscription will cancel at period end
-
-
webhook_events - Audit log for all webhook events
- Helps with debugging and tracking webhook history
- Prevents duplicate processing of events
- Useful for reconciliation and troubleshooting
npm install stripe express
# or if using yarn
yarn add stripe express
// server/routes/stripe.js
const express = require('express');
const router = express.Router();
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
router.post('/create-checkout-session', async (req, res) => {
try {
const { priceId, userId } = req.body;
if (!priceId || !userId) {
return res.status(400).json({ error: 'Price ID and User ID are required' });
}
// Verify user exists in your database
const user = await getUserById(userId);
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
// Check if user already has a Stripe customer ID
let customerId = user.stripe_customer_id;
if (!customerId) {
// Create a new customer in Stripe
const customer = await stripe.customers.create({
email: user.email,
metadata: {
userId: userId
}
});
customerId = customer.id;
// Save customer ID to your database
await updateUserStripeCustomerId(userId, customerId);
}
// Create checkout session
const session = await stripe.checkout.sessions.create({
customer: customerId,
payment_method_types: ['card'],
line_items: [
{
price: priceId,
quantity: 1,
},
],
mode: 'subscription',
success_url: `${req.headers.origin}/subscription/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${req.headers.origin}/subscription/cancel`,
metadata: {
userId: userId
}
});
res.json({ url: session.url });
} catch (error) {
console.error('Error creating checkout session:', error);
res.status(500).json({ error: error.message });
}
});
module.exports = router;
// server/routes/webhook.js
const express = require('express');
const router = express.Router();
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
// This must be raw for Stripe webhook verification
router.post('/webhook', express.raw({type: 'application/json'}), async (req, res) => {
const sig = req.headers['stripe-signature'];
let event;
try {
// Verify webhook signature
event = stripe.webhooks.constructEvent(
req.body,
sig,
process.env.STRIPE_WEBHOOK_SECRET
);
} catch (err) {
console.error(`Webhook Error: ${err.message}`);
return res.status(400).send(`Webhook Error: ${err.message}`);
}
// Store event for auditing
await storeWebhookEvent({
type: event.type,
stripe_event_id: event.id,
created_at: new Date(event.created * 1000),
data: event.data.object
});
// Handle the event based on type
try {
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object;
// Extract user ID from metadata
const userId = session.metadata?.userId;
if (!userId) {
throw new Error('User ID not found in session metadata');
}
// Get subscription details
const subscription = await stripe.subscriptions.retrieve(session.subscription);
// Store subscription in database
await createSubscription({
user_id: userId,
stripe_subscription_id: subscription.id,
stripe_customer_id: subscription.customer,
stripe_price_id: subscription.items.data[0].price.id,
product_id: subscription.items.data[0].price.product,
status: subscription.status,
current_period_start: new Date(subscription.current_period_start * 1000),
current_period_end: new Date(subscription.current_period_end * 1000),
cancel_at_period_end: subscription.cancel_at_period_end,
amount: subscription.items.data[0].price.unit_amount,
currency: subscription.items.data[0].price.currency,
interval: subscription.items.data[0].price.recurring.interval
});
// Update user's premium status
await updateUserPremiumStatus(userId, true);
break;
}
case 'customer.subscription.updated': {
const subscription = event.data.object;
// Find user by customer ID
const user = await getUserByStripeCustomerId(subscription.customer);
if (!user) {
throw new Error('User not found for customer ID: ' + subscription.customer);
}
// Update subscription in database
await updateSubscription(subscription.id, {
status: subscription.status,
current_period_start: new Date(subscription.current_period_start * 1000),
current_period_end: new Date(subscription.current_period_end * 1000),
cancel_at_period_end: subscription.cancel_at_period_end
});
break;
}
case 'customer.subscription.deleted': {
const subscription = event.data.object;
// Find user by customer ID
const user = await getUserByStripeCustomerId(subscription.customer);
if (!user) {
throw new Error('User not found for customer ID: ' + subscription.customer);
}
// Update subscription as canceled
await updateSubscription(subscription.id, {
status: 'canceled',
cancel_at_period_end: false
});
// Check if user has any active subscriptions left
const hasActiveSubscription = await checkUserHasActiveSubscription(user.id);
if (!hasActiveSubscription) {
// No active subscriptions left, remove premium status
await updateUserPremiumStatus(user.id, false);
}
break;
}
case 'invoice.payment_succeeded': {
const invoice = event.data.object;
// Handle successful payment
// You might want to send a receipt email or update payment history
break;
}
case 'invoice.payment_failed': {
const invoice = event.data.object;
// Handle failed payment
// You might want to notify the user or take action on their account
break;
}
default:
console.log(`Unhandled event type: ${event.type}`);
}
// Return success response
res.json({ received: true });
} catch (error) {
console.error(`Error processing webhook: ${error.message}`);
res.status(500).json({ error: error.message });
}
});
module.exports = router;
npm install @stripe/react-stripe-js @stripe/stripe-js
# or if using yarn
yarn add @stripe/react-stripe-js @stripe/stripe-js
// src/App.jsx or src/index.jsx
import React from 'react';
import { loadStripe } from '@stripe/stripe-js';
import { Elements } from '@stripe/react-stripe-js';
import Routes from './Routes';
// Load Stripe outside of component render to avoid recreating Stripe object
const stripePromise = loadStripe(process.env.REACT_APP_STRIPE_PUBLISHABLE_KEY);
function App() {
return (
<Elements stripe={stripePromise}>
<Routes />
</Elements>
);
}
export default App;
// src/components/SubscriptionPlans.jsx
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
// Replace with your actual Stripe price IDs
const SUBSCRIPTION_PLANS = [
{
name: 'Monthly',
description: 'Access to all premium features',
price: '$9.99',
interval: 'month',
priceId: 'price_monthly_id_here',
features: ['Feature 1', 'Feature 2', 'Feature 3']
},
{
name: 'Annual',
description: 'Save 20% with yearly billing',
price: '$95.88',
interval: 'year',
priceId: 'price_annual_id_here',
features: ['Feature 1', 'Feature 2', 'Feature 3', 'Priority support']
}
];
export function SubscriptionPlans() {
const navigate = useNavigate();
const [loading, setLoading] = useState(false);
const handleSubscribe = async (priceId) => {
try {
setLoading(true);
// Get current user
const user = await getCurrentUser();
if (!user) {
alert('Please sign in to subscribe');
navigate('/login');
return;
}
// Call your backend to create checkout session
const response = await fetch('/api/create-checkout-session', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
priceId,
userId: user.id
})
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || 'Failed to create checkout session');
}
const { url } = await response.json();
// Redirect to Stripe checkout
window.location.href = url;
} catch (error) {
console.error('Error creating checkout session:', error);
alert('Failed to create checkout session. Please try again.');
} finally {
setLoading(false);
}
};
return (
<div className="subscription-plans">
<h2>Choose a Subscription Plan</h2>
<div className="plans-container">
{SUBSCRIPTION_PLANS.map((plan) => (
<div key={plan.priceId} className="plan-card">
<h3>{plan.name}</h3>
<p>{plan.description}</p>
<div className="price">
<span className="amount">{plan.price}</span>
<span className="interval">/{plan.interval}</span>
</div>
<ul className="features">
{plan.features.map((feature, index) => (
<li key={index}>{feature}</li>
))}
</ul>
<button
onClick={() => handleSubscribe(plan.priceId)}
className="subscribe-button"
disabled={loading}
>
{loading ? 'Processing...' : 'Subscribe'}
</button>
</div>
))}
</div>
</div>
);
}
// src/components/SubscriptionManagement.jsx
import React, { useState, useEffect } from 'react';
export function SubscriptionManagement() {
const [subscription, setSubscription] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [cancelLoading, setCancelLoading] = useState(false);
useEffect(() => {
async function fetchSubscription() {
try {
const user = await getCurrentUser();
if (!user) {
setError('User not authenticated');
setLoading(false);
return;
}
const response = await fetch(`/api/subscriptions/${user.id}`);
if (!response.ok) {
throw new Error('Failed to fetch subscription');
}
const data = await response.json();
setSubscription(data.subscription);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}
fetchSubscription();
}, []);
const handleCancelSubscription = async () => {
if (!window.confirm('Are you sure you want to cancel your subscription?')) {
return;
}
try {
setCancelLoading(true);
const response = await fetch(`/api/subscriptions/${subscription.stripe_subscription_id}/cancel`, {
method: 'POST',
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || 'Failed to cancel subscription');
}
const updatedSubscription = await response.json();
setSubscription(updatedSubscription.subscription);
alert('Your subscription has been canceled and will end at the end of the current billing period.');
} catch (error) {
console.error('Error canceling subscription:', error);
alert('Failed to cancel subscription. Please try again.');
} finally {
setCancelLoading(false);
}
};
if (loading) return <div>Loading subscription details...</div>;
if (error) return <div>Error: {error}</div>;
if (!subscription) return <div>No active subscription found.</div>;
return (
<div className="subscription-management">
<h2>Subscription Details</h2>
<div className="subscription-info">
<p><strong>Status:</strong> {subscription.status}</p>
<p><strong>Plan:</strong> {subscription.product_name}</p>
<p><strong>Amount:</strong> {formatCurrency(subscription.amount, subscription.currency)}</p>
<p><strong>Billing Period:</strong> {formatDate(subscription.current_period_start)} to {formatDate(subscription.current_period_end)}</p>
{subscription.cancel_at_period_end ? (
<p className="cancellation-notice">
Your subscription will end on {formatDate(subscription.current_period_end)}
</p>
) : (
<button
onClick={handleCancelSubscription}
className="cancel-button"
disabled={cancelLoading}
>
{cancelLoading ? 'Processing...' : 'Cancel Subscription'}
</button>
)}
</div>
</div>
);
}
function formatDate(dateString) {
return new Date(dateString).toLocaleDateString();
}
function formatCurrency(amount, currency) {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: currency || 'USD',
}).format(amount / 100);
}
Stripe uses webhook signatures to verify that events are sent by Stripe and not by a third party:
const event = stripe.webhooks.constructEvent(
req.body,
req.headers['stripe-signature'],
process.env.STRIPE_WEBHOOK_SECRET
);
-
checkout.session.completed
: When a customer completes checkout- Store subscription details
- Update user's premium status
-
customer.subscription.updated
: When subscription details change- Update stored subscription information
- Handle plan changes if applicable
-
customer.subscription.deleted
: When a subscription is canceled- Mark subscription as canceled
- Update user's premium status if needed
-
invoice.payment_succeeded
: When a payment is successful- Update payment history
- Send receipt email (optional)
-
invoice.payment_failed
: When a payment fails- Notify user
- Take appropriate action on account
- Store webhook event IDs to prevent duplicate processing
- Add logging for all webhook events for debugging
- Implement error handling for failed webhook processing
- Use Stripe CLI for local webhook testing:
stripe listen --forward-to localhost:3000/api/webhook
- Use Stripe test cards for simulating payments:
- Success:
4242 4242 4242 4242
- Decline:
4000 0000 0000 0002
- Success:
- Use Stripe test mode for all testing
- Test the full subscription flow
- Verify webhook events are properly received and processed
- Switch to Stripe live mode and update API keys
- Configure production webhook endpoints
- Implement proper error handling and monitoring
- Set up monitoring for webhook failures
- Create alerts for failed subscription events
- Implement a dashboard to track subscription metrics
- Stripe.js React Documentation
- Stripe Server-Side Node.js Documentation
- Stripe Checkout Documentation
- Stripe Webhooks Documentation
- Stripe Testing Documentation
---
https://medium.com/@hikmatullahmcs/here-is-a-step-by-step-guide-on-how-to-integrate-stripe-with-a-node-js-77a25adf7064