EasyStarter logoEasyStarter

Stripe Payments

Configure Stripe Checkout, Billing Portal, and Webhooks

Stripe Payment Integration

EasyStarter's web app ships with a fully integrated Stripe payment system, including:

  • Subscription checkout (monthly / yearly)
  • One-time lifetime purchase checkout
  • Free trial support
  • Stripe Billing Portal (self-serve subscription management, cancellations, payment method updates, invoices)
  • In-app plan upgrades (monthly → yearly, without redirecting to Checkout)
  • Webhook event handling (subscription sync, invoices, refunds, disputes, and more)

Payment setup is split into two parts:

  1. Environment variables: API keys and webhook signing secret, added to .dev.vars / .env.production
  2. Pricing plans: Stripe Price IDs and pricing metadata configured in packages/app-config/src/app-config.ts

Required Environment Variables

STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET=

Register on Stripe and get your API key

Official docs: Stripe API Keys

  1. Go to stripe.com and create an account
  2. Log in and navigate to Developers → API keys
  3. Copy the Secret key (test keys start with sk_test_, live keys with sk_live_)

Start with the test key during development. Switch to Live mode in the same page before going to production.

Fill in the copied key:

apps/server/.dev.vars
STRIPE_SECRET_KEY=sk_test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
apps/server/.env.production
STRIPE_SECRET_KEY=sk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

Create products and prices in Stripe

EasyStarter defaults to three plans: Free, Pro (monthly + yearly), and Lifetime (one-time purchase).

The Free plan requires no Stripe product. Follow these steps to create Pro and Lifetime:

Official docs: Stripe Products & Prices

  1. Go to Products and click Add product
  2. Enter a product name, e.g. Pro
  3. In the Pricing section:
    • Choose Recurring (subscription), set your price and billing period (Monthly or Yearly)
    • Click More price options to add multiple prices for the same product
  4. After saving, click into each price's detail page and copy its Price ID (format: price_xxxxxxx)
  5. Repeat for Lifetime, choosing One time as the pricing type

Each price gets a unique Price ID, which you'll use in the next step.

Configure pricing plans

Add the Price IDs from the previous step to the web.payments.plans field in packages/app-config/src/app-config.ts:

packages/app-config/src/app-config.ts
web: {
  payments: {
    provider: "stripe",
    plans: [
      {
        id: "free",   // Free plan — no Price ID required
      },
      {
        id: "pro",
        prices: [
          {
            id: "monthly",
            provider: "stripe",
            providerPriceId: "price_xxxxxxxxxxxxxxxx",  // Stripe monthly Price ID
            currency: "usd",
            amountCents: 1000,    // $10.00
            priceType: "subscription",
            interval: "month",
            trialDays: 7,         // Free trial days — remove if not needed
            status: "active",
          },
          {
            id: "yearly",
            provider: "stripe",
            providerPriceId: "price_xxxxxxxxxxxxxxxx",  // Stripe yearly Price ID
            currency: "usd",
            amountCents: 10000,   // $100.00
            priceType: "subscription",
            interval: "year",
            trialDays: 7,
            status: "active",
          },
        ],
      },
      {
        id: "lifetime",
        prices: [
          {
            id: "lifetime",
            provider: "stripe",
            providerPriceId: "price_xxxxxxxxxxxxxxxx",  // Stripe one-time Price ID
            currency: "usd",
            amountCents: 20000,   // $200.00
            priceType: "lifetime",
            status: "active",
          },
        ],
      },
    ],
  },
},

Field reference:

FieldDescription
providerPriceIdStripe Price ID from the dashboard, format price_xxx
amountCentsPrice in cents — 1000 = $10.00
priceType"subscription" for recurring, "lifetime" for one-time
intervalBilling cycle: "month" or "year" (omit for lifetime)
trialDaysFree trial length in days — remove the field to disable trials
status"active" to show / "archived" to hide from the pricing page

Configure Stripe Webhooks

Webhooks are how Stripe notifies your server about events like successful payments, subscription changes, and invoices. They are essential for keeping your database in sync.

