Install
openclaw skills install afrexai-stripe-productionProvides best practices and code patterns for building, scaling, and operating production Stripe payment systems from checkout to enterprise billing.
openclaw skills install afrexai-stripe-productionComplete methodology for building, scaling, and operating production Stripe payment systems. From first checkout to enterprise billing at scale.
Run through these 8 signals. Score 1 point each. Below 5 = stop and fix.
Score: /8 — Below 5? Fix gaps before adding features.
| Pattern | Best For | Complexity | PCI Scope |
|---|---|---|---|
| Stripe Checkout (hosted) | MVPs, quick launch | Low | SAQ-A (minimal) |
| Payment Element (embedded) | Custom UX, brand control | Medium | SAQ-A |
| Card Element (legacy) | Existing integrations | Medium | SAQ-A-EP |
| Direct API | Platform/marketplace | High | SAQ-D (avoid) |
Decision rule: Start with Checkout. Move to Payment Element only when you have specific UX requirements that Checkout can't solve.
| Model | Stripe Product | Use When |
|---|---|---|
| One-time | Payment Links / Checkout | Single purchases, lifetime deals |
| Recurring flat | Subscriptions | Fixed monthly/annual SaaS |
| Usage-based | Metered billing | API calls, compute, storage |
| Per-seat | Subscriptions + quantity | Team/user-based pricing |
| Tiered | Tiered pricing | Volume discounts |
| Hybrid | Subscription + usage records | Base fee + overage |
src/
payments/
stripe.config.ts # Stripe client initialization
webhooks.handler.ts # Webhook endpoint + event routing
checkout.service.ts # Checkout session creation
subscription.service.ts # Subscription lifecycle
customer.service.ts # Customer CRUD + portal
invoice.service.ts # Invoice customization
tax.service.ts # Tax calculation
types.ts # Shared types
middleware/
webhook-verify.ts # Signature verification middleware
stripe_customer_id in your DBuser_id, plan, source to every object for debugging// stripe.config.ts
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2024-12-18.acacia', // Pin API version!
maxNetworkRetries: 2,
timeout: 10_000,
telemetry: false,
});
export { stripe };
Rules:
// customer.service.ts
async function getOrCreateCustomer(userId: string, email: string, name?: string) {
// Check DB first
const existing = await db.users.findOne({ id: userId });
if (existing?.stripeCustomerId) {
return existing.stripeCustomerId;
}
// Create in Stripe
const customer = await stripe.customers.create({
email,
name,
metadata: {
user_id: userId,
created_via: 'signup',
},
});
// Store mapping
await db.users.update({ id: userId }, { stripeCustomerId: customer.id });
return customer.id;
}
Customer rules:
customer.updated// checkout.service.ts
async function createCheckoutSession(params: {
customerId: string;
priceId: string;
mode: 'payment' | 'subscription';
successUrl: string;
cancelUrl: string;
metadata?: Record<string, string>;
}) {
const session = await stripe.checkout.sessions.create({
customer: params.customerId,
mode: params.mode,
line_items: [{ price: params.priceId, quantity: 1 }],
success_url: `${params.successUrl}?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: params.cancelUrl,
metadata: params.metadata ?? {},
// Recommended defaults
allow_promotion_codes: true,
billing_address_collection: 'auto',
tax_id_collection: { enabled: true },
customer_update: { address: 'auto', name: 'auto' },
payment_method_types: ['card'],
// For subscriptions
...(params.mode === 'subscription' && {
subscription_data: {
metadata: params.metadata ?? {},
trial_period_days: 14,
},
}),
}, {
idempotencyKey: `checkout_${params.customerId}_${params.priceId}_${Date.now()}`,
});
return session;
}
// Server: create PaymentIntent
async function createPaymentIntent(params: {
customerId: string;
amount: number; // in cents!
currency: string;
metadata?: Record<string, string>;
}) {
return stripe.paymentIntents.create({
customer: params.customerId,
amount: params.amount,
currency: params.currency,
automatic_payment_methods: { enabled: true },
metadata: {
...params.metadata,
created_at: new Date().toISOString(),
},
}, {
idempotencyKey: `pi_${params.customerId}_${params.amount}_${Date.now()}`,
});
}
// Client: React Payment Element
// <PaymentElement /> handles all payment method rendering
// Use confirmPayment() on form submit
Created → Active → Past Due → Canceled → (optionally) Unpaid
↓
Trialing → Active
↓
Paused → Resumed → Active
| Event | Action |
|---|---|
customer.subscription.created | Provision access, set plan in DB |
customer.subscription.updated | Handle plan changes, quantity updates |
customer.subscription.deleted | Revoke access, clean up |
customer.subscription.trial_will_end | Send conversion email (3 days before) |
invoice.payment_succeeded | Confirm access renewal |
invoice.payment_failed | Start dunning sequence |
customer.subscription.paused | Restrict access, retain data |
customer.subscription.resumed | Restore access |
// Upgrade (immediate)
async function upgradePlan(subscriptionId: string, newPriceId: string) {
const sub = await stripe.subscriptions.retrieve(subscriptionId);
return stripe.subscriptions.update(subscriptionId, {
items: [{
id: sub.items.data[0].id,
price: newPriceId,
}],
proration_behavior: 'create_prorations', // charge difference immediately
payment_behavior: 'error_if_incomplete',
});
}
// Downgrade (at period end)
async function downgradePlan(subscriptionId: string, newPriceId: string) {
const sub = await stripe.subscriptions.retrieve(subscriptionId);
return stripe.subscriptions.update(subscriptionId, {
items: [{
id: sub.items.data[0].id,
price: newPriceId,
}],
proration_behavior: 'none', // no refund, change at renewal
billing_cycle_anchor: 'unchanged',
});
}
// Cancel (at period end — always prefer this)
async function cancelSubscription(subscriptionId: string) {
return stripe.subscriptions.update(subscriptionId, {
cancel_at_period_end: true,
});
// User keeps access until period ends
// Handle `customer.subscription.deleted` to revoke
}
# Stripe Dashboard → Settings → Subscriptions → Smart Retries
retry_schedule:
attempt_1: 1 day after failure # Smart timing
attempt_2: 3 days after failure
attempt_3: 5 days after failure
attempt_4: 7 days after failure # Final attempt
# Custom dunning emails (supplement Stripe's built-in)
dunning_sequence:
- day: 0
action: "Email: payment failed, update card link"
template: "Your payment of {amount} failed. Update → {portal_url}"
- day: 3
action: "Email: second notice, urgency"
template: "Still unable to charge. Update payment to avoid interruption."
- day: 5
action: "Email: final warning"
template: "Last chance to update. Access pauses in 48 hours."
- day: 7
action: "Pause or cancel subscription"
note: "Automatic via Stripe if all retries fail"
// Report usage for metered billing
async function reportUsage(subscriptionItemId: string, quantity: number) {
return stripe.subscriptionItems.createUsageRecord(subscriptionItemId, {
quantity,
timestamp: Math.floor(Date.now() / 1000),
action: 'increment', // or 'set' for absolute value
}, {
idempotencyKey: `usage_${subscriptionItemId}_${Date.now()}`,
});
}
// Best practice: batch usage reports
// Don't report every API call individually — aggregate per hour/day
// Report at least daily to avoid surprise bills at period end
// webhooks.handler.ts
import { stripe } from './stripe.config';
import type { Request, Response } from 'express';
const WEBHOOK_SECRET = process.env.STRIPE_WEBHOOK_SECRET!;
// Event handlers map
const handlers: Record<string, (event: Stripe.Event) => Promise<void>> = {
'checkout.session.completed': handleCheckoutCompleted,
'customer.subscription.created': handleSubscriptionCreated,
'customer.subscription.updated': handleSubscriptionUpdated,
'customer.subscription.deleted': handleSubscriptionDeleted,
'invoice.payment_succeeded': handleInvoicePaymentSucceeded,
'invoice.payment_failed': handleInvoicePaymentFailed,
'customer.subscription.trial_will_end': handleTrialEnding,
'payment_intent.succeeded': handlePaymentSucceeded,
'payment_intent.payment_failed': handlePaymentFailed,
};
export async function handleWebhook(req: Request, res: Response) {
// 1. Verify signature (NEVER skip this)
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
req.body, // raw body, not parsed JSON!
req.headers['stripe-signature']!,
WEBHOOK_SECRET,
);
} catch (err) {
console.error('Webhook signature verification failed:', err);
return res.status(400).send('Invalid signature');
}
// 2. Idempotency check
const alreadyProcessed = await db.webhookEvents.findOne({ eventId: event.id });
if (alreadyProcessed) {
return res.status(200).json({ received: true, duplicate: true });
}
// 3. Route to handler
const handler = handlers[event.type];
if (handler) {
try {
await handler(event);
await db.webhookEvents.insert({ eventId: event.id, type: event.type, processedAt: new Date() });
} catch (err) {
console.error(`Webhook handler failed for ${event.type}:`, err);
return res.status(500).send('Handler error'); // Stripe will retry
}
}
// 4. Always return 200 quickly
res.status(200).json({ received: true });
}
express.raw({type: 'application/json'}))stripe listen --forward-to localhost:3000/webhooks| Use Case | Must-Have Events |
|---|---|
| One-time payments | checkout.session.completed, payment_intent.succeeded, payment_intent.payment_failed |
| Subscriptions | All subscription.* + invoice.payment_succeeded, invoice.payment_failed, invoice.upcoming |
| Marketplace/Connect | account.updated, payout.paid, payout.failed, transfer.created |
| Invoicing | invoice.created, invoice.finalized, invoice.paid, invoice.payment_failed |
// Customer portal — saves you building billing UI
async function createPortalSession(customerId: string, returnUrl: string) {
return stripe.billingPortal.sessions.create({
customer: customerId,
return_url: returnUrl,
});
}
// Configure in Dashboard → Settings → Customer portal
// Enable: Update payment method, Cancel subscription, View invoices
// Optional: Plan switching, Invoice history download
Do you sell to EU customers?
YES → Need VAT collection
Use Stripe Tax (automatic) OR manual tax rates
NO →
Do you sell to US customers in multiple states?
YES → Need sales tax (nexus rules)
Use Stripe Tax (automatic) — manual is nightmare
NO →
Do you exceed $100K revenue or 200 transactions in any US state?
YES → You have economic nexus — collect tax
NO → May not need to collect, but verify with accountant
// Enable automatic tax on Checkout
const session = await stripe.checkout.sessions.create({
// ... other config
automatic_tax: { enabled: true },
customer_update: { address: 'auto' }, // Required for tax calculation
});
// For subscriptions via API
const subscription = await stripe.subscriptions.create({
customer: customerId,
items: [{ price: priceId }],
automatic_tax: { enabled: true },
});
Tax rules:
| Type | Control | Onboarding | Best For |
|---|---|---|---|
| Standard | Low (Stripe-hosted dashboard) | Stripe-hosted | Marketplaces where sellers manage their own Stripe |
| Express | Medium (limited dashboard) | Stripe-hosted | Platforms managing payouts for contractors/sellers |
| Custom | Full (you build everything) | You build it | Enterprise platforms needing total control |
Decision rule: Use Express unless you have a specific reason not to.
// Direct charge (platform takes cut)
const paymentIntent = await stripe.paymentIntents.create({
amount: 10000, // $100
currency: 'usd',
application_fee_amount: 1500, // $15 platform fee
transfer_data: {
destination: 'acct_seller123',
},
});
// Destination charge (seller's Stripe processes)
// Same as above but payment appears on seller's statement
// Separate charges and transfers (most flexible)
const charge = await stripe.paymentIntents.create({
amount: 10000,
currency: 'usd',
});
// Then transfer to seller
const transfer = await stripe.transfers.create({
amount: 8500,
currency: 'usd',
destination: 'acct_seller123',
transfer_group: 'order_123',
});
| Test | How | Pass Criteria |
|---|---|---|
| Successful payment | Card: 4242424242424242 | Checkout completes, webhook fires, DB updated |
| Declined card | Card: 4000000000000002 | Error shown, no DB change |
| 3D Secure required | Card: 4000002500003155 | Auth modal shown, completes after |
| Insufficient funds | Card: 4000000000009995 | Graceful failure message |
| Subscription create | Use test price, complete checkout | Sub active, access granted |
| Payment failure + retry | Attach 4000000000000341, trigger invoice | Dunning sequence fires |
| Webhook replay | stripe events resend evt_xxx | Idempotent — no duplicate processing |
| Refund | Refund via API or Dashboard | Customer notified, access handled |
| Upgrade/downgrade | Change plan mid-cycle | Proration correct |
| Cancel at period end | Cancel, verify access until period end | Access maintained, then revoked |
# Install
brew install stripe/stripe-cli/stripe
# Login
stripe login
# Forward webhooks to local server
stripe listen --forward-to localhost:3000/api/webhooks/stripe
# Trigger specific events for testing
stripe trigger checkout.session.completed
stripe trigger invoice.payment_failed
stripe trigger customer.subscription.deleted
| Level | Criteria | Requirement |
|---|---|---|
| SAQ-A | Checkout or Elements (recommended) | Annual questionnaire, no scan |
| SAQ-A-EP | Client-side tokenization | Annual questionnaire + quarterly scan |
| SAQ-D | Direct API card handling (avoid) | Full audit, quarterly scans, penetration tests |
Live mode:
sk_live_xxx → Server only, env var, restricted permissions
pk_live_xxx → Client-side (public, safe to expose)
whsec_xxx → Webhook secret, server only
Test mode:
sk_test_xxx → Same restrictions as live
pk_test_xxx → Client-side
whsec_test_xxx → Webhook secret (different from live!)
Restricted key permissions (create in Dashboard → API Keys):
metrics:
payment_success_rate:
calculation: "successful_payments / total_attempts × 100"
healthy: ">= 95%"
warning: "90-95%"
critical: "< 90%"
webhook_delivery_rate:
calculation: "successful_deliveries / total_events × 100"
healthy: ">= 99.5%"
critical: "< 99%"
average_revenue_per_user:
calculation: "total_revenue / active_customers"
track: "weekly trend"
monthly_recurring_revenue:
calculation: "sum(active_subscription_amounts)"
track: "monthly growth rate"
churn_rate:
calculation: "canceled_subscriptions / total_active × 100"
healthy: "< 5% monthly"
warning: "5-10%"
critical: "> 10%"
involuntary_churn:
calculation: "failed_payment_cancellations / total_churn × 100"
note: "Should be < 30% of total churn — fix with better dunning"
// Create both monthly and annual prices for each plan
const prices = {
starter: {
monthly: 'price_starter_monthly', // $29/mo
annual: 'price_starter_annual', // $290/yr (save ~17%)
},
pro: {
monthly: 'price_pro_monthly', // $79/mo
annual: 'price_pro_annual', // $790/yr
},
};
// Client toggles monthly/annual → creates checkout with correct priceId
// New price, existing customers keep old price
// 1. Create new price object (don't modify existing)
// 2. New signups use new price
// 3. Existing subs stay on old price until they change plans
// 4. Optional: scheduled price migration with notice
async function schedulePriceIncrease(subscriptionId: string, newPriceId: string, effectiveDate: Date) {
// Create a subscription schedule
const schedule = await stripe.subscriptionSchedules.create({
from_subscription: subscriptionId,
});
// Add phase with new price
await stripe.subscriptionSchedules.update(schedule.id, {
phases: [
{ items: [{ price: currentPriceId }], end_date: Math.floor(effectiveDate.getTime() / 1000) },
{ items: [{ price: newPriceId }] },
],
});
}
// Create coupon (reusable)
const coupon = await stripe.coupons.create({
percent_off: 20,
duration: 'repeating',
duration_in_months: 3,
max_redemptions: 100,
metadata: { campaign: 'launch_2024' },
});
// Create promotion code (shareable link)
const promoCode = await stripe.promotionCodes.create({
coupon: coupon.id,
code: 'LAUNCH20',
max_redemptions: 50,
expires_at: Math.floor(new Date('2024-12-31').getTime() / 1000),
});
dispute_response_process:
when_received:
- Log dispute details: amount, reason, deadline
- Alert team immediately
- Begin evidence collection within 24 hours
evidence_to_collect:
- Customer communication (emails, chat logs)
- Delivery proof (access logs, download records)
- Terms of service acceptance timestamp
- Refund policy shown at checkout
- IP address and device fingerprint
- Prior successful transactions from same customer
submit:
- Within 7 days (Stripe deadline is usually 21 days, but act fast)
- Use Stripe Dashboard evidence submission form
- Include written rebuttal addressing specific reason code
prevention:
- Clear billing descriptor (customer recognizes charge)
- Send receipt emails immediately
- Offer easy refunds before disputes escalate
- Use Radar for fraud detection
| # | Mistake | Fix |
|---|---|---|
| 1 | Trusting client-side redirect as payment confirmation | Always verify via webhook |
| 2 | Parsing JSON body before webhook signature verification | Use raw body buffer |
| 3 | No idempotency keys on API calls | Add to every mutating call |
| 4 | Immediately canceling instead of cancel_at_period_end | Always cancel at period end unless refunding |
| 5 | Amounts in dollars instead of cents | Stripe uses smallest currency unit (cents) |
| 6 | Not handling 3D Secure / requires_action status | Check payment_intent.status, handle authentication |
| 7 | Same webhook secret for test and live | Use separate secrets per environment |
| 8 | Not testing with Stripe CLI locally | Set up stripe listen in development |
| 9 | Hardcoding price IDs | Use config/env vars, different per environment |
| 10 | No dunning strategy for failed subscription payments | Configure Smart Retries + custom emails |
| Dimension | Weight | Criteria |
|---|---|---|
| Webhook reliability | 25% | Signature verification, idempotency, error handling, event coverage |
| Security & PCI | 20% | SAQ-A compliance, key management, no card data exposure |
| Subscription lifecycle | 15% | Create/upgrade/downgrade/cancel/pause all handled correctly |
| Customer experience | 15% | Portal, receipts, dunning emails, clear error messages |
| Testing coverage | 10% | All payment states tested, CLI setup, edge cases |
| Monitoring | 10% | Failure alerts, revenue metrics, dispute tracking |
| Code quality | 5% | TypeScript types, error handling, idempotency keys, logging |
The agent responds to these natural language requests: