
Stripe Integration Expert
- 106 installs
- 451 repo stars
- Updated July 21, 2026
- borghei/claude-skills
stripe-integration-expert is a Claude skill that builds production Stripe billing for SaaS including subscriptions, checkout, webhooks, and SCA.
About
Stripe-integration-expert guides Claude to build production Stripe billing for SaaS: subscription lifecycle, checkout sessions, proration, usage-based metered billing, dunning, and SCA/3D Secure. A developer uses it when adding or hardening billing and webhook endpoints. It provides idempotent webhook patterns for Next.js, Express, and Django and a Stripe CLI test matrix.
- Builds SaaS Stripe billing: subscriptions, checkout, proration, usage-based billing, SCA
- Idempotent webhook handlers with signature verification and event dedup
- Patterns for Next.js, Express, and Django plus Stripe CLI local testing
Stripe Integration Expert by the numbers
- 106 all-time installs (skills.sh)
- Ranked #2,965 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
stripe-integration-expert capabilities & compatibility
Free skill; needs Stripe API keys and webhook secret to run against Stripe.
- Capabilities
- stripe billing · webhook handling · subscription management
- Works with
- stripe
- Use cases
- api development
- Pricing
- Free
What stripe-integration-expert says it does
Provides patterns for Next.js, Express, and Django with emphasis on real-world edge cases.
npx skills add https://github.com/borghei/claude-skills --skill stripe-integration-expertAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 106 |
|---|---|
| repo stars | ★ 451 |
| Last updated | July 21, 2026 |
| Repository | borghei/claude-skills ↗ |
What it does
Build or harden SaaS Stripe billing including subscriptions, checkout, proration, and idempotent webhooks.
Who is it for?
Developers building SaaS subscription billing or hardening a Stripe webhook endpoint on Next.js, Express, or Django.
Skip if: Stripe Connect marketplace payouts, one-time e-commerce payments without subscriptions, or tax calculation and remittance.
When should I use this skill?
Building SaaS billing, handling Stripe webhooks, or testing with the Stripe CLI.
What you get
Production Stripe integration with idempotent webhooks, subscription state machine, and SCA compliance.
- subscription state machine
- idempotent webhook handler
- checkout session
By the numbers
- 3 supported frameworks (Next.js, Express, Django)
Files
Stripe Integration Expert
The agent builds production-grade Stripe integrations for SaaS billing: subscription lifecycle management with trials and proration, idempotent webhook handlers, usage-based metered billing, Checkout sessions, Customer Portal, dunning recovery, and SCA/3D Secure compliance. Provides patterns for Next.js, Express, and Django with emphasis on real-world edge cases.
Core Capabilities
- Checkout & client setup — pinned-version Stripe client, centralized plan config, Checkout sessions with trials, tax collection, and promo codes
- Subscription lifecycle — a state machine covering trialing → active → past_due → canceled, plus upgrades/downgrades with proration previews and reactivation
- Idempotent webhooks — signature verification, event dedup, re-fetch-from-API handlers, and retry-safe processing
- Usage-based billing & gating — metered usage records, feature gating by plan, and grace-period access logic
- Dunning & SCA — payment-failure email sequences and PSD2 / 3D Secure authentication flows
- Local testing — Stripe CLI webhook forwarding, event triggers, and test card matrix
When to Use
- Building SaaS subscription billing from scratch on Next.js, Express, or Django
- Adding plan upgrades/downgrades with correct proration behavior
- Hardening or debugging a webhook endpoint for idempotency and retry safety
- Implementing metered/usage-based billing or feature gating by plan
- Adding dunning recovery or European SCA/3D Secure compliance
Clarify First
Before building the integration, confirm these inputs. If any is unknown or vague, ASK — do not assume:
- [ ] Framework — Next.js / Express / Django (the handler and client patterns differ per stack)
- [ ] Billing model — flat subscription / metered usage-based / trials + proration (shapes the subscription state machine and code)
- [ ] Scope — which piece: Checkout, idempotent webhooks, dunning, or SCA/3D Secure (selects the reference and code generated)
Stop rule: ask only the 2-3 that most change the output. If the user says "just draft it," proceed and list your assumptions at the top of the artifact.
References
Load the reference that matches the task — keep this file lean and pull detail on demand:
- [references/payment-flows.md](references/payment-flows.md) — Stripe client setup, plan config, Checkout sessions, Customer Portal, and SCA/3D Secure. Read when wiring up the client or building the checkout/portal redirects.
- [references/subscriptions.md](references/subscriptions.md) — lifecycle state-machine diagram, upgrade/downgrade/preview/cancel code, usage-based billing, feature gating, and the Prisma schema. Read when modeling subscription state or implementing plan changes.
- [references/webhooks.md](references/webhooks.md) — the full idempotent webhook handler with signature verification and every event handler. Read when building or auditing the webhook endpoint.
- [references/testing-and-troubleshooting.md](references/testing-and-troubleshooting.md) — Stripe CLI testing, common pitfalls, troubleshooting table, and success criteria. Read when testing locally or diagnosing a billing bug.
Related Skills
| Skill | Use When |
|---|---|
| ab-test-setup | Testing pricing page variants and checkout flows |
| analytics-tracking | Tracking checkout and subscription conversion events |
| email-template-builder | Building dunning and billing notification emails |
| api-design-reviewer | Reviewing your billing API endpoints |
Scope & Limitations
This skill covers:
- Stripe Checkout, Subscriptions, and Customer Portal integration for SaaS billing
- Webhook handling with idempotency, signature verification, and retry safety
- Usage-based (metered) billing, proration previews, and plan change workflows
- SCA/3D Secure compliance for European payment regulations (PSD2)
This skill does NOT cover:
- Stripe Connect (marketplace payouts, multi-party payments) -- see platform-specific Stripe Connect documentation
- One-time payment flows without subscriptions (e.g., e-commerce product purchases)
- Tax calculation and remittance (Stripe Tax configuration, VAT/GST filing) -- see
ra-qm-team/compliance skills for regulatory guidance - Payment fraud detection and dispute management (Stripe Radar rules, chargeback workflows) -- see
skill-security-auditorfor security review patterns
Integration Points
| Skill | Integration | Data Flow |
|---|---|---|
| api-design-reviewer | Review billing API endpoints for REST conventions, error handling, and rate limiting | Billing route definitions --> API review checklist --> validated endpoint contracts |
| database-schema-designer | Design and validate the Prisma schema for Stripe customer, subscription, and event tracking tables | Schema requirements --> normalized table design --> migration files |
| observability-designer | Instrument webhook handlers and checkout flows with structured logging, metrics, and alerting | Webhook events --> OpenTelemetry traces --> dashboard alerts on failure spikes |
| env-secrets-manager | Manage Stripe API keys, webhook secrets, and price IDs across dev/staging/production | Secret definitions --> encrypted vault storage --> runtime injection via env vars |
| ci-cd-pipeline-builder | Automate Stripe CLI webhook testing in CI and validate integration before deployment | Test triggers --> stripe listen in CI --> webhook handler assertions |
| runbook-generator | Create operational runbooks for billing incidents: failed webhooks, mass payment failures, subscription reconciliation | Incident scenarios --> step-by-step remediation --> escalation paths |
Payment Flows — Client Setup, Checkout, Portal & SCA
Read this when wiring up the Stripe client, building the Checkout redirect, exposing the Customer Portal, or handling European SCA/3D Secure authentication.
Stripe Client Setup
// lib/stripe.ts
import Stripe from "stripe";
if (!process.env.STRIPE_SECRET_KEY) {
throw new Error("STRIPE_SECRET_KEY is required");
}
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
apiVersion: "2024-12-18.acacia", // Pin to specific version
typescript: true,
appInfo: {
name: "your-app-name",
version: "1.0.0",
url: "https://yourapp.com",
},
});
// Centralized plan configuration
export const PLANS = {
starter: {
monthly: process.env.STRIPE_STARTER_MONTHLY_PRICE!,
yearly: process.env.STRIPE_STARTER_YEARLY_PRICE!,
limits: { projects: 5, events: 10_000 },
},
pro: {
monthly: process.env.STRIPE_PRO_MONTHLY_PRICE!,
yearly: process.env.STRIPE_PRO_YEARLY_PRICE!,
limits: { projects: -1, events: 1_000_000 }, // -1 = unlimited
},
enterprise: {
monthly: process.env.STRIPE_ENTERPRISE_MONTHLY_PRICE!,
yearly: process.env.STRIPE_ENTERPRISE_YEARLY_PRICE!,
limits: { projects: -1, events: -1 },
},
} as const;
export type PlanName = keyof typeof PLANS;
export type BillingInterval = "monthly" | "yearly";---
Checkout Session
// app/api/billing/checkout/route.ts
import { NextResponse } from "next/server";
import { stripe, PLANS, type PlanName, type BillingInterval } from "@/lib/stripe";
import { getAuthUser } from "@/lib/auth";
import { db } from "@/lib/db";
export async function POST(req: Request) {
const user = await getAuthUser();
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const { plan, interval = "monthly" } = (await req.json()) as {
plan: PlanName;
interval: BillingInterval;
};
if (!PLANS[plan]) {
return NextResponse.json({ error: "Invalid plan" }, { status: 400 });
}
const priceId = PLANS[plan][interval];
// Get or create Stripe customer (idempotent)
let customerId = user.stripeCustomerId;
if (!customerId) {
const customer = await stripe.customers.create({
email: user.email,
name: user.name || undefined,
metadata: { userId: user.id, source: "checkout" },
});
customerId = customer.id;
await db.user.update({
where: { id: user.id },
data: { stripeCustomerId: customerId },
});
}
const session = await stripe.checkout.sessions.create({
customer: customerId,
mode: "subscription",
payment_method_types: ["card"],
line_items: [{ price: priceId, quantity: 1 }],
allow_promotion_codes: true,
tax_id_collection: { enabled: true },
subscription_data: {
trial_period_days: user.hasHadTrial ? undefined : 14,
metadata: { userId: user.id, plan },
},
success_url: `${process.env.APP_URL}/dashboard?checkout=success&session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.APP_URL}/pricing`,
metadata: { userId: user.id },
});
return NextResponse.json({ url: session.url });
}---
Customer Portal
// app/api/billing/portal/route.ts
export async function POST() {
const user = await getAuthUser();
if (!user?.stripeCustomerId) {
return NextResponse.json({ error: "No billing account" }, { status: 400 });
}
const session = await stripe.billingPortal.sessions.create({
customer: user.stripeCustomerId,
return_url: `${process.env.APP_URL}/settings/billing`,
});
return NextResponse.json({ url: session.url });
}Portal configuration (must be done in Stripe Dashboard > Billing > Customer Portal):
- Enable: Update subscription, cancel subscription, update payment method
- Set cancellation flow: show pause option, require reason
- Configure plan change options: which plans can switch to which
---
SCA (Strong Customer Authentication) Compliance
Required for European customers under PSD2.
// Checkout Sessions handle SCA automatically (3D Secure)
// For existing subscriptions, handle authentication_required:
async function handlePaymentRequiresAction(invoice: Stripe.Invoice) {
if (invoice.payment_intent) {
const pi = await stripe.paymentIntents.retrieve(invoice.payment_intent as string);
if (pi.status === "requires_action") {
// Send email with link to complete authentication
await sendAuthenticationEmail(
invoice.customer_email!,
pi.next_action?.redirect_to_url?.url || `${process.env.APP_URL}/billing/authenticate`
);
}
}
}Subscriptions — Lifecycle, Plan Changes, Usage & Gating
Read this when modeling subscription state, implementing upgrades/downgrades with proration, reporting metered usage, gating features by plan, or designing the persistence schema. Every billing edge case maps to a state transition below.
Subscription Lifecycle State Machine
Understand this before writing any code. Every billing edge case maps to a state transition.
┌────────────────────────────────────────┐
│ │
┌──────────┐ paid ┌────────┐ cancel ┌──────────────┐ period_end ┌──────────┐
│ TRIALING │──────────▶│ ACTIVE │────────────▶│ CANCEL_PENDING│──────────────▶│ CANCELED │
└──────────┘ └────────┘ └──────────────┘ └──────────┘
│ │ ▲
│ │ upgrade │
│ ▼ reactivate
│ ┌──────────┐ period_end ┌────────┐ │
│ │UPGRADING │─────────────▶│ ACTIVE │ │
│ └──────────┘ (new plan) └────────┘ │
│ │
│ trial_end ┌──────────┐ 3x fail ┌──────────┐ │
└─(no payment)───▶│ PAST_DUE │───────────▶│ CANCELED │──────────────────────┘
└──────────┘ └──────────┘
│
payment_success
│
▼
┌────────┐
│ ACTIVE │
└────────┘DB status values: trialing | active | past_due | canceled | cancel_pending | paused | unpaid
---
Subscription Management
Upgrade (Immediate, Prorated)
export async function upgradeSubscription(subscriptionId: string, newPriceId: string) {
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
const currentItem = subscription.items.data[0];
return stripe.subscriptions.update(subscriptionId, {
items: [{ id: currentItem.id, price: newPriceId }],
proration_behavior: "always_invoice", // Charge difference immediately
billing_cycle_anchor: "unchanged", // Keep same billing date
});
}Downgrade (End of Period, No Proration)
export async function downgradeSubscription(subscriptionId: string, newPriceId: string) {
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
const currentItem = subscription.items.data[0];
// Schedule change for end of current period
return stripe.subscriptions.update(subscriptionId, {
items: [{ id: currentItem.id, price: newPriceId }],
proration_behavior: "none", // No refund
billing_cycle_anchor: "unchanged",
});
}Preview Proration (Show Before Confirming)
export async function previewProration(subscriptionId: string, newPriceId: string) {
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
const invoice = await stripe.invoices.createPreview({
customer: subscription.customer as string,
subscription: subscriptionId,
subscription_details: {
items: [{ id: subscription.items.data[0].id, price: newPriceId }],
proration_date: Math.floor(Date.now() / 1000),
},
});
return {
amountDue: invoice.amount_due, // In cents
credit: invoice.total < 0 ? Math.abs(invoice.total) : 0,
lineItems: invoice.lines.data.map(line => ({
description: line.description,
amount: line.amount,
})),
};
}Cancel (At Period End)
export async function cancelSubscription(subscriptionId: string) {
// Cancel at period end -- user keeps access until their paid period expires
return stripe.subscriptions.update(subscriptionId, {
cancel_at_period_end: true,
});
}
export async function reactivateSubscription(subscriptionId: string) {
// Undo pending cancellation
return stripe.subscriptions.update(subscriptionId, {
cancel_at_period_end: false,
});
}---
Usage-Based Billing
// Report metered usage
export async function reportUsage(
subscriptionItemId: string,
quantity: number,
idempotencyKey?: string,
) {
return stripe.subscriptionItems.createUsageRecord(
subscriptionItemId,
{
quantity,
timestamp: Math.floor(Date.now() / 1000),
action: "increment", // or "set" for absolute values
},
{
idempotencyKey, // Prevent double-counting on retries
}
);
}
// Middleware: track API usage per request
export async function trackApiUsage(userId: string) {
const user = await db.user.findUnique({ where: { id: userId } });
if (!user?.stripeSubscriptionId) return;
const subscription = await stripe.subscriptions.retrieve(user.stripeSubscriptionId);
const meteredItem = subscription.items.data.find(
(item) => item.price.recurring?.usage_type === "metered"
);
if (meteredItem) {
await reportUsage(meteredItem.id, 1, `${userId}-${Date.now()}`);
}
}---
Feature Gating
// lib/subscription.ts
import { PLANS, type PlanName } from "./stripe";
export function isSubscriptionActive(user: {
subscriptionStatus: string | null;
stripeCurrentPeriodEnd: Date | null;
}): boolean {
if (!user.subscriptionStatus) return false;
// Active or trialing = full access
if (["active", "trialing"].includes(user.subscriptionStatus)) return true;
// Past due: grace period until period end
if (user.subscriptionStatus === "past_due" && user.stripeCurrentPeriodEnd) {
return user.stripeCurrentPeriodEnd > new Date();
}
// Cancel pending: access until period end
if (user.subscriptionStatus === "cancel_pending" && user.stripeCurrentPeriodEnd) {
return user.stripeCurrentPeriodEnd > new Date();
}
return false;
}
export function getUserPlan(stripePriceId: string | null): PlanName | "free" {
if (!stripePriceId) return "free";
for (const [plan, config] of Object.entries(PLANS)) {
if (config.monthly === stripePriceId || config.yearly === stripePriceId) {
return plan as PlanName;
}
}
return "free";
}
export function canAccess(user: { stripePriceId: string | null }, feature: string): boolean {
const plan = getUserPlan(user.stripePriceId);
const limits = plan === "free" ? { projects: 1, events: 1000 } : PLANS[plan].limits;
// Feature-specific checks
switch (feature) {
case "unlimited_projects": return limits.projects === -1;
case "api_access": return plan !== "free" && plan !== "starter";
default: return plan !== "free";
}
}---
Database Schema (Prisma)
model User {
id String @id @default(cuid())
email String @unique
name String?
// Stripe fields
stripeCustomerId String? @unique
stripeSubscriptionId String? @unique
stripePriceId String?
stripeCurrentPeriodEnd DateTime?
subscriptionStatus String? // trialing, active, past_due, canceled, cancel_pending
cancelAtPeriodEnd Boolean @default(false)
hasHadTrial Boolean @default(false)
}
model StripeEvent {
id String @id // Stripe event ID (evt_xxx)
type String // Event type
processedAt DateTime @default(now())
@@index([type])
}Testing & Troubleshooting
Read this when testing the integration locally with the Stripe CLI, reviewing against common pitfalls, diagnosing a billing bug, or validating against success criteria before shipping.
Testing with Stripe CLI
# Install and authenticate
brew install stripe/stripe-cli/stripe
stripe login
# Forward webhooks to local server
stripe listen --forward-to localhost:3000/api/webhooks/stripe
# Trigger specific events
stripe trigger checkout.session.completed
stripe trigger customer.subscription.updated
stripe trigger invoice.payment_failed
stripe trigger customer.subscription.trial_will_end
# Test card numbers
# Success: 4242 4242 4242 4242
# Requires 3D Secure: 4000 0025 0000 3155
# Declined: 4000 0000 0000 0002
# Insufficient funds: 4000 0000 0000 9995
# Expired card: 4000 0000 0000 0069
# View recent events
stripe events list --limit 10
# Inspect a specific event
stripe events retrieve evt_xxx---
Common Pitfalls
| Pitfall | Consequence | Prevention |
|---|---|---|
| Trusting webhook event data | Stale data, race conditions | Always re-fetch from Stripe API in handlers |
| No idempotency on webhooks | Double-charges, duplicate records | Track processed event IDs in database |
| Missing metadata on checkout | Cannot link subscription to user | Always pass userId in metadata |
| Proration surprises | Users charged unexpected amounts | Always preview proration before upgrade |
Not handling past_due | Users lose access without warning | Implement dunning emails on payment failure |
| Skipping trial abuse prevention | Users create multiple accounts for free trials | Store hasHadTrial: true, check on checkout |
| Customer Portal not configured | Portal returns blank page | Enable features in Stripe Dashboard first |
| Webhook endpoint not idempotent | Stripe retries cause duplicate processing | Idempotency table with event ID dedup |
| Not pinning API version | Breaking changes on Stripe updates | Pin apiVersion in client constructor |
Ignoring trial_will_end event | Users surprised when trial ends | Send reminder email 3 days before |
---
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
| Webhook returns 400 on all events | Webhook signing secret mismatch between environments | Verify STRIPE_WEBHOOK_SECRET matches the endpoint in Stripe Dashboard; use stripe listen output secret for local dev |
| Checkout session redirects to blank page | success_url or cancel_url missing {CHECKOUT_SESSION_ID} template or pointing to wrong domain | Ensure URLs use APP_URL env var and include the session ID template literal for retrieval |
Subscription shows incomplete status | First payment requires 3D Secure but was never completed | Handle checkout.session.async_payment_failed and send the customer a link to complete authentication |
| Proration invoice charges full price instead of difference | Using create_prorations instead of always_invoice or not passing existing subscription item ID | Use always_invoice proration behavior and update the existing items[0].id rather than adding a new line item |
| Usage records return "Cannot create usage record" | Reporting usage on a non-metered price or after subscription cancellation | Confirm the price uses recurring.usage_type: "metered" and the subscription is active before reporting |
| Customer Portal shows no options | Portal configuration not enabled in Stripe Dashboard | Navigate to Stripe Dashboard > Settings > Billing > Customer Portal and enable subscription management features |
| Duplicate webhook processing despite idempotency table | markProcessed called before handler completes, then handler throws on retry | Move markProcessed to after the handler succeeds (as shown in the webhook handler pattern above) |
---
Success Criteria
- Webhook reliability: 99.9%+ webhook processing success rate with zero duplicate side effects over a 30-day window
- Checkout conversion: End-to-end checkout flow completes in under 3 seconds (redirect to Stripe and back)
- Idempotency coverage: 100% of webhook handlers are idempotent, verified by replaying the same event ID twice with no state change on the second pass
- Subscription state accuracy: Database subscription status matches Stripe source of truth within 60 seconds of any state change
- SCA compliance: All European payment flows pass 3D Secure challenges without manual intervention or dropped transactions
- Dunning recovery: Automated dunning emails recover at least 30% of failed payments within the retry window (typically 7-21 days)
- Zero hardcoded price IDs: All Stripe price IDs are sourced from environment variables, enabling test/production parity without code changes
Webhooks — Idempotent Event Handling
Read this when building or auditing the Stripe webhook endpoint. This is the most critical code in your billing system — get signature verification, idempotency, and retry safety right.
Webhook Handler (Idempotent)
This is the most critical code in your billing system. Get this right.
// app/api/webhooks/stripe/route.ts
import { NextResponse } from "next/server";
import { headers } from "next/headers";
import { stripe } from "@/lib/stripe";
import { db } from "@/lib/db";
import type Stripe from "stripe";
// Idempotency: track processed events to handle Stripe retries
async function isProcessed(eventId: string): Promise<boolean> {
return !!(await db.stripeEvent.findUnique({ where: { id: eventId } }));
}
async function markProcessed(eventId: string, type: string) {
await db.stripeEvent.create({
data: { id: eventId, type, processedAt: new Date() },
});
}
export async function POST(req: Request) {
const body = await req.text();
const signature = headers().get("stripe-signature");
if (!signature) {
return NextResponse.json({ error: "Missing signature" }, { status: 400 });
}
// Step 1: Verify webhook signature
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body, signature, process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (err) {
console.error("Webhook signature verification failed:", err);
return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
}
// Step 2: Idempotency check
if (await isProcessed(event.id)) {
return NextResponse.json({ received: true, deduplicated: true });
}
// Step 3: Handle events
try {
switch (event.type) {
case "checkout.session.completed":
await handleCheckoutCompleted(event.data.object as Stripe.Checkout.Session);
break;
case "customer.subscription.created":
case "customer.subscription.updated":
await handleSubscriptionChange(event.data.object as Stripe.Subscription);
break;
case "customer.subscription.deleted":
await handleSubscriptionDeleted(event.data.object as Stripe.Subscription);
break;
case "invoice.payment_succeeded":
await handlePaymentSucceeded(event.data.object as Stripe.Invoice);
break;
case "invoice.payment_failed":
await handlePaymentFailed(event.data.object as Stripe.Invoice);
break;
case "customer.subscription.trial_will_end":
await handleTrialEnding(event.data.object as Stripe.Subscription);
break;
default:
// Log unhandled events for monitoring
console.log(`Unhandled webhook: ${event.type}`);
}
await markProcessed(event.id, event.type);
return NextResponse.json({ received: true });
} catch (err) {
console.error(`Webhook processing failed [${event.type}]:`, err);
// Return 500 so Stripe retries. Do NOT mark as processed.
return NextResponse.json({ error: "Processing failed" }, { status: 500 });
}
}
// --- Handler implementations ---
async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
if (session.mode !== "subscription") return;
const userId = session.metadata?.userId;
if (!userId) throw new Error("Missing userId in checkout metadata");
// Always re-fetch from Stripe API -- event data may be stale
const subscription = await stripe.subscriptions.retrieve(
session.subscription as string
);
await db.user.update({
where: { id: userId },
data: {
stripeCustomerId: session.customer as string,
stripeSubscriptionId: subscription.id,
stripePriceId: subscription.items.data[0].price.id,
stripeCurrentPeriodEnd: new Date(subscription.current_period_end * 1000),
subscriptionStatus: subscription.status,
hasHadTrial: true,
},
});
}
async function handleSubscriptionChange(subscription: Stripe.Subscription) {
// Find user by subscription ID first, fall back to customer ID
const user = await db.user.findFirst({
where: {
OR: [
{ stripeSubscriptionId: subscription.id },
{ stripeCustomerId: subscription.customer as string },
],
},
});
if (!user) {
console.warn(`No user for subscription ${subscription.id}`);
return; // Don't throw -- this may be a subscription we don't manage
}
await db.user.update({
where: { id: user.id },
data: {
stripeSubscriptionId: subscription.id,
stripePriceId: subscription.items.data[0].price.id,
stripeCurrentPeriodEnd: new Date(subscription.current_period_end * 1000),
subscriptionStatus: subscription.status,
cancelAtPeriodEnd: subscription.cancel_at_period_end,
},
});
}
async function handleSubscriptionDeleted(subscription: Stripe.Subscription) {
await db.user.updateMany({
where: { stripeSubscriptionId: subscription.id },
data: {
subscriptionStatus: "canceled",
stripePriceId: null,
stripeCurrentPeriodEnd: null,
cancelAtPeriodEnd: false,
},
});
}
async function handlePaymentSucceeded(invoice: Stripe.Invoice) {
if (!invoice.subscription) return;
await db.user.updateMany({
where: { stripeSubscriptionId: invoice.subscription as string },
data: {
subscriptionStatus: "active",
stripeCurrentPeriodEnd: new Date(invoice.period_end * 1000),
},
});
}
async function handlePaymentFailed(invoice: Stripe.Invoice) {
if (!invoice.subscription) return;
await db.user.updateMany({
where: { stripeSubscriptionId: invoice.subscription as string },
data: { subscriptionStatus: "past_due" },
});
// Dunning: send appropriate email based on attempt count
const attemptCount = invoice.attempt_count || 1;
if (attemptCount === 1) {
// First failure: gentle reminder
await sendDunningEmail(invoice.customer_email!, "first_failure");
} else if (attemptCount === 2) {
// Second failure: more urgent
await sendDunningEmail(invoice.customer_email!, "second_failure");
} else if (attemptCount >= 3) {
// Final failure: last chance before cancellation
await sendDunningEmail(invoice.customer_email!, "final_notice");
}
}
async function handleTrialEnding(subscription: Stripe.Subscription) {
// Stripe sends this 3 days before trial ends
const user = await db.user.findFirst({
where: { stripeSubscriptionId: subscription.id },
});
if (user?.email) {
await sendTrialEndingEmail(user.email, subscription.trial_end!);
}
}#!/usr/bin/env python3
"""Stripe Checkout & Subscription Boilerplate Generator.
Generates production-ready Stripe Checkout integration code for different
frameworks. Includes checkout session creation, webhook handler, subscription
management, and customer portal setup.
Usage:
python checkout_scaffolder.py --framework nextjs --features subscriptions,portal
python checkout_scaffolder.py --framework express --features checkout,webhooks --json
python checkout_scaffolder.py --list-frameworks
python checkout_scaffolder.py --list-features
"""
import argparse
import json
import sys
import textwrap
from datetime import datetime
FRAMEWORKS = {
"nextjs": "Next.js App Router (TypeScript)",
"express": "Express.js (TypeScript)",
"django": "Django (Python)",
}
FEATURES = {
"checkout": "Checkout session creation endpoint",
"webhooks": "Idempotent webhook handler with signature verification",
"subscriptions": "Subscription management (upgrade, downgrade, cancel)",
"portal": "Customer portal session endpoint",
"usage": "Usage-based (metered) billing helpers",
}
# ---------------------------------------------------------------------------
# Code templates per framework per feature
# ---------------------------------------------------------------------------
TEMPLATES = {}
# ── Next.js ────────────────────────────────────────────────────────────────
TEMPLATES[("nextjs", "checkout")] = textwrap.dedent('''\
// app/api/billing/checkout/route.ts
import { NextResponse } from "next/server";
import { stripe, PLANS, type PlanName, type BillingInterval } from "@/lib/stripe";
import { getAuthUser } from "@/lib/auth";
import { db } from "@/lib/db";
export async function POST(req: Request) {
const user = await getAuthUser();
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const { plan, interval = "monthly" } = (await req.json()) as {
plan: PlanName;
interval: BillingInterval;
};
if (!PLANS[plan]) {
return NextResponse.json({ error: "Invalid plan" }, { status: 400 });
}
const priceId = PLANS[plan][interval];
// Get or create Stripe customer (idempotent)
let customerId = user.stripeCustomerId;
if (!customerId) {
const customer = await stripe.customers.create({
email: user.email,
metadata: { userId: user.id },
});
customerId = customer.id;
await db.user.update({
where: { id: user.id },
data: { stripeCustomerId: customerId },
});
}
const session = await stripe.checkout.sessions.create({
customer: customerId,
mode: "subscription",
payment_method_types: ["card"],
line_items: [{ price: priceId, quantity: 1 }],
allow_promotion_codes: true,
subscription_data: {
trial_period_days: user.hasHadTrial ? undefined : 14,
metadata: { userId: user.id, plan },
},
success_url: `${process.env.APP_URL}/dashboard?checkout=success&session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.APP_URL}/pricing`,
metadata: { userId: user.id },
});
return NextResponse.json({ url: session.url });
}
''')
TEMPLATES[("nextjs", "webhooks")] = textwrap.dedent('''\
// app/api/webhooks/stripe/route.ts
import { NextResponse } from "next/server";
import { headers } from "next/headers";
import { stripe } from "@/lib/stripe";
import { db } from "@/lib/db";
import type Stripe from "stripe";
async function isProcessed(eventId: string): Promise<boolean> {
return !!(await db.stripeEvent.findUnique({ where: { id: eventId } }));
}
async function markProcessed(eventId: string, type: string) {
await db.stripeEvent.create({ data: { id: eventId, type, processedAt: new Date() } });
}
export async function POST(req: Request) {
const body = await req.text();
const signature = headers().get("stripe-signature");
if (!signature) return NextResponse.json({ error: "Missing signature" }, { status: 400 });
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, signature, process.env.STRIPE_WEBHOOK_SECRET!);
} catch (err) {
console.error("Webhook signature verification failed:", err);
return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
}
if (await isProcessed(event.id)) {
return NextResponse.json({ received: true, deduplicated: true });
}
try {
switch (event.type) {
case "checkout.session.completed":
await handleCheckoutCompleted(event.data.object as Stripe.Checkout.Session);
break;
case "customer.subscription.updated":
await handleSubscriptionChange(event.data.object as Stripe.Subscription);
break;
case "customer.subscription.deleted":
await handleSubscriptionDeleted(event.data.object as Stripe.Subscription);
break;
case "invoice.payment_succeeded":
await handlePaymentSucceeded(event.data.object as Stripe.Invoice);
break;
case "invoice.payment_failed":
await handlePaymentFailed(event.data.object as Stripe.Invoice);
break;
default:
console.log(`Unhandled webhook: ${event.type}`);
}
await markProcessed(event.id, event.type);
return NextResponse.json({ received: true });
} catch (err) {
console.error(`Webhook error [${event.type}]:`, err);
return NextResponse.json({ error: "Processing failed" }, { status: 500 });
}
}
// TODO: Implement handler functions (handleCheckoutCompleted, etc.)
// See SKILL.md for full handler implementations.
''')
TEMPLATES[("nextjs", "subscriptions")] = textwrap.dedent('''\
// lib/subscriptions.ts
import { stripe } from "@/lib/stripe";
export async function upgradeSubscription(subscriptionId: string, newPriceId: string) {
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
return stripe.subscriptions.update(subscriptionId, {
items: [{ id: subscription.items.data[0].id, price: newPriceId }],
proration_behavior: "always_invoice",
billing_cycle_anchor: "unchanged",
});
}
export async function downgradeSubscription(subscriptionId: string, newPriceId: string) {
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
return stripe.subscriptions.update(subscriptionId, {
items: [{ id: subscription.items.data[0].id, price: newPriceId }],
proration_behavior: "none",
billing_cycle_anchor: "unchanged",
});
}
export async function cancelSubscription(subscriptionId: string) {
return stripe.subscriptions.update(subscriptionId, {
cancel_at_period_end: true,
});
}
export async function reactivateSubscription(subscriptionId: string) {
return stripe.subscriptions.update(subscriptionId, {
cancel_at_period_end: false,
});
}
export async function previewProration(subscriptionId: string, newPriceId: string) {
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
const invoice = await stripe.invoices.createPreview({
customer: subscription.customer as string,
subscription: subscriptionId,
subscription_details: {
items: [{ id: subscription.items.data[0].id, price: newPriceId }],
proration_date: Math.floor(Date.now() / 1000),
},
});
return {
amountDue: invoice.amount_due,
credit: invoice.total < 0 ? Math.abs(invoice.total) : 0,
lineItems: invoice.lines.data.map((l) => ({ description: l.description, amount: l.amount })),
};
}
''')
TEMPLATES[("nextjs", "portal")] = textwrap.dedent('''\
// app/api/billing/portal/route.ts
import { NextResponse } from "next/server";
import { stripe } from "@/lib/stripe";
import { getAuthUser } from "@/lib/auth";
export async function POST() {
const user = await getAuthUser();
if (!user?.stripeCustomerId) {
return NextResponse.json({ error: "No billing account" }, { status: 400 });
}
const session = await stripe.billingPortal.sessions.create({
customer: user.stripeCustomerId,
return_url: `${process.env.APP_URL}/settings/billing`,
});
return NextResponse.json({ url: session.url });
}
''')
TEMPLATES[("nextjs", "usage")] = textwrap.dedent('''\
// lib/usage-billing.ts
import { stripe } from "@/lib/stripe";
import { db } from "@/lib/db";
export async function reportUsage(subscriptionItemId: string, quantity: number, idempotencyKey?: string) {
return stripe.subscriptionItems.createUsageRecord(
subscriptionItemId,
{ quantity, timestamp: Math.floor(Date.now() / 1000), action: "increment" },
{ idempotencyKey },
);
}
export async function trackApiUsage(userId: string) {
const user = await db.user.findUnique({ where: { id: userId } });
if (!user?.stripeSubscriptionId) return;
const subscription = await stripe.subscriptions.retrieve(user.stripeSubscriptionId);
const meteredItem = subscription.items.data.find(
(item) => item.price.recurring?.usage_type === "metered",
);
if (meteredItem) {
await reportUsage(meteredItem.id, 1, `${userId}-${Date.now()}`);
}
}
''')
# ── Express ────────────────────────────────────────────────────────────────
TEMPLATES[("express", "checkout")] = textwrap.dedent('''\
// routes/billing.ts
import { Router, Request, Response } from "express";
import { stripe, PLANS } from "../lib/stripe";
import { requireAuth } from "../middleware/auth";
import { db } from "../lib/db";
const router = Router();
router.post("/checkout", requireAuth, async (req: Request, res: Response) => {
const { plan, interval = "monthly" } = req.body;
const user = req.user!;
if (!PLANS[plan]) return res.status(400).json({ error: "Invalid plan" });
let customerId = user.stripeCustomerId;
if (!customerId) {
const customer = await stripe.customers.create({
email: user.email,
metadata: { userId: user.id },
});
customerId = customer.id;
await db.user.update({ where: { id: user.id }, data: { stripeCustomerId: customerId } });
}
const session = await stripe.checkout.sessions.create({
customer: customerId,
mode: "subscription",
line_items: [{ price: PLANS[plan][interval], quantity: 1 }],
subscription_data: { metadata: { userId: user.id, plan } },
success_url: `${process.env.APP_URL}/dashboard?checkout=success`,
cancel_url: `${process.env.APP_URL}/pricing`,
metadata: { userId: user.id },
});
res.json({ url: session.url });
});
export default router;
''')
TEMPLATES[("express", "webhooks")] = textwrap.dedent('''\
// routes/webhooks.ts
import { Router, Request, Response } from "express";
import { stripe } from "../lib/stripe";
import { db } from "../lib/db";
import type Stripe from "stripe";
const router = Router();
// IMPORTANT: Use express.raw() middleware for this route, not express.json()
router.post(
"/stripe",
async (req: Request, res: Response) => {
const sig = req.headers["stripe-signature"] as string;
if (!sig) return res.status(400).json({ error: "Missing signature" });
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(req.body, sig, process.env.STRIPE_WEBHOOK_SECRET!);
} catch (err) {
console.error("Webhook signature failed:", err);
return res.status(400).json({ error: "Invalid signature" });
}
// Idempotency check
const existing = await db.stripeEvent.findUnique({ where: { id: event.id } });
if (existing) return res.json({ received: true, deduplicated: true });
try {
// Handle event types here (see SKILL.md for full handlers)
switch (event.type) {
case "checkout.session.completed":
case "customer.subscription.updated":
case "customer.subscription.deleted":
case "invoice.payment_succeeded":
case "invoice.payment_failed":
console.log(`Processing: ${event.type}`);
// TODO: Implement handlers
break;
default:
console.log(`Unhandled: ${event.type}`);
}
await db.stripeEvent.create({ data: { id: event.id, type: event.type } });
res.json({ received: true });
} catch (err) {
console.error(`Webhook error [${event.type}]:`, err);
res.status(500).json({ error: "Processing failed" });
}
},
);
export default router;
''')
TEMPLATES[("express", "subscriptions")] = TEMPLATES[("nextjs", "subscriptions")]
TEMPLATES[("express", "portal")] = textwrap.dedent('''\
// routes/portal.ts (add to billing router)
import { Router, Request, Response } from "express";
import { stripe } from "../lib/stripe";
import { requireAuth } from "../middleware/auth";
const router = Router();
router.post("/portal", requireAuth, async (req: Request, res: Response) => {
const user = req.user!;
if (!user.stripeCustomerId) return res.status(400).json({ error: "No billing account" });
const session = await stripe.billingPortal.sessions.create({
customer: user.stripeCustomerId,
return_url: `${process.env.APP_URL}/settings/billing`,
});
res.json({ url: session.url });
});
export default router;
''')
TEMPLATES[("express", "usage")] = TEMPLATES[("nextjs", "usage")]
# ── Django ─────────────────────────────────────────────────────────────────
TEMPLATES[("django", "checkout")] = textwrap.dedent('''\
# billing/views.py
import json
import stripe
from django.conf import settings
from django.http import JsonResponse
from django.views.decorators.http import require_POST
from django.contrib.auth.decorators import login_required
from .models import UserProfile
stripe.api_key = settings.STRIPE_SECRET_KEY
PLANS = {
"starter": {"monthly": settings.STRIPE_STARTER_MONTHLY, "yearly": settings.STRIPE_STARTER_YEARLY},
"pro": {"monthly": settings.STRIPE_PRO_MONTHLY, "yearly": settings.STRIPE_PRO_YEARLY},
}
@require_POST
@login_required
def create_checkout(request):
data = json.loads(request.body)
plan = data.get("plan")
interval = data.get("interval", "monthly")
if plan not in PLANS:
return JsonResponse({"error": "Invalid plan"}, status=400)
profile = request.user.profile
if not profile.stripe_customer_id:
customer = stripe.Customer.create(
email=request.user.email,
metadata={"userId": str(request.user.id)},
)
profile.stripe_customer_id = customer.id
profile.save()
session = stripe.checkout.Session.create(
customer=profile.stripe_customer_id,
mode="subscription",
line_items=[{"price": PLANS[plan][interval], "quantity": 1}],
subscription_data={"metadata": {"userId": str(request.user.id), "plan": plan}},
success_url=f"{settings.APP_URL}/dashboard/?checkout=success&session_id={{CHECKOUT_SESSION_ID}}",
cancel_url=f"{settings.APP_URL}/pricing/",
metadata={"userId": str(request.user.id)},
)
return JsonResponse({"url": session.url})
''')
TEMPLATES[("django", "webhooks")] = textwrap.dedent('''\
# billing/webhooks.py
import stripe
from django.conf import settings
from django.http import JsonResponse
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
from .models import StripeEvent, UserProfile
stripe.api_key = settings.STRIPE_SECRET_KEY
@csrf_exempt
@require_POST
def stripe_webhook(request):
payload = request.body
sig_header = request.META.get("HTTP_STRIPE_SIGNATURE")
if not sig_header:
return JsonResponse({"error": "Missing signature"}, status=400)
try:
event = stripe.Webhook.construct_event(payload, sig_header, settings.STRIPE_WEBHOOK_SECRET)
except (ValueError, stripe.error.SignatureVerificationError) as e:
return JsonResponse({"error": "Invalid signature"}, status=400)
# Idempotency check
if StripeEvent.objects.filter(event_id=event["id"]).exists():
return JsonResponse({"received": True, "deduplicated": True})
try:
event_type = event["type"]
# TODO: Implement handlers for each event type
if event_type == "checkout.session.completed":
pass # handle checkout
elif event_type == "customer.subscription.updated":
pass # handle subscription change
elif event_type == "customer.subscription.deleted":
pass # handle cancellation
elif event_type == "invoice.payment_failed":
pass # handle payment failure
StripeEvent.objects.create(event_id=event["id"], event_type=event_type)
return JsonResponse({"received": True})
except Exception as e:
return JsonResponse({"error": "Processing failed"}, status=500)
''')
TEMPLATES[("django", "subscriptions")] = textwrap.dedent('''\
# billing/subscriptions.py
import stripe
from django.conf import settings
stripe.api_key = settings.STRIPE_SECRET_KEY
def upgrade_subscription(subscription_id: str, new_price_id: str):
subscription = stripe.Subscription.retrieve(subscription_id)
return stripe.Subscription.modify(
subscription_id,
items=[{"id": subscription["items"]["data"][0]["id"], "price": new_price_id}],
proration_behavior="always_invoice",
billing_cycle_anchor="unchanged",
)
def downgrade_subscription(subscription_id: str, new_price_id: str):
subscription = stripe.Subscription.retrieve(subscription_id)
return stripe.Subscription.modify(
subscription_id,
items=[{"id": subscription["items"]["data"][0]["id"], "price": new_price_id}],
proration_behavior="none",
)
def cancel_subscription(subscription_id: str):
return stripe.Subscription.modify(subscription_id, cancel_at_period_end=True)
def reactivate_subscription(subscription_id: str):
return stripe.Subscription.modify(subscription_id, cancel_at_period_end=False)
''')
TEMPLATES[("django", "portal")] = textwrap.dedent('''\
# billing/views.py (add to existing views)
@require_POST
@login_required
def customer_portal(request):
profile = request.user.profile
if not profile.stripe_customer_id:
return JsonResponse({"error": "No billing account"}, status=400)
session = stripe.billing_portal.Session.create(
customer=profile.stripe_customer_id,
return_url=f"{settings.APP_URL}/settings/billing/",
)
return JsonResponse({"url": session.url})
''')
TEMPLATES[("django", "usage")] = textwrap.dedent('''\
# billing/usage.py
import time
import stripe
from django.conf import settings
stripe.api_key = settings.STRIPE_SECRET_KEY
def report_usage(subscription_item_id: str, quantity: int, idempotency_key: str = None):
return stripe.SubscriptionItem.create_usage_record(
subscription_item_id,
quantity=quantity,
timestamp=int(time.time()),
action="increment",
idempotency_key=idempotency_key,
)
''')
def generate_scaffold(framework, features):
"""Generate code scaffold for the given framework and features."""
output = {
"framework": framework,
"framework_name": FRAMEWORKS[framework],
"features": features,
"generated_at": datetime.now().isoformat(),
"files": [],
}
for feature in features:
key = (framework, feature)
if key not in TEMPLATES:
output["files"].append({
"feature": feature,
"error": f"No template available for {framework}/{feature}",
})
continue
code = TEMPLATES[key]
# Extract filename from first comment line
first_line = code.strip().splitlines()[0]
filename = first_line.strip("/ #").strip()
output["files"].append({
"feature": feature,
"filename": filename,
"code": code,
})
return output
def format_human(output):
"""Format scaffold output for human-readable display."""
lines = []
lines.append("=" * 64)
lines.append(f" Stripe Checkout Scaffolder - {output['framework_name']}")
lines.append("=" * 64)
lines.append(f"\nFeatures: {', '.join(output['features'])}")
lines.append(f"Generated: {output['generated_at']}")
for file_info in output["files"]:
lines.append(f"\n{'─' * 64}")
if "error" in file_info:
lines.append(f"[ERROR] {file_info['feature']}: {file_info['error']}")
continue
lines.append(f"Feature: {file_info['feature']}")
lines.append(f"File: {file_info['filename']}")
lines.append(f"{'─' * 64}")
lines.append(file_info["code"])
lines.append("=" * 64)
lines.append("\nNext steps:")
lines.append(" 1. Copy generated files into your project")
lines.append(" 2. Set environment variables: STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, APP_URL")
lines.append(" 3. Configure price IDs in environment for each plan")
lines.append(" 4. Set up webhook endpoint in Stripe Dashboard")
lines.append(" 5. Test with: stripe listen --forward-to localhost:3000/api/webhooks/stripe")
lines.append("=" * 64)
return "\n".join(lines)
def main():
parser = argparse.ArgumentParser(
description="Generate Stripe Checkout and subscription boilerplate code for different frameworks.",
epilog="Example: %(prog)s --framework nextjs --features checkout,webhooks,subscriptions",
)
parser.add_argument(
"--framework", "-f",
choices=list(FRAMEWORKS.keys()),
help="Target framework for code generation",
)
parser.add_argument(
"--features",
help="Comma-separated list of features to generate (checkout,webhooks,subscriptions,portal,usage)",
)
parser.add_argument("--json", action="store_true", dest="json_output", help="Output as JSON")
parser.add_argument("--list-frameworks", action="store_true", help="List available frameworks")
parser.add_argument("--list-features", action="store_true", help="List available features")
parser.add_argument("--all", action="store_true", help="Generate all features for the selected framework")
args = parser.parse_args()
if args.list_frameworks:
if args.json_output:
print(json.dumps(FRAMEWORKS, indent=2))
else:
print("Available frameworks:")
for key, name in FRAMEWORKS.items():
print(f" {key:12s} - {name}")
sys.exit(0)
if args.list_features:
if args.json_output:
print(json.dumps(FEATURES, indent=2))
else:
print("Available features:")
for key, desc in FEATURES.items():
print(f" {key:16s} - {desc}")
sys.exit(0)
if not args.framework:
parser.error("--framework is required (use --list-frameworks to see options)")
if args.all:
features = list(FEATURES.keys())
elif not args.features:
parser.error("--features is required (use --list-features to see options, or --all for everything)")
else:
features = [f.strip() for f in args.features.split(",")]
invalid = [f for f in features if f not in FEATURES]
if invalid:
parser.error(f"Unknown feature(s): {', '.join(invalid)}. Use --list-features to see options.")
output = generate_scaffold(args.framework, features)
if args.json_output:
print(json.dumps(output, indent=2))
else:
print(format_human(output))
sys.exit(0)
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""Stripe Integration Code Auditor.
Scans a project for common Stripe integration anti-patterns: hardcoded API keys,
missing idempotency keys, unhandled webhook events, unpinned API versions,
insecure key handling, and missing error handling around Stripe API calls.
Usage:
python integration_auditor.py /path/to/project
python integration_auditor.py /path/to/project --json
python integration_auditor.py /path/to/project --severity critical
"""
import argparse
import json
import os
import re
import sys
from pathlib import Path
CODE_EXTENSIONS = {".ts", ".tsx", ".js", ".jsx", ".py", ".rb", ".go", ".java", ".php"}
SKIP_DIRS = {"node_modules", ".git", "__pycache__", "dist", "build", ".next", "venv", ".venv", "vendor"}
def collect_source_files(project_dir):
"""Walk the project and collect source files with their contents."""
files = {}
for root, dirs, filenames in os.walk(project_dir):
dirs[:] = [d for d in dirs if d not in SKIP_DIRS]
for fname in filenames:
ext = os.path.splitext(fname)[1]
if ext not in CODE_EXTENSIONS:
continue
fpath = os.path.join(root, fname)
try:
with open(fpath, "r", encoding="utf-8", errors="replace") as f:
files[fpath] = f.read()
except (OSError, IOError):
continue
return files
def relative_path(fpath, project_dir):
"""Return path relative to the project directory."""
try:
return str(Path(fpath).relative_to(project_dir))
except ValueError:
return fpath
def audit_hardcoded_keys(files, project_dir):
"""Detect hardcoded Stripe API keys in source code."""
findings = []
patterns = [
(r'sk_live_[a-zA-Z0-9]{20,}', "Live secret key hardcoded in source. This is a critical security risk."),
(r'sk_test_[a-zA-Z0-9]{20,}', "Test secret key hardcoded in source. Move to environment variable."),
(r'pk_live_[a-zA-Z0-9]{20,}', "Live publishable key hardcoded. Consider using environment variable for flexibility."),
(r'whsec_[a-zA-Z0-9]{20,}', "Webhook signing secret hardcoded in source."),
(r'rk_live_[a-zA-Z0-9]{20,}', "Live restricted key hardcoded in source."),
]
for fpath, content in files.items():
rel = relative_path(fpath, project_dir)
for line_no, line in enumerate(content.splitlines(), 1):
for pattern, message in patterns:
if re.search(pattern, line):
severity = "critical" if "live" in pattern else "warning"
findings.append({
"severity": severity,
"rule": "KEY-001",
"message": message,
"file": rel,
"line": line_no,
"fix": "Use environment variables: process.env.STRIPE_SECRET_KEY or os.environ['STRIPE_SECRET_KEY'].",
})
return findings
def audit_api_version_pinning(files, project_dir):
"""Check if Stripe API version is pinned in client initialization."""
findings = []
has_stripe_init = False
has_version_pin = False
for fpath, content in files.items():
# Detect Stripe client initialization
if re.search(r'new\s+Stripe\s*\(', content) or re.search(r'stripe\s*=\s*stripe\.', content, re.IGNORECASE):
has_stripe_init = True
if re.search(r'apiVersion\s*[=:]\s*["\']', content) or re.search(r'api_version\s*[=:]\s*["\']', content):
has_version_pin = True
if has_stripe_init and not has_version_pin:
findings.append({
"severity": "warning",
"rule": "VER-001",
"message": "Stripe client initialized without pinning apiVersion. Breaking changes may occur on Stripe updates.",
"fix": "Pin apiVersion in the Stripe constructor: new Stripe(key, { apiVersion: '2024-12-18.acacia' }).",
})
elif has_stripe_init and has_version_pin:
findings.append({
"severity": "pass",
"rule": "VER-001",
"message": "Stripe API version is pinned.",
})
return findings
def audit_idempotency_keys(files, project_dir):
"""Check for idempotency key usage on mutating Stripe API calls."""
findings = []
mutating_calls = [
r"\.create\s*\(",
r"\.update\s*\(",
r"\.del\s*\(",
r"\.cancel\s*\(",
]
idempotency_pattern = r"idempotencyKey|idempotency_key"
for fpath, content in files.items():
rel = relative_path(fpath, project_dir)
# Only check files that reference Stripe
if not re.search(r"stripe", content, re.IGNORECASE):
continue
has_mutating = False
has_idempotency = bool(re.search(idempotency_pattern, content, re.IGNORECASE))
for pattern in mutating_calls:
if re.search(pattern, content):
has_mutating = True
break
if has_mutating and not has_idempotency:
findings.append({
"severity": "warning",
"rule": "IDEM-001",
"message": f"Stripe mutating API calls found without idempotency keys: {rel}",
"file": rel,
"fix": "Pass idempotencyKey to .create() calls to prevent duplicate charges on retries.",
})
return findings
def audit_error_handling(files, project_dir):
"""Check for proper error handling around Stripe API calls."""
findings = []
for fpath, content in files.items():
rel = relative_path(fpath, project_dir)
if not re.search(r"stripe", content, re.IGNORECASE):
continue
lines = content.splitlines()
for i, line in enumerate(lines):
# Look for await stripe.X calls outside of try blocks
if re.search(r"await\s+stripe\.", line) or re.search(r"stripe\.\w+\.\w+\(", line):
# Check if we are inside a try block (simple heuristic: look back 10 lines for "try")
context_start = max(0, i - 10)
context = "\n".join(lines[context_start:i])
if "try" not in context and "catch" not in context and ".then(" not in line:
findings.append({
"severity": "info",
"rule": "ERR-001",
"message": f"Stripe API call may lack error handling: {rel}:{i + 1}",
"file": rel,
"line": i + 1,
"fix": "Wrap Stripe API calls in try/catch to handle network errors and API failures gracefully.",
})
return findings
def audit_price_id_handling(files, project_dir):
"""Check for hardcoded Stripe price IDs instead of environment variables."""
findings = []
price_pattern = r'["\']price_[a-zA-Z0-9]{10,}["\']'
for fpath, content in files.items():
rel = relative_path(fpath, project_dir)
for line_no, line in enumerate(content.splitlines(), 1):
if re.search(price_pattern, line):
# Ignore comments and test files
stripped = line.strip()
if stripped.startswith("//") or stripped.startswith("#") or stripped.startswith("*"):
continue
if "test" in fpath.lower() or "spec" in fpath.lower() or "mock" in fpath.lower():
continue
findings.append({
"severity": "warning",
"rule": "PRICE-001",
"message": f"Hardcoded price ID found: {rel}:{line_no}",
"file": rel,
"line": line_no,
"fix": "Move price IDs to environment variables for test/production parity.",
})
return findings
def audit_metadata_on_checkout(files, project_dir):
"""Check that checkout sessions include user metadata for webhook linking."""
findings = []
for fpath, content in files.items():
rel = relative_path(fpath, project_dir)
# Look for checkout session creation
if re.search(r"checkout\.sessions\.create", content, re.IGNORECASE):
# Check for metadata in the surrounding code block
if not re.search(r"metadata\s*[=:{]", content):
findings.append({
"severity": "critical",
"rule": "META-001",
"message": f"Checkout session created without metadata in {rel}. Cannot link subscription to user in webhooks.",
"file": rel,
"fix": "Add metadata: { userId: user.id } to checkout session creation.",
})
return findings
def audit_webhook_endpoint_security(files, project_dir):
"""Check for common webhook endpoint security issues."""
findings = []
for fpath, content in files.items():
rel = relative_path(fpath, project_dir)
if "webhook" not in fpath.lower():
continue
# Check for JSON body parsing before signature verification
lines = content.splitlines()
json_parse_line = None
sig_verify_line = None
for i, line in enumerate(lines):
if re.search(r"json\.parse|JSON\.parse|\.json\(\)", line, re.IGNORECASE) and json_parse_line is None:
json_parse_line = i
if re.search(r"constructEvent|construct_event|verify_header", line, re.IGNORECASE) and sig_verify_line is None:
sig_verify_line = i
if json_parse_line is not None and sig_verify_line is not None:
if json_parse_line < sig_verify_line:
findings.append({
"severity": "warning",
"rule": "SEC-002",
"message": f"JSON parsing occurs before signature verification in {rel}. This can break signature checks.",
"file": rel,
"fix": "Read the raw request body first for signature verification, then parse JSON.",
})
return findings
def audit_env_file_exposure(project_dir):
"""Check that .env files with Stripe keys are gitignored."""
findings = []
gitignore_path = os.path.join(project_dir, ".gitignore")
gitignore_content = ""
if os.path.isfile(gitignore_path):
try:
with open(gitignore_path, "r") as f:
gitignore_content = f.read()
except (OSError, IOError):
pass
has_env_in_gitignore = bool(re.search(r"\.env", gitignore_content))
# Check for .env files with Stripe keys
for fname in os.listdir(project_dir):
if fname.startswith(".env") and os.path.isfile(os.path.join(project_dir, fname)):
try:
with open(os.path.join(project_dir, fname), "r") as f:
env_content = f.read()
if re.search(r"STRIPE_SECRET_KEY|sk_live_|sk_test_", env_content):
if not has_env_in_gitignore:
findings.append({
"severity": "critical",
"rule": "ENV-001",
"message": f"{fname} contains Stripe keys but .env is not in .gitignore.",
"fix": "Add .env* to your .gitignore immediately to prevent key exposure.",
})
except (OSError, IOError):
continue
return findings
def run_audit(project_dir, min_severity="info"):
"""Run all audit checks and return results."""
severity_order = {"critical": 0, "warning": 1, "info": 2, "pass": 3}
min_level = severity_order.get(min_severity, 2)
files = collect_source_files(project_dir)
results = {
"project": str(project_dir),
"files_scanned": len(files),
"findings": [],
"summary": {"critical": 0, "warning": 0, "info": 0, "pass": 0},
}
if not files:
results["findings"].append({
"severity": "warning",
"rule": "SCAN-001",
"message": "No source files found to audit.",
})
results["summary"]["warning"] = 1
return results
# Run all audit checks
all_findings = []
all_findings.extend(audit_hardcoded_keys(files, project_dir))
all_findings.extend(audit_api_version_pinning(files, project_dir))
all_findings.extend(audit_idempotency_keys(files, project_dir))
all_findings.extend(audit_error_handling(files, project_dir))
all_findings.extend(audit_price_id_handling(files, project_dir))
all_findings.extend(audit_metadata_on_checkout(files, project_dir))
all_findings.extend(audit_webhook_endpoint_security(files, project_dir))
all_findings.extend(audit_env_file_exposure(project_dir))
# Filter by minimum severity
for finding in all_findings:
sev = finding.get("severity", "info")
if severity_order.get(sev, 2) <= min_level:
results["findings"].append(finding)
if sev in results["summary"]:
results["summary"][sev] += 1
# Overall status
results["status"] = "FAIL" if results["summary"]["critical"] > 0 else "PASS"
return results
def format_human(results):
"""Format results for human-readable terminal output."""
lines = []
lines.append("=" * 64)
lines.append(" Stripe Integration Auditor")
lines.append("=" * 64)
lines.append(f"\nProject: {results['project']}")
lines.append(f"Files scanned: {results['files_scanned']}")
severity_icons = {
"critical": "[CRITICAL]",
"warning": "[WARNING] ",
"info": "[INFO] ",
"pass": "[PASS] ",
}
lines.append(f"\n{'─' * 64}")
lines.append("FINDINGS:")
lines.append(f"{'─' * 64}")
# Group by severity
for sev in ("critical", "warning", "info", "pass"):
sev_findings = [f for f in results["findings"] if f.get("severity") == sev]
if not sev_findings:
continue
for finding in sev_findings:
icon = severity_icons.get(sev, "[?]")
loc = ""
if "file" in finding:
loc = f" ({finding['file']}"
if "line" in finding:
loc += f":{finding['line']}"
loc += ")"
lines.append(f"\n {icon} {finding['rule']}: {finding['message']}{loc}")
if "fix" in finding:
lines.append(f" Fix: {finding['fix']}")
lines.append(f"\n{'─' * 64}")
s = results["summary"]
lines.append(f"Summary: {s['critical']} critical, {s['warning']} warnings, {s['info']} info, {s['pass']} passed")
lines.append(f"Status: {results['status']}")
lines.append("=" * 64)
return "\n".join(lines)
def main():
parser = argparse.ArgumentParser(
description="Audit Stripe integration code for common issues: hardcoded keys, missing idempotency, unhandled events.",
epilog="Example: %(prog)s /path/to/project --severity warning --json",
)
parser.add_argument("project_dir", help="Path to the project directory to audit")
parser.add_argument("--json", action="store_true", dest="json_output", help="Output results as JSON")
parser.add_argument(
"--severity",
choices=["critical", "warning", "info"],
default="info",
help="Minimum severity level to report (default: info)",
)
args = parser.parse_args()
project_dir = Path(args.project_dir).resolve()
if not project_dir.is_dir():
print(f"Error: '{project_dir}' is not a valid directory.", file=sys.stderr)
sys.exit(2)
results = run_audit(project_dir, min_severity=args.severity)
if args.json_output:
print(json.dumps(results, indent=2))
else:
print(format_human(results))
sys.exit(0 if results["status"] == "PASS" else 1)
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""Stripe Webhook Endpoint Configuration Validator.
Validates webhook endpoint configurations, signature verification setup,
and event handling completeness. Scans project files for common webhook
misconfigurations that cause silent billing failures in production.
Usage:
python webhook_validator.py /path/to/project
python webhook_validator.py /path/to/project --json
python webhook_validator.py /path/to/project --strict
"""
import argparse
import json
import os
import re
import sys
from pathlib import Path
# Critical webhook events that every SaaS billing integration must handle
CRITICAL_EVENTS = [
"checkout.session.completed",
"customer.subscription.created",
"customer.subscription.updated",
"customer.subscription.deleted",
"invoice.payment_succeeded",
"invoice.payment_failed",
]
# Recommended events for a production-quality integration
RECOMMENDED_EVENTS = [
"customer.subscription.trial_will_end",
"invoice.payment_action_required",
"customer.updated",
"payment_intent.payment_failed",
"charge.dispute.created",
]
# File extensions to scan
CODE_EXTENSIONS = {".ts", ".tsx", ".js", ".jsx", ".py", ".rb", ".go", ".java", ".php"}
# Patterns that indicate webhook handling code
WEBHOOK_FILE_PATTERNS = [
r"webhook",
r"stripe.*route",
r"stripe.*handler",
r"stripe.*endpoint",
]
def find_webhook_files(project_dir):
"""Locate files likely containing webhook handler code."""
matches = []
for root, _dirs, files in os.walk(project_dir):
# Skip common non-source directories
basename = os.path.basename(root)
if basename in ("node_modules", ".git", "__pycache__", "dist", "build", ".next", "venv"):
continue
for fname in files:
ext = os.path.splitext(fname)[1]
if ext not in CODE_EXTENSIONS:
continue
fpath = os.path.join(root, fname)
name_lower = fname.lower()
if any(re.search(p, name_lower) for p in WEBHOOK_FILE_PATTERNS):
matches.append(fpath)
return matches
def read_file_safe(fpath):
"""Read file contents, returning empty string on failure."""
try:
with open(fpath, "r", encoding="utf-8", errors="replace") as f:
return f.read()
except (OSError, IOError):
return ""
def scan_all_source_files(project_dir):
"""Collect all source file contents for broad pattern matching."""
contents = {}
for root, _dirs, files in os.walk(project_dir):
basename = os.path.basename(root)
if basename in ("node_modules", ".git", "__pycache__", "dist", "build", ".next", "venv"):
continue
for fname in files:
ext = os.path.splitext(fname)[1]
if ext not in CODE_EXTENSIONS:
continue
fpath = os.path.join(root, fname)
contents[fpath] = read_file_safe(fpath)
return contents
def check_signature_verification(webhook_files, all_contents):
"""Check that webhook signature verification is implemented."""
findings = []
sig_patterns = [
r"constructEvent", # stripe.webhooks.constructEvent (Node)
r"Webhook\.construct_event", # stripe.Webhook.construct_event (Python)
r"webhook\.ConstructEvent", # Go
r"Stripe::Webhook\.construct", # Ruby
r"verify_header", # Alternative pattern
r"stripe-signature", # Reading the header
]
has_verification = False
for fpath in webhook_files:
content = all_contents.get(fpath, "")
for pattern in sig_patterns:
if re.search(pattern, content, re.IGNORECASE):
has_verification = True
break
if not has_verification:
findings.append({
"severity": "critical",
"rule": "SIG-001",
"message": "No webhook signature verification detected. Webhooks are vulnerable to forgery.",
"fix": "Use stripe.webhooks.constructEvent(body, sig, secret) to verify signatures before processing.",
})
else:
findings.append({
"severity": "pass",
"rule": "SIG-001",
"message": "Webhook signature verification detected.",
})
# Check for raw body parsing (required for signature verification)
raw_body_patterns = [
r"req\.text\(\)", # Next.js App Router
r"raw\s*[:(]", # express.raw() or bodyParser.raw()
r"getRawBody", # raw-body package
r"bodyParser\.raw", # Express
r"request\.body", # Django/Flask raw
]
has_raw_body = False
for fpath in webhook_files:
content = all_contents.get(fpath, "")
for pattern in raw_body_patterns:
if re.search(pattern, content):
has_raw_body = True
break
if has_verification and not has_raw_body:
findings.append({
"severity": "warning",
"rule": "SIG-002",
"message": "Signature verification found but raw body parsing not detected. JSON-parsed bodies will fail verification.",
"fix": "Ensure the request body is read as raw text/buffer before passing to constructEvent.",
})
return findings
def check_event_handling(webhook_files, all_contents):
"""Check which Stripe events are handled."""
findings = []
handled_events = set()
all_webhook_content = ""
for fpath in webhook_files:
content = all_contents.get(fpath, "")
all_webhook_content += content
# Match event type strings in code
event_matches = re.findall(r'["\']([a-z]+\.[a-z_.]+)["\']', content)
for ev in event_matches:
if ev.count(".") >= 1 and any(
ev.startswith(prefix)
for prefix in ("checkout.", "customer.", "invoice.", "payment_intent.", "charge.", "subscription_schedule.")
):
handled_events.add(ev)
# Check critical events
missing_critical = []
for event in CRITICAL_EVENTS:
if event not in handled_events:
missing_critical.append(event)
if missing_critical:
findings.append({
"severity": "critical",
"rule": "EVT-001",
"message": f"Missing {len(missing_critical)} critical webhook event(s): {', '.join(missing_critical)}",
"fix": "Add handlers for all critical billing events to prevent silent payment failures.",
"missing_events": missing_critical,
})
else:
findings.append({
"severity": "pass",
"rule": "EVT-001",
"message": f"All {len(CRITICAL_EVENTS)} critical webhook events are handled.",
})
# Check recommended events
missing_recommended = []
for event in RECOMMENDED_EVENTS:
if event not in handled_events:
missing_recommended.append(event)
if missing_recommended:
findings.append({
"severity": "info",
"rule": "EVT-002",
"message": f"Missing {len(missing_recommended)} recommended event(s): {', '.join(missing_recommended)}",
"fix": "Consider handling these events for a more robust integration.",
"missing_events": missing_recommended,
})
return findings, handled_events
def check_idempotency(webhook_files, all_contents):
"""Check for idempotent webhook processing."""
findings = []
idempotency_patterns = [
r"isProcessed|already.?processed|event.?id.*find|findUnique.*event",
r"markProcessed|mark.?as.?processed|stripeEvent.*create",
r"idempoten",
r"dedup|de.?dup|deduplicate",
r"processed.?event",
]
has_idempotency = False
for fpath in webhook_files:
content = all_contents.get(fpath, "")
for pattern in idempotency_patterns:
if re.search(pattern, content, re.IGNORECASE):
has_idempotency = True
break
if not has_idempotency:
findings.append({
"severity": "critical",
"rule": "IDEM-001",
"message": "No idempotency mechanism detected in webhook handlers. Stripe retries will cause duplicate processing.",
"fix": "Track processed event IDs in a database table and skip already-handled events.",
})
else:
findings.append({
"severity": "pass",
"rule": "IDEM-001",
"message": "Webhook idempotency mechanism detected.",
})
return findings
def check_error_handling(webhook_files, all_contents):
"""Check for proper error handling and retry behavior."""
findings = []
for fpath in webhook_files:
content = all_contents.get(fpath, "")
# Check for catch blocks that always return 200
if re.search(r"catch.*\{[^}]*200[^}]*\}", content, re.DOTALL):
findings.append({
"severity": "warning",
"rule": "ERR-001",
"message": f"Webhook handler may return 200 on errors, preventing Stripe retries: {os.path.basename(fpath)}",
"fix": "Return 500 on processing errors so Stripe retries the webhook delivery.",
"file": fpath,
})
# Check for try/catch around event processing
has_try_catch = False
for fpath in webhook_files:
content = all_contents.get(fpath, "")
if re.search(r"try\s*\{", content) or re.search(r"try:", content):
has_try_catch = True
break
if not has_try_catch and webhook_files:
findings.append({
"severity": "warning",
"rule": "ERR-002",
"message": "No try/catch blocks found in webhook handlers. Unhandled errors may crash the endpoint.",
"fix": "Wrap event processing in try/catch and return appropriate HTTP status codes.",
})
return findings
def check_webhook_secret_config(all_contents):
"""Check that webhook secret is loaded from environment, not hardcoded."""
findings = []
for fpath, content in all_contents.items():
# Look for hardcoded webhook secrets
if re.search(r'whsec_[a-zA-Z0-9]{20,}', content):
findings.append({
"severity": "critical",
"rule": "SEC-001",
"message": f"Hardcoded webhook signing secret found in {os.path.basename(fpath)}",
"fix": "Move webhook secrets to environment variables (STRIPE_WEBHOOK_SECRET).",
"file": fpath,
})
# Check for env var usage
has_env_secret = False
for content in all_contents.values():
if re.search(r"STRIPE_WEBHOOK_SECRET|webhook.?secret", content, re.IGNORECASE):
has_env_secret = True
break
if not has_env_secret:
findings.append({
"severity": "warning",
"rule": "SEC-002",
"message": "No STRIPE_WEBHOOK_SECRET environment variable reference found.",
"fix": "Define STRIPE_WEBHOOK_SECRET in your environment and reference it in webhook verification.",
})
return findings
def run_validation(project_dir, strict=False):
"""Run all webhook validation checks."""
results = {
"project": str(project_dir),
"webhook_files": [],
"findings": [],
"summary": {"critical": 0, "warning": 0, "info": 0, "pass": 0},
}
# Find webhook files
webhook_files = find_webhook_files(project_dir)
results["webhook_files"] = [str(f) for f in webhook_files]
if not webhook_files:
results["findings"].append({
"severity": "critical",
"rule": "SETUP-001",
"message": "No webhook handler files found in the project.",
"fix": "Create a webhook endpoint (e.g., /api/webhooks/stripe) to handle Stripe events.",
})
results["summary"]["critical"] = 1
return results
# Scan all source files
all_contents = scan_all_source_files(project_dir)
# Run all checks
checks = [
check_signature_verification(webhook_files, all_contents),
check_idempotency(webhook_files, all_contents),
check_error_handling(webhook_files, all_contents),
check_webhook_secret_config(all_contents),
]
for check_findings in checks:
results["findings"].extend(check_findings)
# Event handling check returns extra data
event_findings, handled_events = check_event_handling(webhook_files, all_contents)
results["findings"].extend(event_findings)
results["handled_events"] = sorted(handled_events)
# Tally summary
for finding in results["findings"]:
sev = finding.get("severity", "info")
if sev in results["summary"]:
results["summary"][sev] += 1
# Determine overall status
if results["summary"]["critical"] > 0:
results["status"] = "FAIL"
elif strict and results["summary"]["warning"] > 0:
results["status"] = "FAIL"
else:
results["status"] = "PASS"
return results
def format_human(results):
"""Format results for human-readable terminal output."""
lines = []
lines.append("=" * 60)
lines.append(" Stripe Webhook Validator")
lines.append("=" * 60)
lines.append(f"\nProject: {results['project']}")
lines.append(f"Webhook files found: {len(results['webhook_files'])}")
for wf in results["webhook_files"]:
lines.append(f" - {wf}")
if results.get("handled_events"):
lines.append(f"\nHandled events ({len(results['handled_events'])}):")
for ev in results["handled_events"]:
lines.append(f" - {ev}")
lines.append(f"\n{'─' * 60}")
lines.append("FINDINGS:")
lines.append(f"{'─' * 60}")
severity_icons = {"critical": "[CRITICAL]", "warning": "[WARNING]", "info": "[INFO]", "pass": "[PASS]"}
for finding in results["findings"]:
sev = finding.get("severity", "info")
icon = severity_icons.get(sev, "[?]")
lines.append(f"\n {icon} {finding['rule']}: {finding['message']}")
if "fix" in finding:
lines.append(f" Fix: {finding['fix']}")
lines.append(f"\n{'─' * 60}")
s = results["summary"]
lines.append(f"Summary: {s['critical']} critical, {s['warning']} warnings, {s['info']} info, {s['pass']} passed")
lines.append(f"Status: {results['status']}")
lines.append("=" * 60)
return "\n".join(lines)
def main():
parser = argparse.ArgumentParser(
description="Validate Stripe webhook endpoint configurations and signature verification setup.",
epilog="Example: %(prog)s /path/to/project --strict --json",
)
parser.add_argument("project_dir", help="Path to the project directory to scan")
parser.add_argument("--json", action="store_true", dest="json_output", help="Output results as JSON")
parser.add_argument("--strict", action="store_true", help="Treat warnings as failures (non-zero exit code)")
args = parser.parse_args()
project_dir = Path(args.project_dir).resolve()
if not project_dir.is_dir():
print(f"Error: '{project_dir}' is not a valid directory.", file=sys.stderr)
sys.exit(2)
results = run_validation(project_dir, strict=args.strict)
if args.json_output:
print(json.dumps(results, indent=2))
else:
print(format_human(results))
sys.exit(0 if results["status"] == "PASS" else 1)
if __name__ == "__main__":
main()
Related skills
FAQ
Which frameworks does it cover?
It provides patterns for Next.js, Express, and Django with emphasis on real-world edge cases.
Does it cover Stripe Connect?
No. Stripe Connect marketplace payouts and multi-party payments are explicitly out of scope.