api-commerce-stripe
Stripe payment processing — Checkout Sessions, Payment Intents, subscriptions, webhooks, Connect, customer management, error handling
Install
npx skills add https://github.com/agents-inc/skills/tree/main/dist/plugins/api-commerce-stripe/skills/api-commerce-stripe
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install agents-inc-skills@llmmart
git clone https://github.com/agents-inc/skills.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole agents-inc/skills collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
Stripe Patterns
Quick Guide: Use the
stripenpm package for all server-side Stripe operations. Always verify webhook signatures withconstructEvent()using the raw request body, never the parsed body. Use idempotency keys on all mutating requests. Keep the secret key server-side only. Handle errors withinstanceof Stripe.errors.StripeError. Amounts are always in the smallest currency unit (e.g., cents for USD).
<critical_requirements>
CRITICAL: Before Using This Skill
All code must follow project conventions in CLAUDE.md (kebab-case, named exports, import ordering,
import type, named constants)
(You MUST NEVER expose STRIPE_SECRET_KEY in client-side code — it stays on the server only)
(You MUST verify webhook signatures with stripe.webhooks.constructEvent() using the RAW request body — never parsed JSON)
(You MUST use idempotency keys on all mutating (POST) requests to prevent duplicate charges)
(You MUST handle all Stripe errors with instanceof Stripe.errors.StripeError — never swallow payment errors)
(You MUST express monetary amounts in the smallest currency unit — cents for USD, not dollars)
</critical_requirements>
Auto-detection: Stripe, stripe, stripe.checkout.sessions, stripe.paymentIntents, stripe.customers, stripe.subscriptions, stripe.webhooks, constructEvent, PaymentIntent, CheckoutSession, STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, stripe.prices, stripe.products, stripe.refunds, stripe.transfers, stripe.accounts, Stripe.errors, idempotencyKey, payment_intent.succeeded, checkout.session.completed
When to use:
- Creating Checkout Sessions for one-time or subscription payments
- Building custom payment flows with Payment Intents
- Handling webhook events for asynchronous payment lifecycle
- Managing customers, payment methods, and subscriptions
- Building marketplace platforms with Stripe Connect
- Processing refunds and handling disputes
- Setting up products and prices for a catalog
Key patterns covered:
- Stripe client initialization with TypeScript types
- Checkout Sessions (one-time payments, subscriptions, setup mode)
- Payment Intents (custom flows, confirmation, capture)
- Webhook signature verification and event handling
- Customer creation, update, and payment method attachment
- Subscription lifecycle (create, update, cancel, trials, proration)
- Products and Prices (catalog management)
- Stripe Connect (account creation, transfers, destination charges)
- Error handling with typed Stripe errors
- Idempotency keys for safe retries
When NOT to use:
- Client-side Stripe.js or Stripe Elements (use your frontend framework skill)
- Stripe CLI commands or dashboard configuration
- Non-Stripe payment processors (use their dedicated skill)
Detailed Resources:
- For decision frameworks and anti-patterns, see reference.md
Core Setup & Payments:
- examples/core.md — Client setup, Checkout Sessions, Payment Intents, error handling
Webhooks & Events:
- examples/webhooks.md — Signature verification, event handling, idempotent processing
Subscriptions & Billing:
- examples/subscriptions.md — Subscription lifecycle, trials, proration, metered billing
Connect & Platforms:
- examples/connect.md — Connected accounts, transfers, destination charges, platform fees
<red_flags>
RED FLAGS
High Priority Issues:
- Secret key in client-side code —
STRIPE_SECRET_KEYmust never appear in browser bundles. UseSTRIPE_PUBLISHABLE_KEY(starts withpk_) for client-side Stripe.js only. - Webhook signature not verified — Without
constructEvent()verification, attackers can send fake events to fulfill orders, grant access, or modify records. - Raw body not used for webhooks — Using
req.body(parsed JSON) instead of the raw body string/buffer causes signature verification to fail silently. With Express, useexpress.raw({ type: "application/json" })on the webhook route. - Missing idempotency keys — Without idempotency keys, network retries can create duplicate charges. Always pass
{ idempotencyKey }on create/update operations. - Dollar amounts instead of cents —
amount: 10creates a $0.10 charge, not $10.00. Always multiply by 100 or name variablesamountInCents.
Medium Priority Issues:
- Not pinning API version — Without
apiVersionin the constructor, Stripe uses your account's default version. API changes can silently break your integration. - Using
payment_method_typesinstead ofautomatic_payment_methods— The legacy array approach requires manual updates as new payment methods become available.automatic_payment_methods: { enabled: true }is the modern approach. - Swallowing Stripe errors — Empty
catchblocks hide payment failures. Always log the error'srequestIdfor debugging with Stripe support. - Not handling
requires_actionstatus — Payment Intents may require 3D Secure authentication. CheckpaymentIntent.statusafter confirmation. - Polling instead of webhooks — Checking payment status in a loop is unreliable and wastes API calls. Use webhooks for all asynchronous payment events.
Common Mistakes:
- Processing webhooks synchronously — Long-running operations in the webhook handler cause timeouts. Return
200immediately, then process asynchronously. - Not handling duplicate webhook events — Stripe may deliver the same event multiple times. Track processed event IDs to ensure idempotent handling.
- Using test keys in production — Keys starting with
sk_test_andpk_test_only work with test data. Verify your environment configuration. - Forgetting
expandfor nested objects — Stripe returns IDs by default for related objects. Useexpand: ["latest_invoice.payment_intent"]to get full objects.
Gotchas & Edge Cases:
- Stripe events are not ordered —
invoice.paidmay arrive beforeinvoice.created. Design handlers to be order-independent. - Checkout Session
{CHECKOUT_SESSION_ID}is a literal template — Stripe replaces this placeholder in thesuccess_url. Do not URL-encode it. - Subscription proration is on by default — Upgrading a plan mid-cycle prorates automatically. Pass
proration_behavior: "none"to disable. - Idempotency keys expire after 24 hours — After expiry, the same key creates a new request. For long-lived retries, generate a new key.
- Zero-decimal currencies — JPY, KRW, and others have no decimal subunit.
amount: 500in JPY means 500 yen, not 5 yen. CheckStripe.ZERO_DECIMAL_CURRENCIES. - Connect transfers require
transferscapability — Connected accounts must havecard_paymentsandtransferscapabilities enabled before receiving transfers. - Webhook secrets differ per endpoint — Each webhook endpoint has its own signing secret. Using the wrong secret causes all signature verifications to fail.
</red_flags>
<critical_reminders>
CRITICAL REMINDERS
All code must follow project conventions in CLAUDE.md (kebab-case, named exports, import ordering,
import type, named constants)
(You MUST NEVER expose STRIPE_SECRET_KEY in client-side code — it stays on the server only)
(You MUST verify webhook signatures with stripe.webhooks.constructEvent() using the RAW request body — never parsed JSON)
(You MUST use idempotency keys on all mutating (POST) requests to prevent duplicate charges)
(You MUST handle all Stripe errors with instanceof Stripe.errors.StripeError — never swallow payment errors)
(You MUST express monetary amounts in the smallest currency unit — cents for USD, not dollars)
Failure to follow these rules will create security vulnerabilities, duplicate charges, and silent payment failures.
</critical_reminders>
Files (skills)
-
examples
-
connect.md 8.6 KB
# Stripe Connect Examples > Connected accounts, transfers, destination charges, and platform fees. See [SKILL.md](../SKILL.md) for core concepts. **Prerequisites**: Understand [Pattern 1: Client Setup](core.md#pattern-1-client-setup) and [Pattern 3: Payment Intent](core.md#pattern-4-payment-intent--custom-flow) from core examples first. --- ## Pattern 1: Create Connected Account ### Good Example — Express Account (Recommended for Most Platforms) ```typescript async function createConnectedAccount(email: string, country: string = "US") { const account = await stripe.accounts.create({ type: "express", // Stripe-hosted onboarding country, email, capabilities: { card_payments: { requested: true }, transfers: { requested: true }, }, metadata: { platform_user_id: email }, }); return account; } ``` **Why good:** `express` type uses Stripe-hosted onboarding (least work for platforms), `card_payments` and `transfers` capabilities are the minimum for receiving payments, metadata links to your platform's user ### Good Example — Generate Onboarding Link ```typescript async function createOnboardingLink( accountId: string, returnUrl: string, refreshUrl: string, ) { const accountLink = await stripe.accountLinks.create({ account: accountId, refresh_url: refreshUrl, // If link expires, user returns here return_url: returnUrl, // After completing onboarding type: "account_onboarding", }); return accountLink.url; } ``` **Why good:** `refresh_url` handles expired links gracefully, `return_url` redirects after successful onboarding, `account_onboarding` type for initial setup --- ## Pattern 2: Destination Charges (Single Recipient) Use when each payment goes to one connected account. The platform can take an application fee. ### Good Example — Payment with Application Fee ```typescript const PLATFORM_FEE_PERCENT = 10; const CENTS_PER_DOLLAR = 100; async function createDestinationCharge( amountInCents: number, currency: string, connectedAccountId: string, customerId?: string, ) { const applicationFee = Math.round( amountInCents * (PLATFORM_FEE_PERCENT / CENTS_PER_DOLLAR), ); const paymentIntent = await stripe.paymentIntents.create( { amount: amountInCents, currency, customer: customerId, automatic_payment_methods: { enabled: true }, application_fee_amount: applicationFee, transfer_data: { destination: connectedAccountId, }, metadata: { seller_account: connectedAccountId, platform_fee: String(applicationFee), }, }, { idempotencyKey: `dest_${connectedAccountId}_${Date.now()}` }, ); return { clientSecret: paymentIntent.client_secret }; } ``` **Why good:** `application_fee_amount` is the platform's cut (goes to platform's Stripe balance), `transfer_data.destination` sends the remainder to the connected account, fee calculated as percentage with named constants, metadata for audit trail --- ## Pattern 3: Separate Charges and Transfers (Multiple Recipients) Use when a single payment needs to be split among multiple connected accounts (e.g., marketplace with multiple sellers in one cart). ### Good Example — Payment with Transfer Group ```typescript async function createMarketplacePayment( amountInCents: number, currency: string, orderId: string, customerId?: string, ) { const transferGroup = `order_${orderId}`; const paymentIntent = await stripe.paymentIntents.create( { amount: amountInCents, currency, customer: customerId, automatic_payment_methods: { enabled: true }, transfer_group: transferGroup, metadata: { order_id: orderId }, }, { idempotencyKey: `pi_${orderId}` }, ); return { clientSecret: paymentIntent.client_secret, transferGroup, }; } // After payment succeeds (typically in a webhook handler) async function distributePayment( transferGroup: string, distributions: Array<{ accountId: string; amountInCents: number; }>, ) { const transfers = await Promise.all( distributions.map((dist) => stripe.transfers.create( { amount: dist.amountInCents, currency: "usd", destination: dist.accountId, transfer_group: transferGroup, }, { idempotencyKey: `transfer_${transferGroup}_${dist.accountId}`, }, ), ), ); return transfers; } ``` **Why good:** `transfer_group` links payment and transfers for reconciliation, transfers created after payment succeeds (via webhook), idempotency keys prevent duplicate transfers, each seller gets their own transfer **When to use:** Marketplaces with multi-seller carts, food delivery (restaurant + driver), service platforms (provider + platform) --- ## Pattern 4: Direct Charges (On Behalf Of) Use when the connected account processes the payment directly. The platform can still take a fee. ### Good Example — Charge on Connected Account ```typescript async function createDirectCharge( amountInCents: number, currency: string, connectedAccountId: string, platformFeeInCents: number, ) { const paymentIntent = await stripe.paymentIntents.create( { amount: amountInCents, currency, automatic_payment_methods: { enabled: true }, application_fee_amount: platformFeeInCents, }, { stripeAccount: connectedAccountId, // Charge on connected account idempotencyKey: `direct_${connectedAccountId}_${Date.now()}`, }, ); return { clientSecret: paymentIntent.client_secret }; } ``` **Why good:** `stripeAccount` in request options makes the API call on behalf of the connected account, `application_fee_amount` goes to the platform, the connected account handles disputes and refunds --- ## Pattern 5: Check Account Status ### Good Example — Verify Onboarding and Capabilities ```typescript interface AccountStatus { isOnboarded: boolean; canReceivePayments: boolean; canReceiveTransfers: boolean; requiresAction: boolean; disabledReason?: string; } async function getAccountStatus(accountId: string): Promise<AccountStatus> { const account = await stripe.accounts.retrieve(accountId); const cardPayments = account.capabilities?.card_payments; const transfers = account.capabilities?.transfers; return { isOnboarded: account.details_submitted ?? false, canReceivePayments: cardPayments === "active", canReceiveTransfers: transfers === "active", requiresAction: (account.requirements?.currently_due?.length ?? 0) > 0, disabledReason: account.requirements?.disabled_reason ?? undefined, }; } ``` **Why good:** Checks both `details_submitted` and capability status, `requirements.currently_due` indicates what Stripe still needs, `disabled_reason` explains why an account can't transact --- ## Pattern 6: Connect Webhook Handling ### Good Example — Listen for Connect Events ```typescript // Connect events arrive on a SEPARATE webhook endpoint // Register at: Dashboard > Developers > Webhooks > "Connected accounts" async function handleConnectEvent(event: Stripe.Event): Promise<void> { switch (event.type) { case "account.updated": { const account = event.data.object as Stripe.Account; // Check if onboarding is complete if (account.details_submitted && account.charges_enabled) { await activateSellerAccount(account.id); } // Check if account has issues if ( account.requirements?.currently_due && account.requirements.currently_due.length > 0 ) { await notifySellerRequirements( account.id, account.requirements.currently_due, ); } break; } case "payout.failed": { // Payout to connected account's bank failed const payout = event.data.object as Stripe.Payout; await handlePayoutFailure(event.account!, payout); break; } default: console.log(`Unhandled Connect event: ${event.type}`); } } // Placeholder functions async function activateSellerAccount(accountId: string): Promise<void> {} async function notifySellerRequirements( accountId: string, requirements: string[], ): Promise<void> {} async function handlePayoutFailure( accountId: string, payout: Stripe.Payout, ): Promise<void> {} ``` **Why good:** Connect events are on a separate webhook endpoint, `event.account` identifies which connected account triggered the event, checks both `details_submitted` and `charges_enabled`, tracks outstanding requirements --- _For core patterns, see [core.md](core.md). For webhooks, see [webhooks.md](webhooks.md). For subscriptions, see [subscriptions.md](subscriptions.md)._ -
core.md 11 KB
# Stripe Core Examples > Client setup, Checkout Sessions, Payment Intents, and error handling patterns. See [SKILL.md](../SKILL.md) for core concepts. **Webhooks:** See [webhooks.md](webhooks.md). **Subscriptions:** See [subscriptions.md](subscriptions.md). **Connect:** See [connect.md](connect.md). --- ## Pattern 1: Client Setup ### Good Example — Typed Singleton with Pinned API Version ```typescript // lib/stripe.ts import Stripe from "stripe"; const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY!; export const stripe = new Stripe(STRIPE_SECRET_KEY, { apiVersion: "2026-02-25.clover", }); ``` **Why good:** Secret key from environment variable, API version pinned for predictable behavior, singleton export for reuse ### Bad Example — Hardcoded Key, No Version Pin ```typescript import Stripe from "stripe"; // BAD: Hardcoded secret, no version pin const stripe = new Stripe("sk_live_abc123..."); ``` **Why bad:** Hardcoded secret key leaks in source control, no `apiVersion` means silent behavior changes on Stripe updates --- ## Pattern 2: Checkout Session — One-Time Payment ### Good Example — With Metadata and Customer ```typescript async function createCheckoutSession( priceId: string, quantity: number, customerId?: string, ) { const session = await stripe.checkout.sessions.create({ mode: "payment", line_items: [{ price: priceId, quantity }], success_url: `${process.env.APP_URL}/success?session_id={CHECKOUT_SESSION_ID}`, cancel_url: `${process.env.APP_URL}/cancel`, customer: customerId, payment_intent_data: { metadata: { source: "web_checkout" }, }, }); return { sessionId: session.id, url: session.url }; } ``` **Why good:** `{CHECKOUT_SESSION_ID}` is a Stripe template variable (replaced automatically), metadata for tracking, `session.url` returned for redirect ### Bad Example — Hardcoded URLs ```typescript // BAD: Hardcoded URLs, no metadata const session = await stripe.checkout.sessions.create({ mode: "payment", line_items: [{ price: "price_abc", quantity: 1 }], success_url: "http://localhost:3000/success", // Hardcoded, no session ID cancel_url: "http://localhost:3000/cancel", }); ``` **Why bad:** Hardcoded URLs break in production, missing `{CHECKOUT_SESSION_ID}` prevents retrieval on success page, no metadata for tracking --- ## Pattern 3: Checkout Session — Subscription ### Good Example — With Trial and Metadata ```typescript const TRIAL_DAYS = 14; async function createSubscriptionCheckout(priceId: string, customerId: string) { const session = await stripe.checkout.sessions.create({ mode: "subscription", line_items: [{ price: priceId, quantity: 1 }], customer: customerId, success_url: `${process.env.APP_URL}/dashboard?session_id={CHECKOUT_SESSION_ID}`, cancel_url: `${process.env.APP_URL}/pricing`, subscription_data: { trial_period_days: TRIAL_DAYS, metadata: { plan: "pro" }, }, }); return { sessionId: session.id, url: session.url }; } ``` **Why good:** Named constant for trial days, `subscription_data.metadata` carries through to the subscription object, customer pre-linked --- ## Pattern 4: Payment Intent — Custom Flow ### Good Example — With Idempotency Key ```typescript const CURRENCY = "usd"; async function createPaymentIntent( amountInCents: number, customerId: string, orderId: string, ) { const paymentIntent = await stripe.paymentIntents.create( { amount: amountInCents, currency: CURRENCY, customer: customerId, automatic_payment_methods: { enabled: true }, metadata: { order_id: orderId }, }, { idempotencyKey: `pi_${orderId}` }, ); return { clientSecret: paymentIntent.client_secret }; } ``` **Why good:** Parameter named `amountInCents` removes ambiguity, idempotency key tied to order ID (same order = same payment), `automatic_payment_methods` is the modern approach, `client_secret` returned for frontend ### Bad Example — No Idempotency, Legacy Payment Methods ```typescript // BAD: Missing idempotency key, legacy approach async function createPayment(amount: number) { const pi = await stripe.paymentIntents.create({ amount, // Dollars or cents? Ambiguous! currency: "usd", payment_method_types: ["card"], // Legacy array }); return pi; } ``` **Why bad:** No idempotency key risks duplicate charges, `amount` parameter name is ambiguous, `payment_method_types` is legacy (use `automatic_payment_methods`), no customer or metadata --- ## Pattern 5: Retrieving and Expanding Objects ### Good Example — Expand Related Objects ```typescript // Retrieve a checkout session with expanded payment intent and line items async function getCheckoutDetails(sessionId: string) { const session = await stripe.checkout.sessions.retrieve(sessionId, { expand: ["payment_intent", "line_items"], }); // session.payment_intent is now a full PaymentIntent object, not just an ID const paymentIntent = session.payment_intent as Stripe.PaymentIntent; const lineItems = session.line_items?.data ?? []; return { session, paymentIntent, lineItems }; } // Retrieve subscription with latest invoice and payment intent async function getSubscriptionPaymentStatus(subscriptionId: string) { const subscription = await stripe.subscriptions.retrieve(subscriptionId, { expand: ["latest_invoice.payment_intent"], }); const invoice = subscription.latest_invoice as Stripe.Invoice; const pi = invoice.payment_intent as Stripe.PaymentIntent; return { status: pi.status, subscription }; } ``` **Why good:** `expand` fetches nested objects in a single API call (avoids multiple round-trips), cast to full type since Stripe returns expanded objects as `string | Object` --- ## Pattern 6: Error Handling — Complete Pattern ### Good Example — Typed Error Handling ```typescript import Stripe from "stripe"; interface PaymentResult { success: boolean; error?: { type: string; message: string; code?: string; declineCode?: string; requestId?: string; }; } async function processPayment(paymentIntentId: string): Promise<PaymentResult> { try { await stripe.paymentIntents.confirm(paymentIntentId); return { success: true }; } catch (error) { if (error instanceof Stripe.errors.StripeCardError) { return { success: false, error: { type: "card_error", message: error.message, code: error.code, declineCode: error.raw?.decline_code as string | undefined, }, }; } if (error instanceof Stripe.errors.StripeInvalidRequestError) { // Developer error — log and throw console.error(`[Stripe] Invalid request: ${error.message}`, { requestId: error.requestId, param: error.raw?.param, }); throw error; } if (error instanceof Stripe.errors.StripeRateLimitError) { // Retry-able throw new Error("Rate limited — retry with backoff"); } if (error instanceof Stripe.errors.StripeConnectionError) { // Network issue — safe to retry with same idempotency key throw new Error("Connection failed — retry"); } if (error instanceof Stripe.errors.StripeAuthenticationError) { // Critical configuration error throw new Error("Invalid Stripe API key"); } throw error; } } ``` **Why good:** Each error type gets appropriate handling, card errors return user-safe messages, developer errors include `requestId` for Stripe support debugging, connection errors hint at retry safety ### Bad Example — Swallowing Errors ```typescript // BAD: Silent catch async function charge(piId: string) { try { return await stripe.paymentIntents.confirm(piId); } catch { return null; // Payment failure hidden! } } ``` **Why bad:** Error completely swallowed, caller has no idea payment failed, no logging for debugging, null return hides the actual problem --- ## Pattern 7: Customer Management ### Good Example — Create with Idempotency ```typescript async function findOrCreateCustomer( email: string, name: string, ): Promise<Stripe.Customer> { // Check if customer exists const existing = await stripe.customers.list({ email, limit: 1 }); if (existing.data.length > 0) { return existing.data[0]; } // Create new customer with idempotency key const customer = await stripe.customers.create( { email, name, metadata: { source: "api" } }, { idempotencyKey: `cus_${email}` }, ); return customer; } ``` **Why good:** Checks for existing customer before creating (avoids duplicates), idempotency key based on email, typed return `Stripe.Customer` ### Good Example — Attach Payment Method ```typescript async function attachAndSetDefault( customerId: string, paymentMethodId: string, ) { await stripe.paymentMethods.attach(paymentMethodId, { customer: customerId, }); await stripe.customers.update(customerId, { invoice_settings: { default_payment_method: paymentMethodId, }, }); } ``` **Why good:** Two-step process: attach first, then set as default for invoices. `invoice_settings.default_payment_method` ensures subscriptions charge the right card. --- ## Pattern 8: Products and Prices ### Good Example — Create Product with Recurring Price ```typescript async function createProductWithPrice( name: string, description: string, amountInCents: number, currency: string, interval?: "month" | "year", ) { const product = await stripe.products.create({ name, description, metadata: { managed_by: "api" }, }); const price = await stripe.prices.create({ product: product.id, unit_amount: amountInCents, currency, ...(interval ? { recurring: { interval } } : {}), }); return { productId: product.id, priceId: price.id }; } ``` **Why good:** Products and prices are separate resources (Stripe's data model), `unit_amount` named clearly, conditional `recurring` only for subscriptions ### Good Example — List Active Prices for a Product ```typescript async function getActivePrices(productId: string) { const prices = await stripe.prices.list({ product: productId, active: true, expand: ["data.product"], }); return prices.data; } ``` **Why good:** Filters to `active: true` (excludes archived prices), `expand` fetches product data in the same call --- ## Pattern 9: Refunds ### Good Example — Full and Partial Refunds ```typescript async function refundPayment( paymentIntentId: string, amountInCents?: number, reason?: Stripe.RefundCreateParams.Reason, ) { const refund = await stripe.refunds.create( { payment_intent: paymentIntentId, amount: amountInCents, // Omit for full refund reason, }, { idempotencyKey: `refund_${paymentIntentId}_${amountInCents ?? "full"}`, }, ); return refund; } ``` **Why good:** Omitting `amount` refunds the full payment, `reason` uses Stripe's typed enum, idempotency key unique per refund amount --- _For webhook handling, see [webhooks.md](webhooks.md). For subscriptions, see [subscriptions.md](subscriptions.md). For Connect, see [connect.md](connect.md)._ -
subscriptions.md 9.9 KB
# Stripe Subscription Examples > Subscription lifecycle, trials, proration, and billing patterns. See [SKILL.md](../SKILL.md) for core concepts. **Prerequisites**: Understand [Pattern 1: Client Setup](core.md#pattern-1-client-setup) and [Pattern 7: Customer Management](core.md#pattern-7-customer-management) from core examples first. --- ## Pattern 1: Create Subscription ### Good Example — With Default Payment Method ```typescript async function createSubscription( customerId: string, priceId: string, trialDays?: number, ) { const subscription = await stripe.subscriptions.create( { customer: customerId, items: [{ price: priceId }], payment_behavior: "default_incomplete", payment_settings: { save_default_payment_method: "on_subscription", }, expand: ["latest_invoice.payment_intent"], ...(trialDays ? { trial_period_days: trialDays } : {}), metadata: { created_via: "api" }, }, { idempotencyKey: `sub_${customerId}_${priceId}` }, ); // For "default_incomplete", the first invoice needs payment confirmation const invoice = subscription.latest_invoice as Stripe.Invoice; const paymentIntent = invoice.payment_intent as Stripe.PaymentIntent | null; return { subscriptionId: subscription.id, clientSecret: paymentIntent?.client_secret, status: subscription.status, }; } ``` **Why good:** `payment_behavior: "default_incomplete"` creates the subscription but waits for payment confirmation (SCA-ready), `save_default_payment_method` stores the card for future invoices, `expand` fetches nested objects in one call, idempotency key prevents duplicate subscriptions ### Bad Example — No Payment Behavior ```typescript // BAD: Missing payment handling const sub = await stripe.subscriptions.create({ customer: "cus_abc", items: [{ price: "price_xyz" }], // No payment_behavior — defaults to "allow_incomplete" // No expand — requires extra API calls for invoice/payment intent }); ``` **Why bad:** `allow_incomplete` creates subscription even if first payment fails, no expand means extra API calls, no idempotency key, hardcoded IDs --- ## Pattern 2: Update Subscription (Plan Change) ### Good Example — Upgrade with Proration ```typescript async function changeSubscriptionPlan( subscriptionId: string, newPriceId: string, ) { const subscription = await stripe.subscriptions.retrieve(subscriptionId); const updatedSubscription = await stripe.subscriptions.update( subscriptionId, { items: [ { id: subscription.items.data[0].id, price: newPriceId, }, ], proration_behavior: "create_prorations", // Default — charge difference expand: ["latest_invoice.payment_intent"], }, ); return updatedSubscription; } ``` **Why good:** Retrieves existing subscription to get the item ID, `proration_behavior` is explicit (even though "create_prorations" is the default), expand for immediate access to invoice ### Good Example — Downgrade Without Proration ```typescript async function downgradeAtPeriodEnd( subscriptionId: string, newPriceId: string, ) { const subscription = await stripe.subscriptions.retrieve(subscriptionId); const updatedSubscription = await stripe.subscriptions.update( subscriptionId, { items: [ { id: subscription.items.data[0].id, price: newPriceId, }, ], proration_behavior: "none", // No proration — takes effect at period end }, ); return updatedSubscription; } ``` **Why good:** `proration_behavior: "none"` avoids immediate charges, plan change takes effect at next billing cycle, appropriate for downgrades --- ## Pattern 3: Cancel Subscription ### Good Example — Immediate and End-of-Period Cancellation ```typescript // Cancel at end of billing period (customer keeps access until then) async function cancelAtPeriodEnd(subscriptionId: string) { const subscription = await stripe.subscriptions.update(subscriptionId, { cancel_at_period_end: true, }); return { cancelAt: subscription.cancel_at, currentPeriodEnd: subscription.current_period_end, }; } // Cancel immediately (prorated refund if applicable) async function cancelImmediately(subscriptionId: string) { const subscription = await stripe.subscriptions.cancel(subscriptionId, { prorate: true, // Refund unused time }); return { status: subscription.status }; // "canceled" } // Reactivate a subscription scheduled for cancellation async function reactivateSubscription(subscriptionId: string) { const subscription = await stripe.subscriptions.update(subscriptionId, { cancel_at_period_end: false, }); return subscription; } ``` **Why good:** Two cancellation strategies (end-of-period vs immediate), `cancel_at_period_end: true` is user-friendly (keeps access), `prorate: true` refunds unused time on immediate cancel, reactivation by setting `cancel_at_period_end: false` --- ## Pattern 4: Trial Periods ### Good Example — Free Trial with Payment Method Required ```typescript const TRIAL_DAYS = 14; async function createTrialSubscription(customerId: string, priceId: string) { const subscription = await stripe.subscriptions.create( { customer: customerId, items: [{ price: priceId }], trial_period_days: TRIAL_DAYS, payment_settings: { save_default_payment_method: "on_subscription", }, // trial_settings controls what happens when the trial ends trial_settings: { end_behavior: { missing_payment_method: "cancel", // Cancel if no card on file }, }, expand: ["latest_invoice.payment_intent"], }, { idempotencyKey: `trial_${customerId}_${priceId}` }, ); return { subscriptionId: subscription.id, trialEnd: subscription.trial_end, status: subscription.status, // "trialing" }; } ``` **Why good:** Named constant for trial days, `trial_settings.end_behavior` prevents zombie subscriptions (cancels if no payment method), saves payment method for post-trial billing --- ## Pattern 5: Subscription Webhook Handling ### Good Example — Lifecycle Event Handlers ```typescript async function handleInvoicePaid(invoice: Stripe.Invoice): Promise<void> { const subscriptionId = typeof invoice.subscription === "string" ? invoice.subscription : invoice.subscription?.id; if (!subscriptionId) { return; // One-time payment invoice, not subscription } // Extend access until the subscription's current_period_end const subscription = await stripe.subscriptions.retrieve(subscriptionId); await updateUserAccess({ customerId: typeof invoice.customer === "string" ? invoice.customer : (invoice.customer?.id ?? ""), subscriptionId, accessUntil: new Date(subscription.current_period_end * 1000), plan: subscription.metadata.plan, }); } async function handleInvoicePaymentFailed( invoice: Stripe.Invoice, ): Promise<void> { const customerId = typeof invoice.customer === "string" ? invoice.customer : invoice.customer?.id; if (!customerId) { return; } // Notify customer to update payment method await notifyPaymentFailed({ customerId, invoiceUrl: invoice.hosted_invoice_url, attemptCount: invoice.attempt_count, }); } async function handleSubscriptionCanceled( subscription: Stripe.Subscription, ): Promise<void> { const customerId = typeof subscription.customer === "string" ? subscription.customer : subscription.customer?.id; if (!customerId) { return; } await revokeAccess({ customerId, subscriptionId: subscription.id, canceledAt: subscription.canceled_at ? new Date(subscription.canceled_at * 1000) : new Date(), }); } // Placeholder functions — implement with your database solution async function updateUserAccess(data: { customerId: string; subscriptionId: string; accessUntil: Date; plan?: string; }): Promise<void> {} async function notifyPaymentFailed(data: { customerId: string; invoiceUrl?: string | null; attemptCount: number; }): Promise<void> {} async function revokeAccess(data: { customerId: string; subscriptionId: string; canceledAt: Date; }): Promise<void> {} ``` **Why good:** Handles the three critical subscription events, `invoice.customer` and `invoice.subscription` can be string IDs or expanded objects (handles both), timestamps converted from Unix to Date, `hosted_invoice_url` for customer self-service payment retry --- ## Pattern 6: Preview Upcoming Invoice ### Good Example — Show Proration Preview Before Plan Change ```typescript async function previewPlanChange( subscriptionId: string, newPriceId: string, ): Promise<{ amountDue: number; prorationAmount: number; currency: string; }> { const subscription = await stripe.subscriptions.retrieve(subscriptionId); const invoice = await stripe.invoices.createPreview({ customer: typeof subscription.customer === "string" ? subscription.customer : subscription.customer.id, subscription: subscriptionId, subscription_details: { items: [ { id: subscription.items.data[0].id, price: newPriceId, }, ], proration_behavior: "create_prorations", }, }); const prorationItems = (invoice.lines?.data ?? []).filter( (line) => line.proration, ); const prorationAmount = prorationItems.reduce( (sum, item) => sum + item.amount, 0, ); return { amountDue: invoice.amount_due, prorationAmount, currency: invoice.currency, }; } ``` **Why good:** Uses `invoices.createPreview` (the current API — replaces the deprecated `invoices.retrieveUpcoming`), shows customer exactly what they'll be charged before confirming, proration items extracted for transparent breakdown --- _For core patterns, see [core.md](core.md). For webhooks, see [webhooks.md](webhooks.md). For Connect, see [connect.md](connect.md)._ -
webhooks.md 9.8 KB
# Stripe Webhook Examples > Signature verification, event handling, and idempotent processing. See [SKILL.md](../SKILL.md) for core concepts. **Prerequisites**: Understand [Pattern 1: Client Setup](core.md#pattern-1-client-setup) from core examples first. --- ## Pattern 1: Webhook Signature Verification (Express) ### Good Example — Raw Body with constructEvent ```typescript import express from "express"; import Stripe from "stripe"; const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!); const STRIPE_WEBHOOK_SECRET = process.env.STRIPE_WEBHOOK_SECRET!; const app = express(); // CRITICAL: Webhook route MUST use raw body — place BEFORE express.json() app.post( "/api/webhooks/stripe", express.raw({ type: "application/json" }), async (req, res) => { const signature = req.headers["stripe-signature"]; if (!signature) { res.status(400).send("Missing Stripe-Signature header"); return; } let event: Stripe.Event; try { event = stripe.webhooks.constructEvent( req.body, // Raw Buffer — NOT parsed JSON signature, STRIPE_WEBHOOK_SECRET, ); } catch (error) { const message = error instanceof Error ? error.message : "Unknown error"; console.error(`Webhook signature verification failed: ${message}`); res.status(400).send(`Webhook Error: ${message}`); return; } // Handle the event await handleStripeEvent(event); // Return 200 immediately — do not block on long operations res.json({ received: true }); }, ); // Other routes use parsed JSON app.use(express.json()); ``` **Why good:** `express.raw()` preserves the raw body needed for signature verification, webhook route placed BEFORE `express.json()` middleware, `constructEvent` verifies HMAC-SHA256 signature, 200 returned immediately ### Bad Example — Parsed Body Breaks Verification ```typescript // BAD: express.json() applied globally BEFORE webhook route app.use(express.json()); // Parses body into object app.post("/webhook", (req, res) => { // req.body is now a parsed object — signature verification WILL FAIL const event = stripe.webhooks.constructEvent( req.body, // Object, not raw string/buffer! req.headers["stripe-signature"]!, "whsec_...", ); }); ``` **Why bad:** `express.json()` parses the body before the webhook route, changing the byte representation. Signature verification requires the exact bytes Stripe sent. This fails silently with a cryptographic mismatch. --- ## Pattern 2: Webhook Handler — Generic Framework (Non-Express) ### Good Example — Framework-Agnostic Handler ```typescript // Works with any framework that gives you the raw request body async function handleWebhook( rawBody: string | Buffer, signatureHeader: string, ): Promise<{ status: number; body: string }> { let event: Stripe.Event; try { event = stripe.webhooks.constructEvent( rawBody, signatureHeader, process.env.STRIPE_WEBHOOK_SECRET!, ); } catch (error) { const message = error instanceof Error ? error.message : "Unknown error"; return { status: 400, body: `Webhook Error: ${message}` }; } await handleStripeEvent(event); return { status: 200, body: JSON.stringify({ received: true }) }; } ``` **Why good:** Works with any HTTP framework or serverless runtime, accepts raw body as parameter, returns status and body for the caller to send --- ## Pattern 3: Event Routing and Processing ### Good Example — Type-Safe Event Handling ```typescript async function handleStripeEvent(event: Stripe.Event): Promise<void> { switch (event.type) { // Checkout completed — fulfill order or provision access case "checkout.session.completed": { const session = event.data.object as Stripe.Checkout.Session; await handleCheckoutComplete(session); break; } // Payment succeeded case "payment_intent.succeeded": { const paymentIntent = event.data.object as Stripe.PaymentIntent; await handlePaymentSuccess(paymentIntent); break; } // Payment failed case "payment_intent.payment_failed": { const paymentIntent = event.data.object as Stripe.PaymentIntent; await handlePaymentFailure(paymentIntent); break; } // Subscription invoice paid case "invoice.paid": { const invoice = event.data.object as Stripe.Invoice; await handleInvoicePaid(invoice); break; } // Subscription invoice failed case "invoice.payment_failed": { const invoice = event.data.object as Stripe.Invoice; await handleInvoicePaymentFailed(invoice); break; } // Subscription canceled case "customer.subscription.deleted": { const subscription = event.data.object as Stripe.Subscription; await handleSubscriptionCanceled(subscription); break; } // Dispute created case "charge.dispute.created": { const dispute = event.data.object as Stripe.Dispute; await handleDisputeCreated(dispute); break; } default: // Unhandled event — log but don't error console.log(`Unhandled Stripe event type: ${event.type}`); } } ``` **Why good:** Each event type gets its own handler, `event.data.object` cast to correct Stripe type, switch with default for unhandled events, async handlers for database operations --- ## Pattern 4: Idempotent Webhook Processing ### Good Example — Track Processed Event IDs ```typescript // Ensure each webhook event is processed exactly once async function handleStripeEventIdempotent(event: Stripe.Event): Promise<void> { // Check if this event was already processed const alreadyProcessed = await isEventProcessed(event.id); if (alreadyProcessed) { console.log(`Skipping already processed event: ${event.id}`); return; } // Process the event await handleStripeEvent(event); // Mark as processed AFTER successful handling await markEventProcessed(event.id, event.type); } // Example database functions (implement with your database solution) async function isEventProcessed(eventId: string): Promise<boolean> { // Query your database for the event ID // e.g., SELECT 1 FROM processed_stripe_events WHERE event_id = $1 return false; // placeholder } async function markEventProcessed( eventId: string, eventType: string, ): Promise<void> { // Insert into your database // e.g., INSERT INTO processed_stripe_events (event_id, event_type, processed_at) // VALUES ($1, $2, NOW()) // ON CONFLICT (event_id) DO NOTHING } ``` **Why good:** Prevents duplicate processing when Stripe retries delivery, event ID is globally unique, processing marked AFTER success (not before), `ON CONFLICT DO NOTHING` handles race conditions --- ## Pattern 5: Async Processing for Long Operations ### Good Example — Return 200 Immediately, Process Later ```typescript app.post( "/api/webhooks/stripe", express.raw({ type: "application/json" }), async (req, res) => { let event: Stripe.Event; try { event = stripe.webhooks.constructEvent( req.body, req.headers["stripe-signature"]!, STRIPE_WEBHOOK_SECRET, ); } catch (error) { const message = error instanceof Error ? error.message : "Unknown error"; res.status(400).send(`Webhook Error: ${message}`); return; } // Return 200 IMMEDIATELY — Stripe expects a response within 20 seconds res.json({ received: true }); // Process asynchronously (outside the request lifecycle) // In production, use a message queue (not shown — use your queue solution) try { await handleStripeEventIdempotent(event); } catch (error) { // Log but don't crash — event will be retried by Stripe console.error(`Failed to process event ${event.id}:`, error); } }, ); ``` **Why good:** 200 returned before processing, Stripe won't time out or retry unnecessarily, processing failure is logged but doesn't crash the server, Stripe will retry on next delivery ### Bad Example — Synchronous Processing ```typescript // BAD: Processing before responding app.post( "/webhook", express.raw({ type: "application/json" }), async (req, res) => { const event = stripe.webhooks.constructEvent(/* ... */); // Slow operations block the response await updateDatabase(event); // 500ms await sendConfirmationEmail(event); // 2000ms await notifyExternalService(event); // 1500ms res.json({ received: true }); // 4+ seconds later — may timeout }, ); ``` **Why bad:** Stripe expects a response within 20 seconds, slow operations may cause timeouts, Stripe will retry the event if it times out, leading to duplicate processing --- ## Pattern 6: Webhook in Serverless Functions ### Good Example — Serverless Handler (Generic) ```typescript // Works with serverless platforms that provide raw body access export async function POST(request: Request): Promise<Response> { const rawBody = await request.text(); // Raw string, not parsed JSON const signature = request.headers.get("stripe-signature"); if (!signature) { return new Response("Missing signature", { status: 400 }); } let event: Stripe.Event; try { event = stripe.webhooks.constructEvent( rawBody, signature, process.env.STRIPE_WEBHOOK_SECRET!, ); } catch (error) { const message = error instanceof Error ? error.message : "Unknown error"; return new Response(`Webhook Error: ${message}`, { status: 400 }); } await handleStripeEventIdempotent(event); return new Response(JSON.stringify({ received: true }), { status: 200, headers: { "Content-Type": "application/json" }, }); } ``` **Why good:** Uses Web-standard `Request`/`Response` (works in any serverless runtime), `request.text()` preserves raw body, no framework-specific middleware needed --- _For core patterns, see [core.md](core.md). For subscriptions, see [subscriptions.md](subscriptions.md). For Connect, see [connect.md](connect.md)._
-
-
reference.md 7.5 KB
# Stripe Reference > Decision frameworks, API quick reference, and error code lookup tables. See [SKILL.md](SKILL.md) for core concepts and [examples/](examples/) for code examples. --- ## Payment Flow Decision Framework ``` What type of payment? ├─ One-time payment │ ├─ Simple (no custom UI needed) → Checkout Session (mode: "payment") │ └─ Custom UI (Stripe Elements) → Payment Intent ├─ Recurring subscription │ ├─ Simple checkout → Checkout Session (mode: "subscription") │ └─ Custom billing logic → stripe.subscriptions.create() ├─ Save card for later │ └─ Checkout Session (mode: "setup") or Setup Intent └─ Marketplace / split payment ├─ Pay seller directly → Destination charge (Connect) └─ Platform collects, distributes → Separate charges + transfers (Connect) ``` --- ## Connect Charge Type Decision ``` Who processes the payment? ├─ Platform processes, sends to seller │ ├─ Single recipient per payment → Destination charge │ │ └─ stripe.paymentIntents.create({ transfer_data: { destination } }) │ └─ Multiple recipients per payment → Separate charges + transfers │ └─ stripe.paymentIntents.create({ transfer_group }) │ └─ stripe.transfers.create({ destination, transfer_group }) └─ Seller processes directly └─ Direct charge (on_behalf_of) └─ stripe.paymentIntents.create({}, { stripeAccount }) ``` --- ## Webhook Event Priority Handle these events for a robust integration: | Event | When | Action | | ------------------------------- | ----------------------------- | --------------------------------- | | `checkout.session.completed` | Customer completes checkout | Fulfill order, provision access | | `payment_intent.succeeded` | Payment confirmed | Update order status | | `payment_intent.payment_failed` | Payment failed | Notify customer, retry | | `invoice.paid` | Subscription invoice paid | Extend subscription access | | `invoice.payment_failed` | Subscription payment failed | Notify, handle grace period | | `customer.subscription.updated` | Subscription changed | Update plan in database | | `customer.subscription.deleted` | Subscription canceled | Revoke access | | `charge.dispute.created` | Chargeback filed | Flag order, gather evidence | | `account.updated` | Connect account status change | Check requirements, update status | --- ## Stripe Error Types | Error Type | HTTP Status | Meaning | Action | | --------------------------- | ----------- | ------------------------ | ------------------------------- | | `StripeCardError` | 402 | Card declined | Show `error.message` to user | | `StripeInvalidRequestError` | 400 | Invalid parameters | Fix code (developer error) | | `StripeAPIError` | 500 | Stripe internal error | Retry with backoff | | `StripeConnectionError` | N/A | Network failure | Retry with same idempotency key | | `StripeAuthenticationError` | 401 | Invalid API key | Check key configuration | | `StripeRateLimitError` | 429 | Too many requests | Retry with exponential backoff | | `StripePermissionError` | 403 | Insufficient permissions | Check API key scope | | `StripeIdempotencyError` | 400 | Idempotency key conflict | Generate new idempotency key | --- ## Common Card Decline Codes | Code | Meaning | User Message | | -------------------- | -------------------- | ------------------------- | | `card_declined` | Generic decline | "Your card was declined" | | `insufficient_funds` | Not enough balance | "Insufficient funds" | | `expired_card` | Card expired | "Your card has expired" | | `incorrect_cvc` | Wrong CVC | "Incorrect security code" | | `processing_error` | Processing failed | "Please try again" | | `lost_card` | Card reported lost | "Your card was declined" | | `stolen_card` | Card reported stolen | "Your card was declined" | --- ## Subscription Status Lifecycle ``` incomplete → active → past_due → canceled → trialing → active → paused → active → unpaid → canceled ``` | Status | Meaning | | -------------------- | ------------------------------------------ | | `incomplete` | First invoice not paid (within 23 hours) | | `incomplete_expired` | First invoice not paid within 23 hours | | `trialing` | In trial period, no charge yet | | `active` | Paid and current | | `past_due` | Latest invoice unpaid, retrying | | `unpaid` | All retry attempts exhausted | | `canceled` | Terminated (by API or failed payments) | | `paused` | Temporarily paused (no invoices generated) | --- ## Currency Amounts Quick Reference | Currency | Smallest Unit | `amount: 1000` means | | -------- | ------------------ | -------------------- | | USD | cent | $10.00 | | EUR | cent | 10.00 euro | | GBP | penny | 10.00 pound | | JPY | yen (zero-decimal) | 1000 yen | | KRW | won (zero-decimal) | 1000 won | **Zero-decimal currencies** do not need multiplication by 100. Check the Stripe docs for the full list. --- ## Idempotency Key Patterns | Operation | Key Pattern | Example | | ------------------- | ------------------------------------- | ------------------------------ | | Create customer | `cus_create_${email}` | `cus_create_alice@example.com` | | Create payment | `pi_${orderId}` | `pi_order_12345` | | Create subscription | `sub_${customerId}_${priceId}` | `sub_cus_abc_price_xyz` | | Refund | `refund_${paymentIntentId}_${amount}` | `refund_pi_abc_2000` | | Transfer | `transfer_${orderId}_${accountId}` | `transfer_order_123_acct_xyz` | --- ## Environment Variables ```bash # .env STRIPE_SECRET_KEY=sk_test_... # Server only — NEVER expose to client STRIPE_PUBLISHABLE_KEY=pk_test_... # Safe for client-side (Stripe.js) STRIPE_WEBHOOK_SECRET=whsec_... # Per-endpoint, used in constructEvent() APP_URL=http://localhost:3000 # For success/cancel URLs ``` --- ## Stripe CLI (Local Development) ```bash # Install brew install stripe/stripe-cli/stripe # macOS # or download from https://stripe.com/docs/stripe-cli # Login stripe login # Listen for webhook events and forward to local server stripe listen --forward-to localhost:3000/api/webhooks/stripe # Outputs: whsec_... (use as STRIPE_WEBHOOK_SECRET locally) # Trigger a specific event for testing stripe trigger payment_intent.succeeded stripe trigger checkout.session.completed stripe trigger customer.subscription.created # View recent events stripe events list --limit 5 ``` -
SKILL.md 12.9 KB
--- name: api-commerce-stripe description: Stripe payment processing — Checkout Sessions, Payment Intents, subscriptions, webhooks, Connect, customer management, error handling --- # Stripe Patterns > **Quick Guide:** Use the `stripe` npm package for all server-side Stripe operations. Always verify webhook signatures with `constructEvent()` using the raw request body, never the parsed body. Use idempotency keys on all mutating requests. Keep the secret key server-side only. Handle errors with `instanceof Stripe.errors.StripeError`. Amounts are always in the smallest currency unit (e.g., cents for USD). --- <critical_requirements> ## CRITICAL: Before Using This Skill > **All code must follow project conventions in CLAUDE.md** (kebab-case, named exports, import ordering, `import type`, named constants) **(You MUST NEVER expose `STRIPE_SECRET_KEY` in client-side code — it stays on the server only)** **(You MUST verify webhook signatures with `stripe.webhooks.constructEvent()` using the RAW request body — never parsed JSON)** **(You MUST use idempotency keys on all mutating (POST) requests to prevent duplicate charges)** **(You MUST handle all Stripe errors with `instanceof Stripe.errors.StripeError` — never swallow payment errors)** **(You MUST express monetary amounts in the smallest currency unit — cents for USD, not dollars)** </critical_requirements> --- **Auto-detection:** Stripe, stripe, stripe.checkout.sessions, stripe.paymentIntents, stripe.customers, stripe.subscriptions, stripe.webhooks, constructEvent, PaymentIntent, CheckoutSession, STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, stripe.prices, stripe.products, stripe.refunds, stripe.transfers, stripe.accounts, Stripe.errors, idempotencyKey, payment_intent.succeeded, checkout.session.completed **When to use:** - Creating Checkout Sessions for one-time or subscription payments - Building custom payment flows with Payment Intents - Handling webhook events for asynchronous payment lifecycle - Managing customers, payment methods, and subscriptions - Building marketplace platforms with Stripe Connect - Processing refunds and handling disputes - Setting up products and prices for a catalog **Key patterns covered:** - Stripe client initialization with TypeScript types - Checkout Sessions (one-time payments, subscriptions, setup mode) - Payment Intents (custom flows, confirmation, capture) - Webhook signature verification and event handling - Customer creation, update, and payment method attachment - Subscription lifecycle (create, update, cancel, trials, proration) - Products and Prices (catalog management) - Stripe Connect (account creation, transfers, destination charges) - Error handling with typed Stripe errors - Idempotency keys for safe retries **When NOT to use:** - Client-side Stripe.js or Stripe Elements (use your frontend framework skill) - Stripe CLI commands or dashboard configuration - Non-Stripe payment processors (use their dedicated skill) **Detailed Resources:** - For decision frameworks and anti-patterns, see [reference.md](reference.md) **Core Setup & Payments:** - [examples/core.md](examples/core.md) — Client setup, Checkout Sessions, Payment Intents, error handling **Webhooks & Events:** - [examples/webhooks.md](examples/webhooks.md) — Signature verification, event handling, idempotent processing **Subscriptions & Billing:** - [examples/subscriptions.md](examples/subscriptions.md) — Subscription lifecycle, trials, proration, metered billing **Connect & Platforms:** - [examples/connect.md](examples/connect.md) — Connected accounts, transfers, destination charges, platform fees --- <philosophy> ## Philosophy Stripe is a payment infrastructure platform. The `stripe` npm package is the server-side SDK for interacting with the Stripe API. All payment processing happens server-side for security. **Core principles:** 1. **Server-side only** — The secret key and all payment-creating operations must never run in the browser. Client-side uses Stripe.js (a separate concern) only for collecting payment details. 2. **Amounts in smallest unit** — All monetary values are integers in the smallest currency unit (cents for USD, pence for GBP). `1000` means $10.00, not $1000. 3. **Idempotency for safety** — Every mutating request should include an idempotency key to prevent duplicate charges on network retries. Stripe's SDK auto-generates keys for retries, but you should provide explicit keys for application-level retries. 4. **Webhooks are the source of truth** — Payment status should be confirmed via webhooks, not by polling. Webhook events are the only reliable indicator that a payment succeeded, failed, or requires action. 5. **Error as typed exceptions** — Stripe errors are thrown (not returned as values). Catch with `instanceof Stripe.errors.StripeError` and handle by type for appropriate user responses. 6. **API versioning matters** — Pin your API version. Types reflect the latest API version. Use `apiVersion` in the constructor to lock behavior. **When to use Stripe:** - Accepting payments (one-time, recurring, marketplace splits) - Building subscription billing systems - Platform/marketplace payment splitting with Connect - Saving payment methods for future charges **When NOT to use:** - Client-side payment form rendering (Stripe.js / Elements is a separate domain) - Payment processing without a server (Stripe requires server-side secret key) - Simple donation buttons (Stripe Payment Links may suffice without code) </philosophy> --- <patterns> ## Core Patterns ### Pattern 1: Stripe Client Initialization Create a singleton Stripe client. Secret key from env, API version pinned. See [examples/core.md](examples/core.md) for full setup. ```typescript export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { apiVersion: "2026-02-25.clover", }); ``` Never hardcode the secret key or omit `apiVersion` (behavior changes silently on Stripe API upgrades). --- ### Pattern 2: Checkout Sessions Use `mode: "payment"` for one-time, `mode: "subscription"` for recurring. Stripe hosts the payment page. Always include `{CHECKOUT_SESSION_ID}` in the success URL (Stripe replaces this template automatically). See [examples/core.md](examples/core.md) for full examples. ```typescript const session = await stripe.checkout.sessions.create({ mode: "payment", // or "subscription" or "setup" line_items: [{ price: priceId, quantity }], success_url: `${process.env.APP_URL}/success?session_id={CHECKOUT_SESSION_ID}`, cancel_url: `${process.env.APP_URL}/cancel`, }); ``` --- ### Pattern 3: Payment Intents (Custom Flows) Use Payment Intents when you need full control over the payment UI (e.g., Stripe Elements). Always use `automatic_payment_methods` (not the legacy `payment_method_types` array) and include an idempotency key. See [examples/core.md](examples/core.md) for full examples. ```typescript const paymentIntent = await stripe.paymentIntents.create( { amount: amountInCents, currency, automatic_payment_methods: { enabled: true }, }, { idempotencyKey: `pi_${orderId}` }, ); return { clientSecret: paymentIntent.client_secret }; ``` Name parameters `amountInCents` to avoid dollar/cent confusion. Return `client_secret` to the frontend. --- ### Pattern 4: Customer Management Create customers with idempotency keys (based on email to prevent duplicates). Attach payment methods in two steps: attach, then set as default via `invoice_settings.default_payment_method`. See [examples/core.md](examples/core.md) for full examples. --- ### Pattern 5: Products and Prices Products and prices are separate resources in Stripe's data model. Add `recurring: { interval }` only for subscription prices. Name the amount parameter `amountInCents`. See [examples/core.md](examples/core.md) for full examples. --- ### Pattern 6: Refunds Omit `amount` for a full refund. Use `payment_intent` (preferred over `charge`). Always include an idempotency key unique to the refund amount. See [examples/core.md](examples/core.md) for full examples. --- ### Pattern 7: Error Handling Catch errors with `instanceof Stripe.errors.StripeCardError` (and other error subclasses). `StripeCardError` returns user-safe messages with `decline_code`. `StripeInvalidRequestError` is a developer bug. `StripeConnectionError` and `StripeRateLimitError` are retry-able. See [examples/core.md](examples/core.md) for the complete error handling pattern. ```typescript if (error instanceof Stripe.errors.StripeCardError) { return { success: false, message: error.message, code: error.code }; } ``` </patterns> --- <red_flags> ## RED FLAGS **High Priority Issues:** - **Secret key in client-side code** — `STRIPE_SECRET_KEY` must never appear in browser bundles. Use `STRIPE_PUBLISHABLE_KEY` (starts with `pk_`) for client-side Stripe.js only. - **Webhook signature not verified** — Without `constructEvent()` verification, attackers can send fake events to fulfill orders, grant access, or modify records. - **Raw body not used for webhooks** — Using `req.body` (parsed JSON) instead of the raw body string/buffer causes signature verification to fail silently. With Express, use `express.raw({ type: "application/json" })` on the webhook route. - **Missing idempotency keys** — Without idempotency keys, network retries can create duplicate charges. Always pass `{ idempotencyKey }` on create/update operations. - **Dollar amounts instead of cents** — `amount: 10` creates a $0.10 charge, not $10.00. Always multiply by 100 or name variables `amountInCents`. **Medium Priority Issues:** - **Not pinning API version** — Without `apiVersion` in the constructor, Stripe uses your account's default version. API changes can silently break your integration. - **Using `payment_method_types` instead of `automatic_payment_methods`** — The legacy array approach requires manual updates as new payment methods become available. `automatic_payment_methods: { enabled: true }` is the modern approach. - **Swallowing Stripe errors** — Empty `catch` blocks hide payment failures. Always log the error's `requestId` for debugging with Stripe support. - **Not handling `requires_action` status** — Payment Intents may require 3D Secure authentication. Check `paymentIntent.status` after confirmation. - **Polling instead of webhooks** — Checking payment status in a loop is unreliable and wastes API calls. Use webhooks for all asynchronous payment events. **Common Mistakes:** - **Processing webhooks synchronously** — Long-running operations in the webhook handler cause timeouts. Return `200` immediately, then process asynchronously. - **Not handling duplicate webhook events** — Stripe may deliver the same event multiple times. Track processed event IDs to ensure idempotent handling. - **Using test keys in production** — Keys starting with `sk_test_` and `pk_test_` only work with test data. Verify your environment configuration. - **Forgetting `expand` for nested objects** — Stripe returns IDs by default for related objects. Use `expand: ["latest_invoice.payment_intent"]` to get full objects. **Gotchas & Edge Cases:** - **Stripe events are not ordered** — `invoice.paid` may arrive before `invoice.created`. Design handlers to be order-independent. - **Checkout Session `{CHECKOUT_SESSION_ID}` is a literal template** — Stripe replaces this placeholder in the `success_url`. Do not URL-encode it. - **Subscription proration is on by default** — Upgrading a plan mid-cycle prorates automatically. Pass `proration_behavior: "none"` to disable. - **Idempotency keys expire after 24 hours** — After expiry, the same key creates a new request. For long-lived retries, generate a new key. - **Zero-decimal currencies** — JPY, KRW, and others have no decimal subunit. `amount: 500` in JPY means 500 yen, not 5 yen. Check `Stripe.ZERO_DECIMAL_CURRENCIES`. - **Connect transfers require `transfers` capability** — Connected accounts must have `card_payments` and `transfers` capabilities enabled before receiving transfers. - **Webhook secrets differ per endpoint** — Each webhook endpoint has its own signing secret. Using the wrong secret causes all signature verifications to fail. </red_flags> --- <critical_reminders> ## CRITICAL REMINDERS > **All code must follow project conventions in CLAUDE.md** (kebab-case, named exports, import ordering, `import type`, named constants) **(You MUST NEVER expose `STRIPE_SECRET_KEY` in client-side code — it stays on the server only)** **(You MUST verify webhook signatures with `stripe.webhooks.constructEvent()` using the RAW request body — never parsed JSON)** **(You MUST use idempotency keys on all mutating (POST) requests to prevent duplicate charges)** **(You MUST handle all Stripe errors with `instanceof Stripe.errors.StripeError` — never swallow payment errors)** **(You MUST express monetary amounts in the smallest currency unit — cents for USD, not dollars)** **Failure to follow these rules will create security vulnerabilities, duplicate charges, and silent payment failures.** </critical_reminders>
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.