Payments

Stripe and Creem are both wired in. How checkout, webhooks, plan changes and refunds work, how to pick a provider, and how to test locally.

ShipKit sells two kinds of things: subscriptions (a plan, billed monthly or yearly) and one-time purchases (credit packs). Both run through the same pipeline, and both providers are already implemented, so the provider is a setting rather than a rewrite.

How a purchase flows

  1. A signed-in user clicks a buy button. A server function (createSubscriptionCheckoutFn in src/features/billing/server/fns.ts, createCreditCheckoutFn in src/features/credits/server/fns.ts) asks the active provider for a hosted checkout page, with the user id and what was bought (kind, planId or packId) in the checkout metadata.
  2. The buyer pays on the provider's page and is sent back to /billing or /credits, in the language they left in.
  3. The provider posts a webhook to /api/webhooks/stripe or /api/webhooks/creem. The route does three things only: verify the signature, dedupe on the event id (a unique row in webhook_events), and translate the payload into provider-neutral PaymentEvents.
  4. Each feature that cares about money handles those events: billing keeps subscriptions and orders in sync and sends receipts, credits grants the balance, affiliate records commissions, audit and analytics log it.

Step 2 also settles the purchase on its own. The return URL carries the checkout id, and the account dialog calls reconcileCheckoutFn (src/core/payment/fns.ts), which asks the provider what happened, runs the answer through the same translator and dispatches the same events. Every handler keys on the external order or subscription id, so whichever of the webhook or the return gets there first does the work and the other is a no-op. A webhook that never arrives becomes a delay, not a lost payment.

A user who already has a live subscription never gets a second checkout: the buy button sends them to /billing, where the change is a plan switch with a quote.

Stripe or Creem

StripeCreem
RolePayment processor; you are the merchant of recordMerchant of record; they sell, you get paid
Sales tax and VATYours to handle, or enable Stripe TaxCollected and remitted for you
Plan changes in the appUpgrade now for the difference, downgrade at period endNot supported; the switch is disabled, the subscriber cancels and re-subscribes
Full refund of an upgradeUndoes it: old price back, the credit top-up taken backNot applicable
Failed renewalPast-due banner and one warning emailNothing is sent; the period lapses
Account erasureThe Stripe customer is redacted through the APIRemove the customer in the Creem dashboard by hand
Local webhook testbun run stripe:listen through the Stripe CLIA signed payload from the /creem skill, or a tunnel such as ngrok
Test modeA sk_test_ keyA creem_test_ key, which targets Creem's test API
Catalog ids in plans.tsstripePriceId, one per tier and intervalcreemProductId, one per tier and interval
The switchPAYMENT_PROVIDER=stripePAYMENT_PROVIDER=creem

Everything else works the same on both: checkout, renewals, cancellation, full and partial refunds, and the credit grants that hang off each event.

Choosing the active provider

PAYMENT_PROVIDER decides who takes new checkouts. An existing subscription keeps renewing, and its portal and cancel buttons keep working, with whichever provider it started on, read off its row. You can switch later without stranding anyone, and both providers can be configured at once.

If PAYMENT_PROVIDER is empty, ShipKit uses Stripe when STRIPE_SECRET_KEY is set, otherwise Creem. Nothing can be bought until the active provider has its key: every purchase button checks this first and shows "coming soon" instead of a checkout that would fail. The free plan's monthly credits keep the app usable in the meantime, which matters because a merchant account can take weeks to approve.

The keys are STRIPE_SECRET_KEY / STRIPE_WEBHOOK_SECRET and CREEM_API_KEY / CREEM_WEBHOOK_SECRET. Env is read at boot, so restart the dev server after editing .dev.vars. See Configuration for the full list and Deploy for production secrets.

Plans and packs: src/config/plans.ts

This file is the single source of truth. Each plan has a display price in cents per interval, a monthlyCredits grant, and one provider id per interval; each credit pack has a credit amount, a price and one id per provider. The webhook translators resolve the charged price or product id back to a plan here, so an id that is not in this file is not something the app knows how to fulfil.

The ids that ship are placeholders (price_REPLACE_ME_…). Run the /stripe or /creem skill: it creates the catalog, writes the real ids in, and checks the webhook end to end. The display prices must match what the provider charges; the provider's price is always what is actually billed.

