EasyStarter logoEasyStarter
Payments

Creem Payments

Configure Creem Checkout, Billing Portal, and Webhooks for web payments

Creem Payment Integration

EasyStarter's web app ships with a fully integrated Creem payment system for web-only scenarios, including:

  • Subscription checkout (monthly / yearly)
  • One-time lifetime purchase checkout
  • Free trial support
  • Customer Billing Portal (self-serve subscription management)
  • In-app subscription upgrades (with proration)
  • Webhook event handling (subscription sync, refunds, disputes, and more)

Payment setup is split into two parts:

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

Required Environment Variables

CREEM_API_KEY=
CREEM_WEBHOOK_SECRET=

Register on Creem and get your API key

  1. Go to creem.io and create an account
  2. Log in and navigate to API Keys in your dashboard
  3. Copy the API key (test keys start with creem_test_, live keys with creem_live_)

Start with the test key during development. Switch to the live key before going to production.

Fill in the copied key:

apps/server/.dev.vars
CREEM_API_KEY=creem_test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
apps/server/.env.production
CREEM_API_KEY=creem_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

EasyStarter automatically routes API requests based on the key prefix:

  • creem_test_https://test-api.creem.io/v1
  • creem_live_https://api.creem.io/v1

Create products in Creem

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

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

  1. Go to Products in your Creem dashboard and click Create product
  2. Enter a product name, e.g. Pro Monthly
  3. Subscription plans (monthly / yearly): set the billing type to Recurring — create one product for monthly and one for yearly, each with its own billing cycle
  4. Lifetime plan: set the billing type to One time — create one product
  5. After saving each product, copy its Product ID (format: prod_xxxxxxx) — you'll use these in the next step

Configure pricing plans

Add the Product 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: "creem",
    // test/prod hold environment-specific Creem Product IDs.
    // Use test product IDs with creem_test_, and live product IDs with creem_live_.
    plans: [
      {
        id: "free",   // Free plan — no Product ID required
      },
      {
        id: "pro",
        prices: [
          {
            id: "monthly",
            provider: "creem",
            test: {
              providerPriceId: "prod_xxxxxxxxxxxxxxxx",  // sandbox test Creem monthly Product ID
            },
            prod: {
              providerPriceId: "prod_xxxxxxxxxxxxxxxx",  // production Creem monthly Product ID
            },
            currency: "usd",
            amountCents: 1000,    // $10.00
            priceType: "subscription",
            interval: "month",
            trialDays: 7,         // Must match the trial days set on the Creem product
            status: "active",
          },
          {
            id: "yearly",
            provider: "creem",
            test: {
              providerPriceId: "prod_xxxxxxxxxxxxxxxx",  // sandbox test Creem yearly Product ID
            },
            prod: {
              providerPriceId: "prod_xxxxxxxxxxxxxxxx",  // production Creem yearly Product ID
            },
            currency: "usd",
            amountCents: 10000,   // $100.00
            priceType: "subscription",
            interval: "year",
            trialDays: 7,         // Must match the trial days set on the Creem product
            status: "active",
          },
        ],
      },
      {
        id: "lifetime",
        prices: [
          {
            id: "lifetime",
            provider: "creem",
            test: {
              providerPriceId: "prod_xxxxxxxxxxxxxxxx",  // sandbox test Creem one-time Product ID
            },
            prod: {
              providerPriceId: "prod_xxxxxxxxxxxxxxxx",  // production Creem one-time Product ID
            },
            currency: "usd",
            amountCents: 20000,   // $200.00
            priceType: "lifetime",
            status: "active",
          },
        ],
      },
    ],
  },
},

Field reference:

FieldDescription
test.providerPriceIdCreem Product ID from the test environment, format prod_xxx
prod.providerPriceIdCreem Product ID from the production environment, format prod_xxx
amountCentsPrice in cents — 1000 = $10.00
priceType"subscription" for recurring, "lifetime" for one-time
intervalBilling cycle: "month" or "year" (omit for lifetime)
trialDaysMust match the trial days configured on the Creem product — Creem does not support setting trial periods via API, so this value is display-only and must stay in sync with the product setting in the Creem dashboard
status"active" to show / "archived" to hide from the pricing page

Configure Creem Webhooks

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

  1. In your Creem dashboard, go to Webhooks

  2. Click Add endpoint

  3. Set the Endpoint URL:

    • Local development: https://your-ngrok-url/api/webhooks/creem (use ngrok or similar tunnel)
    • Production: https://your-server.workers.dev/api/webhooks/creem
  4. Under Events to send, select the following events (all handled by EasyStarter):

    EventDescription
    checkout.completedCheckout completed (subscription or one-time)
    subscription.activeSubscription activated
    subscription.trialingTrial started
    subscription.paidRecurring payment processed
    subscription.scheduled_cancelScheduled for cancellation at period end
    subscription.past_duePayment overdue
    subscription.updateSubscription modified
    subscription.expiredSubscription period ended
    subscription.canceledSubscription canceled
    subscription.pausedSubscription paused
    refund.createdRefund processed
    dispute.createdChargeback / dispute opened
  5. After saving, copy the Webhook Secret and fill it in:

apps/server/.dev.vars
CREEM_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
apps/server/.env.production
CREEM_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

Creem uses HMAC-SHA256 to sign webhook payloads. EasyStarter verifies the signature from the creem-signature header automatically.

Set environment variables and start the server

Confirm both variables are set in your dev environment:

apps/server/.dev.vars
CREEM_API_KEY=creem_test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
CREEM_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

Start the server:

pnpm dev:server

Since Creem does not provide a CLI tool like Stripe CLI, you'll need a tunnel service (e.g. ngrok) to test webhooks locally:

ngrok http 3001

Then set the ngrok URL as your webhook endpoint in the Creem dashboard.

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

Customer Billing Portal

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

Subscription status mapping

Creem has more granular subscription states than Stripe. Here's how they map to EasyStarter's internal statuses:

Creem StateInternal StatusHas AccessNotes
activeactiveYesNormal active subscription
trialingtrialingYesFree trial period
scheduled_cancelactive (cancelAtPeriodEnd)YesAccess continues until period end
pausedpausedNoSubscription is paused
past_dueunpaidNoPayment overdue
unpaidunpaidNoPayment failed
incompleteunpaidNoSetup incomplete
failedunpaidNoPayment failed
canceledcanceledNoSubscription ended
expiredcanceledNoSubscription period expired

Adding a custom web payment provider

EasyStarter's payment layer is built around a PaymentProvider interface with Stripe and Creem as built-in implementations. 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", "creem", "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,
  creem: createCreemPaymentProvider,
  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.

Pre-launch checklist

ItemWhat to verify
API keySwitched to live key creem_live_
Webhook secretSecret from the production webhook endpoint
Product IDsUsing Product IDs created in live mode
Webhook endpointProduction URL configured in Creem dashboard
Push secretsDeployed to Cloudflare Workers via pnpm run secrets:bulk:production