Shipping Stripe: Real-World Lessons from Integrating Subscriptions into NyxCore
Join me as I recount the intense development session that brought Stripe billing to life for NyxCore, from schema design and API integration to wrestling with environment variables and tricky database migrations.
Building a SaaS product often involves a crucial, yet complex, piece of infrastructure: billing. For NyxCore, our AI-powered platform, the time had come to integrate robust subscription management. This post dives into a recent development session where we tackled exactly that – getting Stripe billing live on production, complete with all the nitty-gritty details, head-scratching moments, and hard-won lessons.
Our goal was clear: implement a full Stripe billing integration, enabling users to manage subscriptions and unlock features. We also had an eye on enhancing our persona evaluation system, but billing was the immediate, high-priority target.
The Billing Blueprint: From Schema to Subscription
Getting Stripe up and running isn't just about dropping in a few API calls. It requires a holistic approach, touching nearly every layer of your application. Here's how we structured our integration for NyxCore:
-
Database Schema (
prisma/schema.prisma): The foundation. We introducedPlanandSubscriptionmodels to track available plans and user subscriptions, linking them directly to our existingTenantmodel. This is where we define what a 'plan' means to our application – its ID, price, and future feature flags.typescriptmodel Plan { id String @id name String stripeId String? @unique // Stripe Product ID priceId String? @unique // Stripe Price ID price Int // Price in cents currency String features Json @default("[]") // For future feature gating createdAt DateTime @default(now()) updatedAt DateTime @updatedAt subscriptions Subscription[] } model Subscription { id String @id @default(cuid()) tenantId String @unique tenant Tenant @relation(fields: [tenantId], references: [id]) planId String plan Plan @relation(fields: [planId], references: [id]) stripeSubscriptionId String @unique // Stripe's subscription ID stripeCustomerId String @unique // Stripe's customer ID stripeCurrentPeriodEnd DateTime? // When the current period ends status String // e.g., 'active', 'canceled' createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } -
Stripe SDK Initialization (
src/lib/stripe.ts): To interact with Stripe, we needed to initialize their SDK. We opted for a lazy-initialized singleton pattern usinggetStripe()to ensure the SDK is only instantiated when needed and that environment variables are available. (More on why this was crucial in the "Pain Log"). -
Core Billing Logic (
src/server/services/billing-service.ts): This service became the brain of our billing operations. It handles:- Creating Stripe Checkout Sessions for new subscriptions.
- Generating Stripe Customer Portal Sessions for users to manage existing subscriptions (update payment methods, cancel, etc.).
- Crucially, processing Stripe webhook events to keep our database in sync with Stripe's state changes (e.g.,
checkout.session.completed,invoice.payment_succeeded,customer.subscription.deleted).
-
API Endpoints (
src/server/trpc/routers/billing.ts): We expose our billing functionality via tRPC procedures. This provides a type-safe API for our frontend to:getPlans: Fetch available subscription plans.getSubscription: Retrieve a user's current subscription status.createCheckoutSession: Initiate a new subscription checkout.createPortalSession: Redirect users to the Stripe Customer Portal.
-
Webhook Endpoint (
src/app/api/v1/webhooks/stripe/route.ts): A dedicated API route to receive and verify Stripe webhook events. Signature verification is paramount here to ensure incoming requests are genuinely from Stripe and haven't been tampered with. -
Dashboard UI (
src/app/(dashboard)/dashboard/settings/billing/page.tsx): The user-facing interface where users can view their current plan, upgrade, downgrade, or manage their subscription details.
With stripe@20.4.1 installed and all these pieces wired up, we had a functional billing pipeline. We manually inserted our initial "nyxCore Pro" plan (25 EUR/month) into the database and pushed the entire system to production. Commit 70ae1f0 marked the moment Stripe billing went live.
Navigating the Minefield: Lessons from the Trenches
No significant feature deployment goes without its share of challenges. These "pain points" are often the most valuable learning experiences.
Lesson 1: Environment Variables & Build-Time Woes
The Problem: I initially tried to initialize the Stripe SDK at the top level of src/lib/stripe.ts, throwing an error if STRIPE_SECRET_KEY was missing. This is a common pattern for mandatory environment variables.
// Initial (problematic) approach
const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY;
if (!STRIPE_SECRET_KEY) {
throw new Error("STRIPE_SECRET_KEY is not set.");
}
export const stripe = new Stripe(STRIPE_SECRET_KEY, {
apiVersion: "2023-10-16", // Example API version
});
However, when running docker build, Next.js performs static analysis and page data collection. At this stage, the full runtime environment variables aren't always available, leading to build failures.
The Fix (Lazy Initialization): The solution was to lazy-initialize the Stripe SDK using a function that only creates the Stripe instance when getStripe() is actually called. This ensures the environment variables are available at runtime, not build time.
// Working solution: Lazy initialization
import Stripe from 'stripe';
let stripeSingleton: Stripe | undefined;
export function getStripe() {
if (!stripeSingleton) {
const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY;
if (!STRIPE_SECRET_KEY) {
throw new Error("STRIPE_SECRET_KEY is not set.");
}
stripeSingleton = new Stripe(STRIPE_SECRET_KEY, {
apiVersion: "2023-10-16", // Always specify API version!
});
}
return stripeSingleton;
}
export function getStripeWebhookSecret() {
const STRIPE_WEBHOOK_SECRET = process.env.STRIPE_WEBHOOK_SECRET;
if (!STRIPE_WEBHOOK_SECRET) {
throw new Error("STRIPE_WEBHOOK_SECRET is not set.");
}
return STRIPE_WEBHOOK_SECRET;
}
Takeaway: For critical environment variables that aren't prefixed with NEXT_PUBLIC_ (which are bundled into the client-side code), always consider lazy initialization in server-side contexts, especially in environments like Next.js within Docker builds.
Lesson 2: Database Migrations - When Prisma Gets Cold Feet
The Problem: Our database already contains a workflow_insights table with a pgvector column for embeddings. When attempting to run npm run db:push (or prisma migrate deploy) to apply the new Plan and Subscription schemas, Prisma tried to drop the embedding vector column. This is a known issue with Prisma's migration engine and custom column types it doesn't fully manage, as it often sees them as "unknown" and tries to correct the schema by removing them. This would have caused catastrophic data loss.
The Fix (Direct SQL): Faced with an immediate production deployment, the safest and quickest workaround was to bypass Prisma's migration tool for these specific tables. I connected directly to the PostgreSQL instance via docker exec nyxcore-postgres-1 psql and executed the raw SQL CREATE TABLE statements for plans and subscriptions.
Takeaway: While ORMs like Prisma are fantastic, it's vital to understand their limitations, especially when dealing with advanced database features or custom types. Always review generated migration SQL (e.g., prisma migrate diff) before applying it to production, and be prepared to use raw SQL for surgical operations when necessary.
Lesson 3: Stripe API Versioning - The Ever-Evolving Landscape
The Problem: Stripe's API is constantly evolving. During webhook event handling, I encountered breaking changes in the v20 API version compared to the examples or older documentation I was referencing. Specifically:
invoice.subscriptionmoved toinvoice.parent.subscription_details.subscription.subscription.current_period_start/endmoved tosubscription.items.data[0].current_period_start/end.
These changes meant my parsing logic for webhook payloads was incorrect, leading to errors in updating our database.
The Fix: Careful debugging and cross-referencing with Stripe's official API documentation and changelogs for v20 allowed me to pinpoint the new paths for these properties.
Takeaway: Always specify the apiVersion when initializing the Stripe SDK. More importantly, when upgrading stripe SDK versions or encountering unexpected data structures, always consult Stripe's official API changelog for your specific API version. Webhook payloads can be particularly sensitive to these changes.
Lesson 4: The Art of the "Good Enough" UI
The Problem: Our UI toolkit didn't have a CardDescription component readily available, and we hadn't integrated sonner for beautiful toasts yet.
The Fix: Instead of getting bogged down in creating new UI components or integrating a new toast library, we opted for pragmatic workarounds: using @/hooks/use-toast for notifications and inline text descriptions within the billing page.
Takeaway: In rapid development or when aiming for a quick production release, sometimes "good enough" is perfectly acceptable for non-critical UI elements. Prioritize core functionality and user flow, and iterate on UI polish later. Don't let perfect be the enemy of good.
The Final Hurdle: Production Environment Variables
Even with the code deployed, there was one last critical step: ensuring the production server had the necessary Stripe environment variables (STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, NEXT_PUBLIC_APP_URL) correctly configured in /opt/nyxcore/.env.production. Without these, the billing page would error out. This often comes down to coordination with DevOps or ensuring automated deployment pipelines handle secrets correctly.
What's Next for NyxCore?
With core billing now live and functional, our immediate next steps include:
- Ensuring Env Vars are Live: Confirming the environment variables are correctly loaded and restarting the container.
- Enhancing Persona Evaluations: Moving back to our secondary goal, we'll be allowing users to select specific providers and models for persona evaluations, moving beyond our current auto-fallback logic. This involves updating
persona-evaluator.tsand thepersonastRPC router. - Expanding Plans: Introducing "Free" and "Enterprise" tiers to complement our "Pro" plan.
- Feature Gating: Wiring up our
Plan.featureFlagsto dynamically control access to features based on a user's subscription.
This session was a whirlwind of coding, debugging, and problem-solving, culminating in a live, production-ready billing system. It underscores the reality of development: a constant dance between ideal solutions and pragmatic workarounds, all while learning and adapting.
{
"thingsDone": [
"Implemented full Stripe billing pipeline (Prisma models, Stripe SDK, billing service, tRPC procedures, webhook endpoint, dashboard UI)",
"Installed stripe@20.4.1 dependency",
"Created plans and subscriptions tables on production via direct SQL",
"Inserted initial nyxCore Pro plan data",
"Deployed to production"
],
"pains": [
"Stripe SDK initialization failing due to env vars not available at Docker build time",
"Prisma db:push attempting to drop pgvector column on workflow_insights table",
"Stripe v20 breaking changes requiring webhook payload parsing adjustments",
"Missing CardDescription component and sonner package leading to minor UI compromises",
"Pending Stripe env vars configuration on production server"
],
"successes": [
"Successfully implemented lazy initialization for Stripe SDK",
"Successfully applied database schema changes using direct SQL",
"Adapted to Stripe v20 API changes and correctly parsed webhooks",
"Deployed a functional billing system to production",
"Established a clear roadmap for next steps"
],
"techStack": [
"Next.js",
"tRPC",
"Prisma",
"Stripe",
"TypeScript",
"PostgreSQL",
"Docker",
"Vercel (implied by Next.js/deployment patterns)"
]
}