LogoLaunchSaaS

Payment

Integrate Stripe, Creem, Polar, Dodo, or Lemon Squeezy payments via a split-package design. Handle one-time purchases, subscriptions, webhooks, and billing with a unified API.

LaunchSaaS provides payment support via a split-package design. The core @launchsaas/payment package defines the interfaces; each payment provider is a separate package you install only when needed.

Updated: 2026-03-26

Architecture

@launchsaas/payment              ← interfaces + webhook route helper (always needed)
@launchsaas/payment-stripe       ← Stripe provider
@launchsaas/payment-creem        ← Creem provider
@launchsaas/payment-polar        ← Polar provider
@launchsaas/payment-dodo         ← Dodo Payments provider
@launchsaas/payment-lemonsqueezy ← Lemon Squeezy provider

You only add the provider package you actually use.

LaunchSaaS ships with @launchsaas/payment-stripe as the default. In your own app, install whichever provider you prefer — or set payment: null in your src/capabilities.ts to leave payment disabled.

All payment providers follow the same responsibility split:

  • checkout.completed only creates order + entitlement for one-time payments
  • Subscription payment success events create the subscription order + activate entitlement
  • Other subscription.* events only sync entitlement status and dates

The payment system has three key concepts:

  • Order — Immutable record of a confirmed monetary transaction
  • Entitlement — Mutable record of user access to a product
  • PaymentEventHandler — App-layer interface for persistence and side effects

Setting Up Stripe

1. Install the provider package

In your app directory:

pnpm add @launchsaas/payment @launchsaas/payment-stripe

2. Add env variables

Add your provider's keys export to src/env.ts (or equivalent):

// src/env.ts
import { keys as paymentKeys } from "@launchsaas/payment-stripe/src/keys";

export const env = createEnv({
  extends: [
    // ... other keys
    paymentKeys,
  ],
  // ...
});

3. Register the provider in src/capabilities.ts

Open your app's src/capabilities.ts and pass your AppPaymentEventHandler to create():

// src/capabilities.ts  (apps/launchsaas/src/capabilities.ts)
import { StripePaymentProvider } from "@launchsaas/payment-stripe";
import { AppPaymentEventHandler } from "@/lib/features/payment/event-handler";

export const capabilities = {
  // set to null to disable payment
  payment: StripePaymentProvider.create(new AppPaymentEventHandler()),
  // ...other capabilities
};

Set payment: null to disable payment entirely.

4. Set up the webhook route

Create the API route file that receives payment webhooks:

// src/app/api/payment/[provider]/webhook/route.ts
import { toHandler } from "@launchsaas/payment";
import { capabilities } from "@/capabilities";

export const { POST } = toHandler(capabilities.payment);

This single file works for all payment providers. The [provider] segment is validated against capabilities.payment.name at runtime.

5. Create a Stripe account and get API keys

  1. Sign up at stripe.com and complete account verification
  2. Go to Stripe DashboardDevelopersAPI keys
  3. Copy your Secret key (starts with sk_test_ for test mode)
  4. Add to your .env file:
STRIPE_SECRET_KEY="sk_test_..."

Use test keys (sk_test_...) for development and live keys (sk_live_...) for production.

6. Create products and prices

  1. Go to Stripe DashboardProduct Catalog
  2. Click Add product
  3. Fill in product details:
    • Name: e.g., "Lifetime Access"
    • Description: Your product description
    • Price: Set your price (one-time or recurring)
  4. Click "Save product"
  5. Copy the Price ID (starts with price_)

7. Configure products

Update the product catalog in apps/launchsaas/src/configuration/product/products.ts:

// apps/launchsaas/src/configuration/product/products.ts
import type { Product } from "./types";

export const products: Product[] = [
  {
    id: "price_1SWtddRwnUQyjRPerb8ge9xD",
    name: "Lifetime Access",
    provider: "stripe",
    type: "onetime",
    sandbox: false,
    // Optional: collect extra fields from the buyer at checkout
    customFields: [
      { key: "githubUsername", label: "GitHub Username", type: "text" },
    ],
  },
  {
    id: "price_1SOwEE2Kn68A5jDtD9vcpirC",
    name: "Pro Monthly",
    provider: "stripe",
    type: "subscription",
    sandbox: false,
  },
];