Local development (using Stripe CLI):

  1. Install the Stripe CLI:

    # macOS
    brew install stripe/stripe-cli/stripe
  2. Log in to Stripe CLI:

    stripe login
  3. Forward events to your local server (default port 3001):

    stripe listen --forward-to http://localhost:3001/api/webhooks/stripe
  4. The CLI will output a Webhook signing secret starting with whsec_. Copy it into:

    apps/server/.dev.vars
    STRIPE_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

Production (Stripe Dashboard):

  1. Go to Developers → Webhooks

  2. Click Add endpoint

  3. Set the Endpoint URL to your production server: https://your-server.workers.dev/api/webhooks/stripe

  4. Under Events to send, select the following events (all handled by EasyStarter):

    EventDescription
    checkout.session.completedCheckout completed
    checkout.session.async_payment_succeededAsync payment succeeded
    checkout.session.async_payment_failedAsync payment failed
    checkout.session.expiredCheckout session expired
    customer.subscription.createdSubscription created
    customer.subscription.updatedSubscription updated
    customer.subscription.deletedSubscription cancelled
    payment_intent.succeededPayment intent succeeded
    payment_intent.payment_failedPayment intent failed
    payment_intent.canceledPayment intent cancelled
    invoice.paidInvoice paid
    invoice.payment_failedInvoice payment failed
    invoice.marked_uncollectibleInvoice marked uncollectible
    invoice.voidedInvoice voided
    charge.dispute.createdDispute opened
    charge.dispute.updatedDispute updated
    charge.dispute.closedDispute closed
    charge.refundedCharge refunded
  5. After saving, click into the endpoint detail and copy the Signing secret (whsec_xxx) into:

    apps/server/.env.production
    STRIPE_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

Set environment variables and start the server

Confirm both variables are set in your dev environment:

apps/server/.dev.vars
STRIPE_SECRET_KEY=sk_test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
STRIPE_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

Start the server:

pnpm dev:server

Keep the Stripe CLI forwarding running in a separate terminal to fully test the payment flow locally.

Test card number: Use 4242 4242 4242 4242 with any future expiry date and any 3-digit CVV in Stripe test mode. More test cards at Stripe Testing Docs.

Billing route configuration

The redirect URLs after a successful or cancelled payment are configured in web.routes inside packages/app-config/src/app-config.ts:

packages/app-config/src/app-config.ts
web: {
  routes: {
    billingSuccess: "/billing/success",  // Redirect after successful payment
    billingCancel: "/billing/cancel",    // Redirect after cancelled payment
    billingReturn: "/settings/billing",  // Redirect after leaving Billing Portal
  },
},

Billing Portal (self-serve subscription management)

EasyStarter includes Stripe Billing Portal integration out of the box. Users can manage their subscriptions from /settings/billing, where the app creates a portal session and redirects them to Stripe's hosted management UI.

Before using it in production, configure the portal in your Stripe dashboard:

  1. Go to Customer Portal Settings
  2. Enable the features you want — cancellations, payment method updates, invoice history, etc.
  3. Save the configuration

Pre-launch checklist

ItemWhat to verify
API keySwitched to Live mode sk_live_ key
Webhook secretSigning secret from the production webhook endpoint
Price IDsUsing Price IDs created in Live mode
Billing PortalConfigured and enabled in Stripe Dashboard
Push secretsDeployed to Cloudflare Workers via pnpm run secrets:bulk:production

Adding a custom web payment provider

EasyStarter's payment layer is built around a PaymentProvider interface with Stripe as the default implementation. You can swap in any other provider (e.g. Paddle, LemonSqueezy) in six steps without touching any business logic.

Step 1: Register the provider key

Add the new provider key to SUPPORTED_WEB_PAYMENT_PROVIDERS in packages/app-config/src/types.ts:

packages/app-config/src/types.ts
export const SUPPORTED_WEB_PAYMENT_PROVIDERS = ["stripe", "paddle"] as const;

This automatically updates the WebPaymentProviderKey and ServerPaymentProviderKey union types everywhere.

Step 2: Implement the PaymentProvider interface

