stripe-integration

Production-ready Stripe integration for SaaS billing.

Safety Notice

This listing is imported from skills.sh public index metadata. Review upstream SKILL.md and repository scripts before running.

Copy this and send it to your AI assistant to learn

Install skill "stripe-integration" with this command: npx skills add dadbodgeoff/drift/dadbodgeoff-drift-stripe-integration

Stripe Integration

Production-ready Stripe integration for SaaS billing.

When to Use This Skill

  • Adding subscription billing to your SaaS

  • Implementing usage-based pricing

  • Setting up customer self-service portal

  • Handling payment webhooks securely

Architecture Overview

┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ Frontend │────▶│ Backend │────▶│ Stripe │ │ Checkout │ │ Webhooks │◀────│ Events │ └─────────────┘ └─────────────┘ └─────────────┘ │ ▼ ┌─────────────┐ │ Database │ │ (sync'd) │ └─────────────┘

Environment Setup

.env

STRIPE_SECRET_KEY=sk_test_... STRIPE_PUBLISHABLE_KEY=pk_test_... STRIPE_WEBHOOK_SECRET=whsec_... STRIPE_PRICE_FREE=price_... STRIPE_PRICE_PRO=price_... STRIPE_PRICE_ENTERPRISE=price_...

TypeScript Implementation

Stripe Client Setup

// lib/stripe.ts import Stripe from 'stripe';

export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { apiVersion: '2023-10-16', typescript: true, });

export const PRICES = { free: process.env.STRIPE_PRICE_FREE!, pro: process.env.STRIPE_PRICE_PRO!, enterprise: process.env.STRIPE_PRICE_ENTERPRISE!, } as const;

export type PriceTier = keyof typeof PRICES;

Create Checkout Session

// api/create-checkout.ts import { stripe, PRICES, PriceTier } from '@/lib/stripe';

interface CreateCheckoutParams { userId: string; email: string; tier: PriceTier; successUrl: string; cancelUrl: string; }

export async function createCheckoutSession({ userId, email, tier, successUrl, cancelUrl, }: CreateCheckoutParams): Promise<string> { // Get or create Stripe customer let customer = await getStripeCustomer(userId);

if (!customer) { customer = await stripe.customers.create({ email, metadata: { userId }, }); await saveStripeCustomerId(userId, customer.id); }

const session = await stripe.checkout.sessions.create({ customer: customer.id, mode: 'subscription', payment_method_types: ['card'], line_items: [ { price: PRICES[tier], quantity: 1, }, ], success_url: ${successUrl}?session_id={CHECKOUT_SESSION_ID}, cancel_url: cancelUrl, subscription_data: { metadata: { userId }, }, allow_promotion_codes: true, });

return session.url!; }

Webhook Handler

// api/webhooks/stripe.ts import { stripe } from '@/lib/stripe'; import { headers } from 'next/headers';

const WEBHOOK_SECRET = process.env.STRIPE_WEBHOOK_SECRET!;

export async function POST(req: Request) { const body = await req.text(); const signature = headers().get('stripe-signature')!;

let event: Stripe.Event;

try { event = stripe.webhooks.constructEvent(body, signature, WEBHOOK_SECRET); } catch (err) { console.error('Webhook signature verification failed:', err); return new Response('Invalid signature', { status: 400 }); }

try { switch (event.type) { case 'checkout.session.completed': await handleCheckoutComplete(event.data.object); break;

  case 'customer.subscription.updated':
    await handleSubscriptionUpdate(event.data.object);
    break;
    
  case 'customer.subscription.deleted':
    await handleSubscriptionCanceled(event.data.object);
    break;
    
  case 'invoice.payment_failed':
    await handlePaymentFailed(event.data.object);
    break;
    
  default:
    console.log(`Unhandled event type: ${event.type}`);
}

return new Response('OK', { status: 200 });

} catch (err) { console.error('Webhook handler error:', err); return new Response('Webhook handler failed', { status: 500 }); } }

async function handleCheckoutComplete(session: Stripe.Checkout.Session) { const userId = session.metadata?.userId; const subscriptionId = session.subscription as string;

if (!userId || !subscriptionId) return;

const subscription = await stripe.subscriptions.retrieve(subscriptionId); const priceId = subscription.items.data[0]?.price.id; const tier = Object.entries(PRICES).find(([, id]) => id === priceId)?.[0] || 'free';

await updateUserSubscription(userId, { stripeSubscriptionId: subscriptionId, stripeCustomerId: session.customer as string, tier, status: subscription.status, currentPeriodEnd: new Date(subscription.current_period_end * 1000), }); }

async function handleSubscriptionUpdate(subscription: Stripe.Subscription) { const userId = subscription.metadata?.userId; if (!userId) return;

const priceId = subscription.items.data[0]?.price.id; const tier = Object.entries(PRICES).find(([, id]) => id === priceId)?.[0] || 'free';

await updateUserSubscription(userId, { tier, status: subscription.status, currentPeriodEnd: new Date(subscription.current_period_end * 1000), cancelAtPeriodEnd: subscription.cancel_at_period_end, }); }

async function handleSubscriptionCanceled(subscription: Stripe.Subscription) { const userId = subscription.metadata?.userId; if (!userId) return;

await updateUserSubscription(userId, { tier: 'free', status: 'canceled', stripeSubscriptionId: null, }); }