customFields defines extra fields shown to the buyer during the Stripe Checkout session (e.g. a GitHub username). The collected values are passed through to your AppPaymentEventHandler via data.metadata.customFields.

Keep sandbox and live IDs in the same catalog by setting sandbox: true for test-mode products and sandbox: false for production products. The pricing page product is selected separately in apps/launchsaas/src/configuration/product/index.ts.

8. Set up webhooks

Webhooks notify your application of payment events in real-time.

Development (Local Testing)

Use the Stripe CLI to forward webhooks to your local server:

  1. Install Stripe CLI:
# macOS
brew install stripe/stripe-cli/stripe

# Other platforms: https://stripe.com/docs/stripe-cli
  1. Login to Stripe:
stripe login
  1. Forward webhooks:
stripe listen --forward-to localhost:3000/api/payment/stripe/webhook
  1. Copy the webhook secret (starts with whsec_) and add to your .env file:
STRIPE_WEBHOOK_SECRET="whsec_..."

Keep the stripe listen command running while developing.

Production

  1. Go to Stripe DashboardDevelopersWebhooks
  2. Click Add endpoint
  3. Set endpoint URL: https://yourdomain.com/api/payment/stripe/webhook
  4. Select events to listen to:
    • checkout.session.completed
    • invoice.paid
    • customer.subscription.created
    • customer.subscription.updated
    • customer.subscription.deleted
  5. Click Add endpoint
  6. Copy the Signing secret and add to your production environment

Required Events

Configure your Stripe webhook endpoint to listen to exactly these events:

  • checkout.session.completed
  • invoice.paid
  • customer.subscription.created
  • customer.subscription.updated
  • customer.subscription.deleted

Stripe Webhook Flow

LaunchSaaS uses a split event model with three independent responsibility lanes, so event delivery order does not matter.

One-Time Payment Flow

Subscription Flow

Event Responsibilities

EventAction
checkout.session.completed (mode=payment)Create order + active entitlement (one-time)
checkout.session.completed (mode=subscription)Ignored — subscriptions handled by invoice.paid
invoice.paidCreate order + active entitlement (duplicate entitlement inserts ignored)
customer.subscription.created (trialing)Create entitlement with status trialing
customer.subscription.created/updatedUpdate entitlement: period, status, cancellation (no-op if not yet created)
customer.subscription.deletedUpdate entitlement status to canceled

Why the flow is split this way

  • checkout.session.completed with mode=payment is the unambiguous one-time purchase signal.
  • invoice.paid is the authoritative payment success signal for subscriptions — it creates the active entitlement when missing, and duplicate create events are ignored.
  • customer.subscription.* handles two cases: for trialing subscriptions it creates the entitlement (since no invoice.paid fires during a trial); for all other statuses it only updates existing records and never upgrades to active, so it cannot cause incorrect access grants even if it arrives before invoice.paid.

This keeps entitlement updates correct even when Stripe delivers events out of order.

Idempotency

RecordKey
Order (one-time)stripe_${payment_intent.id}
Order (subscription)stripe_${invoice.id}
Entitlement (one-time)stripe_pi_${payment_intent.id}
Entitlement (subscription)stripe_${subscription.id}

Replayed events are safe — duplicate inserts are silently ignored.

Testing Cards

For testing Stripe integration, use test credit cards:

Card NumberDescription
4242 4242 4242 4242Successful payment
4000 0000 0000 32203D Secure required
4000 0000 0000 9995Insufficient funds

Use any future expiration date and any 3-digit CVC.

Setting Up Creem

1. Install the provider package

In your app directory:

pnpm add @launchsaas/payment @launchsaas/payment-creem

2. Register the provider in src/capabilities.ts

// src/capabilities.ts  (apps/launchsaas/src/capabilities.ts)
import { CreemPaymentProvider } from "@launchsaas/payment-creem";
import { AppPaymentEventHandler } from "@/lib/features/payment/event-handler";

export const capabilities = {
  // set to null to disable payment
  payment: CreemPaymentProvider.create(new AppPaymentEventHandler()),
  // ...other capabilities
};

3. Create a Creem account and get API keys

  1. Sign up at creem.io and complete account verification
  2. Go to Creem Dashboard → Settings → API Keys
  3. Copy your API key and webhook secret
  4. Add to your .env file:
CREEM_API_KEY="your-api-key"
CREEM_WEBHOOK_SECRET="your-webhook-secret"

4. Configure products

Update the product catalog in apps/launchsaas/src/configuration/product/products.ts:

// apps/launchsaas/src/configuration/product/products.ts
import type { Product } from "./types";

export const products: Product[] = [
  {
    id: "prod_3h218lKmpAIM9xTv97Mdy7",
    name: "Lifetime Access",
    provider: "creem",
    type: "onetime",
    sandbox: false,
    customFields: [
      { key: "githubUsername", label: "GitHub Username", type: "text" },
    ],
  },
];

Creem checkout custom fields only support type: "text" and type: "checkbox". Other field types are rejected when the checkout session is created.

5. Set up webhooks

Configure webhook endpoint in Creem Dashboard:

  • URL: https://yourdomain.com/api/payment/creem/webhook
  • Events: All payment-related events

If you are setting up the environment, now you can go back to the Environment Setup guide and continue.

Setting Up Polar

1. Install the provider package

pnpm add @launchsaas/payment @launchsaas/payment-polar

2. Register the provider in src/capabilities.ts

import { PolarPaymentProvider } from "@launchsaas/payment-polar";
import { AppPaymentEventHandler } from "@/lib/features/payment/event-handler";

export const capabilities = {
  payment: PolarPaymentProvider.create(new AppPaymentEventHandler()),
  // ...other capabilities
};

3. Create a Polar account and get API keys

  1. Sign up at polar.sh and create an organization
  2. Go to SettingsDevelopersPersonal Access TokensNew token
  3. Copy the access token and add to your .env file:
POLAR_ACCESS_TOKEN="your-access-token"
POLAR_WEBHOOK_SECRET="your-webhook-secret"
# "sandbox" (default) or "production"
POLAR_MODE="sandbox"

4. Configure products

Polar uses Product Price IDs as the product identifier. Copy the price ID from your Polar product page:

export const products: Product[] = [
  {
    id: "price_abc123", // Polar product price ID
    name: "Lifetime Access",
    provider: "polar",
    type: "onetime",
    sandbox: false,
  },
];

Polar does not support LaunchSaaS dynamic checkout form fields.

LaunchSaaS can still read submitted Polar custom field values from webhook payloads and expose them as data.metadata.customFields in your payment event handler.

5. Set up webhooks

Configure the webhook endpoint in your Polar organization settings:

  • URL: https://yourdomain.com/api/payment/polar/webhook
  • Events: order.paid, subscription.created, subscription.updated, subscription.canceled, subscription.revoked

Setting Up Dodo Payments

1. Install the provider package

pnpm add @launchsaas/payment @launchsaas/payment-dodo

2. Register the provider in src/capabilities.ts

import { DodoPaymentProvider } from "@launchsaas/payment-dodo";
import { AppPaymentEventHandler } from "@/lib/features/payment/event-handler";

export const capabilities = {
  payment: DodoPaymentProvider.create(new AppPaymentEventHandler()),
  // ...other capabilities
};

3. Create a Dodo Payments account and get API keys

  1. Sign up at dodopayments.com and complete verification
  2. Go to DevelopersAPI Keys
  3. Copy your API key and webhook key and add to your .env file:
DODO_PAYMENTS_API_KEY="your-api-key"
DODO_PAYMENTS_WEBHOOK_KEY="your-webhook-key"
# "test_mode" (default) or "live_mode"
DODO_PAYMENTS_ENVIRONMENT="test_mode"

4. Configure products

Dodo uses Product IDs from your Dodo product catalog:

export const products: Product[] = [
  {
    id: "prod_abc123", // Dodo product ID
    name: "Lifetime Access",
    provider: "dodo",
    type: "onetime",
    sandbox: false,
  },
];

Dodo forwards LaunchSaaS customFields to the Dodo API. You can keep using the built-in options convenience for dropdown-like fields.

5. Set up webhooks

Configure the webhook endpoint in your Dodo Dashboard:

  • URL: https://yourdomain.com/api/payment/dodo/webhook
  • Events: payment.succeeded, subscription.active, subscription.renewed, subscription.updated, subscription.on_hold, subscription.cancelled, subscription.expired