A subscription in ShipKit is a standing credit grant, not a feature flag. See Credits for the pricing constants at the top of the file and for what to do if your product gates features instead.

Webhook events

EventWhat it means
subscription.activatedFirst payment of a subscription
subscription.updatedA renewal, or a change to plan, period or cancel flag
subscription.payment_failedA renewal charge failed and the provider will retry (Stripe)
subscription.canceledThe subscription has ended
order.paidMoney arrived: a first payment, a renewal, a pack, an upgrade
order.refundedMoney went back, fully or partly, or a dispute was lost

For Stripe, the endpoint must subscribe to checkout.session.completed, checkout.session.async_payment_succeeded, invoice.paid, invoice.payment_failed, customer.subscription.updated, customer.subscription.deleted, charge.refunded, credit_note.created and charge.dispute.closed. The /stripe skill lists them too.

If any handler throws, the webhook route deletes its dedupe row and answers 500, so the provider's retry gets a full second attempt instead of being dropped as a duplicate. Delivered payloads are kept for 90 days by default (payment.webhook_retention_days in the admin settings), then pruned by the nightly job.

To react to payments in your own feature, register a handler and add one import line to src/core/payment/handlers.ts:

// src/features/<name>/server/events.ts
import { onPaymentEvent } from '@/core/payment/events'

onPaymentEvent(async (event) => {
  if (event.type !== 'order.paid' || event.meta.kind !== 'my_thing') return
  // Key every write on event.externalOrderId: webhooks are redelivered,
  // and the checkout return dispatches the same event.
})

Features never import a Stripe or Creem client. New checkouts, portals and cancellations go through src/core/payment/provider.ts.

Plan changes (Stripe only)

The rules live in src/features/billing/plan-change.ts, and the preview the buyer confirms is computed by the same code that charges them.

  • Upgrade, same interval: happens now and keeps the renewal date. The buyer pays the price difference (on a yearly plan, for the months left in the year, counting the current one). This month's credit allowance is topped up by the allowance difference, whatever was already spent.
  • Upgrade, monthly to yearly: a new yearly period starts today, less the unused days of the monthly period.
  • Downgrade: any move to a lower tier, and any yearly-to-monthly move, takes effect at the end of the paid period through a Stripe subscription schedule. Until then the pending change shows in the UI and can be called off.

A conditional update on the subscription row acts as a short lock, so a double click cannot charge an upgrade twice.

Cancellation from the app sets cancelAtPeriodEnd; the subscription stays active until the provider reports it ended. If a renewal or cancel webhook never arrives, the nightly billing.expire-stale-subscriptions job expires a subscription 3 days after its period ended (billing.grace_days, editable in the admin console).

Refunds

Refund in the provider's dashboard; the app follows the webhook. Only a full refund undoes what an order bought:

  • a credit pack's credits are taken back (the balance may go negative if they were already spent);
  • a subscription payment (the latest one) ends the paid credit allowance it bought: this month's lot drops back to the free amount. The subscription row itself does not change; cancel the subscription in the provider if the refund means the customer is leaving;
  • an upgrade's price difference reverts the upgrade: old price back, same period, the month's top-up taken back;
  • a pending affiliate commission on the order is voided.

A partial refund is recorded and treated as goodwill: nothing is clawed back. If you want to take credits back after one, use an admin adjustment (see Credits).

Testing locally

Stripe. Install the Stripe CLI, put a test key in .dev.vars, then:

bun run stripe:listen   # prints the whsec_… value for STRIPE_WEBHOOK_SECRET

It forwards webhooks to localhost:3000/api/webhooks/stripe. Buy through the UI with card 4242 4242 4242 4242, then check webhook_events, orders, subscriptions and credit_ledger in the local D1. stripe trigger checkout.session.completed proves the signature and dedupe path, but its fixture has no user id, so it grants nothing.

Creem. Creem cannot reach localhost and has no forwarding CLI. The /creem skill posts a signed fake checkout.completed to the local endpoint, which proves the whole pipeline without a real purchase. For real test-mode webhooks, expose port 3000 through a tunnel and point the Creem webhook at https://<tunnel>/api/webhooks/creem; the tunnel must pass the body through untouched, because the signature covers the exact bytes.

For production webhook URLs and live keys, see Deploy.