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
- A signed-in user clicks a buy button. A server function
(
createSubscriptionCheckoutFninsrc/features/billing/server/fns.ts,createCreditCheckoutFninsrc/features/credits/server/fns.ts) asks the active provider for a hosted checkout page, with the user id and what was bought (kind,planIdorpackId) in the checkout metadata. - The buyer pays on the provider's page and is sent back to
/billingor/credits, in the language they left in. - The provider posts a webhook to
/api/webhooks/stripeor/api/webhooks/creem. The route does three things only: verify the signature, dedupe on the event id (a unique row inwebhook_events), and translate the payload into provider-neutralPaymentEvents. - Each feature that cares about money handles those events: billing keeps
subscriptionsandordersin 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
| Stripe | Creem | |
|---|---|---|
| Role | Payment processor; you are the merchant of record | Merchant of record; they sell, you get paid |
| Sales tax and VAT | Yours to handle, or enable Stripe Tax | Collected and remitted for you |
| Plan changes in the app | Upgrade now for the difference, downgrade at period end | Not supported; the switch is disabled, the subscriber cancels and re-subscribes |
| Full refund of an upgrade | Undoes it: old price back, the credit top-up taken back | Not applicable |
| Failed renewal | Past-due banner and one warning email | Nothing is sent; the period lapses |
| Account erasure | The Stripe customer is redacted through the API | Remove the customer in the Creem dashboard by hand |
| Local webhook test | bun run stripe:listen through the Stripe CLI | A signed payload from the /creem skill, or a tunnel such as ngrok |
| Test mode | A sk_test_ key | A creem_test_ key, which targets Creem's test API |
Catalog ids in plans.ts | stripePriceId, one per tier and interval | creemProductId, one per tier and interval |
| The switch | PAYMENT_PROVIDER=stripe | PAYMENT_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
| Event | What it means |
|---|---|
subscription.activated | First payment of a subscription |
subscription.updated | A renewal, or a change to plan, period or cancel flag |
subscription.payment_failed | A renewal charge failed and the provider will retry (Stripe) |
subscription.canceled | The subscription has ended |
order.paid | Money arrived: a first payment, a renewal, a pack, an upgrade |
order.refunded | Money 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.