async function handlePaymentFailed(invoice: Stripe.Invoice) { const customerId = invoice.customer as string; const user = await getUserByStripeCustomerId(customerId);

if (user) { await sendPaymentFailedEmail(user.email, { amount: invoice.amount_due / 100, nextRetry: invoice.next_payment_attempt ? new Date(invoice.next_payment_attempt * 1000) : null, }); } }

Customer Portal

// api/create-portal.ts export async function createPortalSession(userId: string): Promise<string> { const user = await getUser(userId);

if (!user?.stripeCustomerId) { throw new Error('No Stripe customer found'); }

const session = await stripe.billingPortal.sessions.create({ customer: user.stripeCustomerId, return_url: ${process.env.NEXT_PUBLIC_URL}/settings/billing, });

return session.url; }

Python Implementation

FastAPI Webhook Handler

webhooks/stripe.py

import stripe from fastapi import APIRouter, Request, HTTPException, Header

router = APIRouter() stripe.api_key = settings.STRIPE_SECRET_KEY

@router.post("/webhooks/stripe") async def stripe_webhook( request: Request, stripe_signature: str = Header(None), ): payload = await request.body()

try:
    event = stripe.Webhook.construct_event(
        payload,
        stripe_signature,
        settings.STRIPE_WEBHOOK_SECRET,
    )
except stripe.error.SignatureVerificationError:
    raise HTTPException(status_code=400, detail="Invalid signature")

handlers = {
    "checkout.session.completed": handle_checkout_complete,
    "customer.subscription.updated": handle_subscription_update,
    "customer.subscription.deleted": handle_subscription_canceled,
    "invoice.payment_failed": handle_payment_failed,
}

handler = handlers.get(event["type"])
if handler:
    await handler(event["data"]["object"])

return {"status": "ok"}

async def handle_checkout_complete(session: dict): user_id = session.get("metadata", {}).get("userId") subscription_id = session.get("subscription")

if not user_id or not subscription_id:
    return

subscription = stripe.Subscription.retrieve(subscription_id)
price_id = subscription["items"]["data"][0]["price"]["id"]
tier = PRICE_TO_TIER.get(price_id, "free")

await update_user_subscription(
    user_id=user_id,
    stripe_subscription_id=subscription_id,
    stripe_customer_id=session["customer"],
    tier=tier,
    status=subscription["status"],
    current_period_end=datetime.fromtimestamp(
        subscription["current_period_end"]
    ),
)

Frontend Checkout Button

// components/CheckoutButton.tsx 'use client';

import { useState } from 'react';

interface CheckoutButtonProps { tier: 'pro' | 'enterprise'; children: React.ReactNode; }

export function CheckoutButton({ tier, children }: CheckoutButtonProps) { const [loading, setLoading] = useState(false);

const handleCheckout = async () => { setLoading(true); try { const response = await fetch('/api/create-checkout', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ tier }), });

  const { url } = await response.json();
  window.location.href = url;
} catch (error) {
  console.error('Checkout error:', error);
  setLoading(false);
}

};

return ( <button onClick={handleCheckout} disabled={loading}> {loading ? 'Loading...' : children} </button> ); }

Database Schema

-- users table additions ALTER TABLE users ADD COLUMN stripe_customer_id TEXT; ALTER TABLE users ADD COLUMN stripe_subscription_id TEXT; ALTER TABLE users ADD COLUMN subscription_tier TEXT DEFAULT 'free'; ALTER TABLE users ADD COLUMN subscription_status TEXT DEFAULT 'active'; ALTER TABLE users ADD COLUMN current_period_end TIMESTAMP; ALTER TABLE users ADD COLUMN cancel_at_period_end BOOLEAN DEFAULT FALSE;

CREATE INDEX idx_users_stripe_customer ON users(stripe_customer_id);

Testing Webhooks Locally

Install Stripe CLI

brew install stripe/stripe-cli/stripe

Login

stripe login

Forward webhooks to local server

stripe listen --forward-to localhost:3000/api/webhooks/stripe

Trigger test events

stripe trigger checkout.session.completed stripe trigger customer.subscription.updated

Best Practices

  • Always verify webhook signatures: Never trust unverified payloads

  • Make webhooks idempotent: Same event may be delivered multiple times

  • Store Stripe IDs: Keep customer/subscription IDs in your database

  • Use metadata: Pass your user IDs in metadata for easy lookup

  • Handle all subscription states: active, past_due, canceled, etc.

Common Mistakes

  • Not handling invoice.payment_failed (users don't know payment failed)

  • Trusting client-side tier selection (always verify server-side)

  • Not setting up customer portal (users can't self-manage)

  • Forgetting to sync subscription status on webhook

  • Not testing with Stripe CLI locally

Security Checklist

  • Webhook signature verification

  • HTTPS only for webhook endpoints

  • Stripe keys in environment variables

  • Customer portal configured

  • Test mode vs live mode separation

  • PCI compliance (use Stripe Elements/Checkout)

Source Transparency

This detail page is rendered from real SKILL.md content. Trust labels are metadata-based hints, not a safety guarantee.

Related Skills

Related by shared tags or category signals.

General

oauth-social-login

No summary provided by upstream source.

Repository SourceNeeds Review
General

sse-streaming

No summary provided by upstream source.

Repository SourceNeeds Review
General

multi-tenancy

No summary provided by upstream source.

Repository SourceNeeds Review
General

deduplication

No summary provided by upstream source.

Repository SourceNeeds Review