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:
- Environment variables: API keys and webhook signing secret, added to
.dev.vars/.env.production - 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
- Go to stripe.com and create an account
- Log in and navigate to Developers → API keys
- Copy the Secret key (test keys start with
sk_test_, live keys withsk_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:
STRIPE_SECRET_KEY=sk_test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxSTRIPE_SECRET_KEY=sk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxCreate 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
- Go to Products and click Add product
- Enter a product name, e.g.
Pro - 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
- After saving, click into each price's detail page and copy its Price ID (format:
price_xxxxxxx) - 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:
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:
| Field | Description |
|---|---|
providerPriceId | Stripe Price ID from the dashboard, format price_xxx |
amountCents | Price in cents — 1000 = $10.00 |
priceType | "subscription" for recurring, "lifetime" for one-time |
interval | Billing cycle: "month" or "year" (omit for lifetime) |
trialDays | Free 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):
-
Install the Stripe CLI:
# macOS brew install stripe/stripe-cli/stripe -
Log in to Stripe CLI:
stripe login -
Forward events to your local server (default port
3001):stripe listen --forward-to http://localhost:3001/api/webhooks/stripe -
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):
-
Go to Developers → Webhooks
-
Click Add endpoint
-
Set the Endpoint URL to your production server:
https://your-server.workers.dev/api/webhooks/stripe -
Under Events to send, select the following events (all handled by EasyStarter):
Event Description 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 -
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:
STRIPE_SECRET_KEY=sk_test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
STRIPE_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxStart the server:
pnpm dev:serverKeep the Stripe CLI forwarding running in a separate terminal to fully test the payment flow locally.
Test card number: Use
4242 4242 4242 4242with 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:
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:
- Go to Customer Portal Settings
- Enable the features you want — cancellations, payment method updates, invoice history, etc.
- Save the configuration
Pre-launch checklist
| Item | What to verify |
|---|---|
| API key | Switched to Live mode sk_live_ key |
| Webhook secret | Signing secret from the production webhook endpoint |
| Price IDs | Using Price IDs created in Live mode |
| Billing Portal | Configured and enabled in Stripe Dashboard |
| Push secrets | Deployed 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:
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:
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:
| Method | Description |
|---|---|
createCheckoutSession | Creates a checkout session and returns the redirect URL |
createPortalSession | Creates a subscription management portal session |
parseWebhookEvent | Verifies the webhook signature and parses the event |
setSubscriptionCancelAtPeriodEnd | Schedules a subscription to cancel at period end |
updateSubscriptionPrice | Updates 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:
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:
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:
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:
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.