Claude Skill

api-commerce-stripe

Stripe payment processing — Checkout Sessions, Payment Intents, subscriptions, webhooks, Connect, customer management, error handling

LLM Mart · 0 points · 0 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download agents-inc-skills-dist_plugins_api-commerce-stripe_skills_api-commerce-stripe-3a51ef5.zip · 19 KB
Part of agents-inc/skills — 130 skills

Install

skills CLI npx skills add https://github.com/agents-inc/skills/tree/main/dist/plugins/api-commerce-stripe/skills/api-commerce-stripe
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install agents-inc-skills@llmmart
Git 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 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

Core Setup & Payments:

  • examples/core.md — Client setup, Checkout Sessions, Payment Intents, error handling

Webhooks & Events:

Subscriptions & Billing:

Connect & Platforms:




<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>

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.

No comments yet.

Reviews (0)

No reviews yet.

Related