Dodo has a built-in customer portal. The billing portal URL is retrieved via the Dodo Payments API automatically when createBillingPortal() is called.


Setting Up Lemon Squeezy

1. Install the provider package

pnpm add @launchsaas/payment @launchsaas/payment-lemonsqueezy

2. Register the provider in src/capabilities.ts

import { LemonSqueezyPaymentProvider } from "@launchsaas/payment-lemonsqueezy";
import { AppPaymentEventHandler } from "@/lib/features/payment/event-handler";

export const capabilities = {
  payment: LemonSqueezyPaymentProvider.create(new AppPaymentEventHandler()),
  // ...other capabilities
};

3. Create a Lemon Squeezy account and get API keys

  1. Sign up at lemonsqueezy.com and create a store
  2. Go to SettingsAPINew API Key
  3. Copy the API key and your Store ID from the store settings:
LEMONSQUEEZY_API_KEY="your-api-key"
LEMONSQUEEZY_WEBHOOK_SECRET="your-webhook-secret"
LEMONSQUEEZY_STORE_ID="your-store-id"

4. Configure products

Lemon Squeezy uses Variant IDs as the product identifier:

export const products: Product[] = [
  {
    id: "123456", // Lemon Squeezy variant ID (number as string)
    name: "Lifetime Access",
    provider: "lemonsqueezy",
    type: "onetime",
    sandbox: false,
  },
];

Lemon Squeezy does not support LaunchSaaS dynamic checkout form fields.

5. Set up webhooks

Configure the webhook endpoint in your Lemon Squeezy store settings:

  • URL: https://yourdomain.com/api/payment/lemonsqueezy/webhook
  • Events: order_created, subscription_created, subscription_updated, subscription_cancelled, subscription_resumed, subscription_expired, subscription_payment_success

The PaymentEventHandler interface is the primary extension point for post-payment logic. Providers call its three methods during webhook processing:

export interface PaymentEventHandler {
  onOrderCreated(data: OrderData): Promise<void>;
  onEntitlementCreated(data: EntitlementData): Promise<void>;
  onEntitlementUpdated(data: EntitlementUpdateData): Promise<void>;
}

LaunchSaaS ships with AppPaymentEventHandler at src/lib/features/payment/event-handler.ts. It handles:

  • onOrderCreated — saves the order to the DB and sends a payment confirmation email
  • onEntitlementCreated — inserts the entitlement if it does not already exist, then adds the buyer as a GitHub collaborator (when customFields.githubUsername is present in metadata)
  • onEntitlementUpdated — syncs subscription status/period changes to the DB

Customizing side effects

Edit AppPaymentEventHandler directly to add, remove, or change side effects:

// src/lib/features/payment/event-handler.ts
export class AppPaymentEventHandler implements PaymentEventHandler {
  async onOrderCreated(data: OrderData): Promise<void> {
    await createOrder({
      ...data,
      provider: data.provider as Order["provider"],
    });

    // Add or replace side effects here
    try {
      await sendPaymentConfirmationEmail(data.referenceId);
    } catch (error) {
      console.error(
        "[PaymentEventHandler] Failed to send payment email:",
        error,
      );
    }
  }

  async onEntitlementCreated(data: EntitlementData): Promise<void> {
    await createEntitlement({
      ...data,
      provider: data.provider as Entitlement["provider"],
      status: data.status as Entitlement["status"],
    });

    // Custom fields collected at checkout are in data.metadata.customFields
    const customFields = data.metadata?.customFields as
      | Record<string, string>
      | undefined;
    try {
      await addGitHubCollaborator(customFields?.githubUsername);
    } catch (error) {
      console.error(
        "[PaymentEventHandler] Failed to add GitHub collaborator:",
        error,
      );
    }
  }

  async onEntitlementUpdated(data: EntitlementUpdateData): Promise<void> {
    const { id, ...rest } = data;
    await updateEntitlement(id, {
      ...rest,
      status: data.status as Entitlement["status"] | undefined,
    });
  }
}

Built-in side effects

GitHub Integration

Automatically adds customers as GitHub collaborators after payment. Triggered when the buyer fills in githubUsername at checkout (requires the product to have customFields configured — see Product Custom Fields).