Create a new directory under apps/server/src/payments/providers/ and implement the PaymentProvider interface:

apps/server/src/payments/providers/paddle/provider.ts
import type {
  CreateCheckoutInput,
  CreatePortalInput,
  ParsedWebhookEvent,
  PaymentProvider,
  WebhookInput,
} from "../../public/types";

export function createPaddlePaymentProvider(): PaymentProvider {
  return {
    key: "paddle",

    async createCheckoutSession(input: CreateCheckoutInput) {
      // Call Paddle SDK to create a checkout session
      // Return { providerSessionId, url, expiresAt }
    },

    async createPortalSession(input: CreatePortalInput) {
      // Return the Paddle subscription management URL
      // Return { providerSessionId, url }
    },

    async parseWebhookEvent(input: WebhookInput): Promise<ParsedWebhookEvent> {
      // Verify signature and parse payload
      // Return { providerEventId, type, createdAt, payload }
    },

    async setSubscriptionCancelAtPeriodEnd(input) {
      // Call Paddle API to set cancel-at-period-end
    },

    async updateSubscriptionPrice(input) {
      // Call Paddle API to update the subscription price
    },
  };
}

Interface method reference:

MethodDescription
createCheckoutSessionCreates a checkout session and returns the redirect URL
createPortalSessionCreates a subscription management portal session
parseWebhookEventVerifies the webhook signature and parses the event
setSubscriptionCancelAtPeriodEndSchedules a subscription to cancel at period end
updateSubscriptionPriceUpdates the subscription price (used for in-app upgrades)

Step 3: Implement the webhook event handler

Create event handling logic under apps/server/src/payments/providers/paddle/webhook/ and map provider events to database operations:

apps/server/src/payments/providers/paddle/webhook/handle-event.ts
import type { Database } from "@/db";

export async function handlePaddleEvent(db: Database, payload: unknown) {
  const event = payload as { event_type: string; data: unknown };

  switch (event.event_type) {
    case "subscription.created":
    case "subscription.updated":
    case "subscription.canceled": {
      // Sync subscription state into the billing_subscription table
      break;
    }
    case "transaction.completed": {
      // Handle one-time purchases and write to billing_purchase table
      break;
    }
    // Handle other events as needed...
    default:
      break;
  }
}

See apps/server/src/payments/providers/stripe/webhook/ for how to split complex event types across multiple files.

Step 4: Register the provider in the factory

Add the new provider to the factory map in apps/server/src/payments/providers/index.ts:

apps/server/src/payments/providers/index.ts
import { createPaddlePaymentProvider } from "./paddle/provider";

const providers: Record<ServerPaymentProviderKey, () => PaymentProvider> = {
  stripe: createStripePaymentProvider,
  paddle: createPaddlePaymentProvider,  // add this
};

Step 5: Add a webhook route

Register a dedicated webhook route for the new provider in apps/server/src/index.ts:

apps/server/src/index.ts
app.post("/api/webhooks/paddle", async (c) => {
  const context = await createContext({ context: c });
  const rawBody = await c.req.text();
  const signature = c.req.header("paddle-signature");

  await context.payments.handleWebhookEvent({
    provider: "paddle",
    rawBody,
    signature,
  });

  return c.json({ received: true });
});

The handleWebhookEvent service method automatically routes events to the correct handlePaddleEvent handler.

Step 6: Configure pricing plans and switch the provider

Update web.payments.provider and fill in the new provider's Price IDs in packages/app-config/src/app-config.ts:

packages/app-config/src/app-config.ts
web: {
  payments: {
    provider: "paddle",   // switch to the new provider
    plans: [
      {
        id: "pro",
        prices: [
          {
            id: "monthly",
            provider: "paddle",
            providerPriceId: "pri_xxxxxxxxxxxxxxxx",  // Paddle Price ID
            currency: "usd",
            amountCents: 1000,
            priceType: "subscription",
            interval: "month",
            status: "active",
          },
        ],
      },
    ],
  },
},

Once complete, all checkout sessions, upgrades, and portal redirects will go through the new provider automatically — no changes to business logic needed.