Configure environment variables:

GITHUB_TOKEN="your-personal-access-token"
GITHUB_REPO="owner/repo"

Generate a GitHub Personal Access Token with admin:org and repo scopes at GitHub Settings.

Payment Confirmation Email

Sends a confirmation email after successful payments. Requires the email provider to be configured in capabilities.ts.

Product Custom Fields

Products can define customFields to collect extra information from the buyer during checkout. LaunchSaaS does not centrally restrict the type string. When a provider supports checkout fields, collected values are forwarded to your AppPaymentEventHandler via data.metadata.customFields.

{
  id: "price_xxx",
  name: "Lifetime Access",
  customFields: [
    { key: "githubUsername", label: "GitHub Username", type: "text" },
    { key: "plan", label: "Plan", type: "dropdown", options: ["starter", "pro"] },
    { key: "agreeToTerms", label: "Terms", type: "checkbox" },
  ],
}

Field definition:

PropertyTypeDescription
keystringKey used in metadata.customFields
labelstringLabel shown to buyer at checkout
typestringProvider-defined field type
optionalbooleanWhether the field is optional (default: true)
optionsstring[]Convenience helper for dropdown-like providers

Provider notes:

ProviderNotes
StripeStripe Checkout custom fields only support text, numeric, and dropdown. dropdown requires options.
CreemCreem checkout custom fields only support text and checkbox.
Dodo PaymentsDodo custom fields only support text, number, email, url, date, dropdown, and boolean. dropdown requires options.
PolarDoes not support LaunchSaaS dynamic checkout form fields. LaunchSaaS only reads returned custom_field_data from webhook payloads.
Lemon SqueezyDoes not support LaunchSaaS dynamic checkout form fields.

Database Schema

Order Table

Represents confirmed monetary transactions:

  • Created when payment is confirmed
  • One-time products create an order upon checkout completion
  • Subscription products create an order upon each successful payment

Entitlement Table

Represents user access to products:

  • Granted/extended when order is paid
  • One-time products create entitlement with status active
  • Subscription products create entitlement with status trialing (on trial) or active (after first payment)
  • Contains period information for subscriptions

Custom Payment Provider

To add a custom payment provider:

  1. Create a new package (e.g., packages/capabilities/payment-custom/)
  2. Implement the PaymentProvider interface
  3. Register it in your app's src/capabilities.ts
// packages/capabilities/payment-custom/src/provider.ts
import type {
  BillingPortalResult,
  CheckoutOptions,
  CheckoutResult,
  PaymentEventHandler,
  PaymentProvider,
  UpgradeSubscriptionOptions,
} from "@launchsaas/payment";

export class CustomPaymentProvider implements PaymentProvider {
  readonly name = "custom";

  constructor(private readonly handler: PaymentEventHandler) {}

  static create(handler: PaymentEventHandler): CustomPaymentProvider {
    return new CustomPaymentProvider(handler);
  }

  getSignatureHeader(): string {
    return "custom-signature";
  }

  async verifySignature(
    rawBody: string | Buffer,
    signature: string,
  ): Promise<boolean> {
    // Verify webhook signature
    return true;
  }

  async handleEvent(
    rawBody: string | Buffer,
    signature: string,
  ): Promise<void> {
    // Verify, parse, and dispatch to:
    //   this.handler.onOrderCreated(...)
    //   this.handler.onEntitlementCreated(...)
    //   this.handler.onEntitlementUpdated(...)
  }

  async createCheckout(options: CheckoutOptions): Promise<CheckoutResult> {
    return { url: "", sessionId: "" };
  }

  async createBillingPortal(
    customerId: string,
    returnUrl: string,
  ): Promise<BillingPortalResult> {
    return { url: "" };
  }

  async upgradeSubscription(
    options: UpgradeSubscriptionOptions,
  ): Promise<void> {}
  async cancelSubscription(subscriptionId: string): Promise<void> {}
}

Register it in your app's src/capabilities.ts:

import { CustomPaymentProvider } from "@launchsaas/payment-custom";
import { AppPaymentEventHandler } from "@/lib/features/payment/event-handler";

export const capabilities = {
  payment: CustomPaymentProvider.create(new AppPaymentEventHandler()),
  // ...other capabilities
};

References

Next Steps