@lunora/payment is one provider-agnostic API over Stripe, Polar, Autumn, Dodo Payments, and Creem. Webhook
events are verified, normalized, and applied through an explicit
payment/subscription state machine that makes duplicate and out-of-order
deliveries safe by construction, and synced into a durable store that rides the
request's ctx.db. Switching providers is a configuration change: the provider
is a stateless translator; the store owns all state.
Outbound calls carry idempotency keys wherever the provider accepts one (no double-charge), and every mutation is authorized per-caller. Each adapter takes the provider's SDK client by injection. The provider SDKs are optional peer dependencies, so you install (and bundle) only the providers you actually use.
pnpm add @lunora/payment stripeConfigure
ctx.payments is wired by codegen onto ActionCtx whenever a lunora/ source
imports @lunora/payment or reads ctx.payments. The adapter, which carries
provider secrets, comes from a config.payment(env) thunk you pass to
createShardDO(). The store is built per request from ctx.db, and the default
authorizer ties referenceId to ctx.auth.userId.
import { createStripeAdapter } from "@lunora/payment/stripe";
import { createShardDO } from "./_generated/shard";
import Stripe from "stripe";
export const ShardDO = createShardDO({
payment: (env) => ({
adapter: createStripeAdapter({
// The adapter types its client as the real `Stripe` instance (an optional peer dependency),
// so it's passed straight through — the fetch HTTP client is required on workerd.
client: new Stripe(env.STRIPE_SECRET_KEY, { httpClient: Stripe.createFetchHttpClient() }),
webhookSecret: env.STRIPE_WEBHOOK_SECRET,
}),
// Override the default "caller owns the referenceId" rule for org/workspace keys.
// authorize: (referenceId) => referenceId === ctx.auth.orgId,
entitlements: {
plans: {
pro: { features: ["export"], limits: { api_calls: 1000 }, priceIds: ["price_123"] },
},
},
observability: (event) => console.log("[payment]", event.type, event),
}),
});The thunk returns PaymentsFromContextOptions:
| Option | Type | Notes |
|---|---|---|
adapter | PaymentAdapter | Built with createStripeAdapter / createPolarAdapter / createAutumnAdapter / createDodoPaymentsAdapter / createCreemAdapter. Required. |
authorize | AuthorizeReference | (referenceId) => boolean | Promise<boolean>. Defaults to "caller owns reference". |
entitlements | EntitlementsConfig | Plan → features/limits map. Required to use check / listBalances. |
observability | PaymentObserver | Telemetry sink for webhook applies, failed payments, past-due subscriptions. |
Provider adapters
Every adapter takes its client by injection and verifies inbound webhooks. Each lives at its own subpath so importing one never loads another provider's SDK:
import { createAutumnAdapter } from "@lunora/payment/autumn";
import { createCreemAdapter } from "@lunora/payment/creem";
import { createDodoPaymentsAdapter } from "@lunora/payment/dodopayments";
import { createPolarAdapter } from "@lunora/payment/polar";
import { createStripeAdapter } from "@lunora/payment/stripe";
const stripe = createStripeAdapter({ client, webhookSecret, webhookToleranceSeconds: 300 });
const polar = createPolarAdapter({ client, webhookSecret });
const autumn = createAutumnAdapter({ client, webhookSecret });
const dodo = createDodoPaymentsAdapter({ client, webhookSecret });
const creem = createCreemAdapter({ client, webhookSecret });| Option | Stripe | Polar | Autumn | Dodo | Creem | Notes |
|---|---|---|---|---|---|---|
client | ✓ | ✓ | ✓ | ✓ | ✓ | The provider's injected SDK client (e.g. a Stripe instance). |
webhookSecret | ✓ | ✓ | ✓ | ✓ | ✓ | Signing secret for inbound webhook verification. |
webhookToleranceSeconds | ✓ | ✓ | ✓ | ✓ | Optional clock-skew tolerance (schemes that sign a timestamp). |
Stripe is a PSP (manual authorize/capture available). Polar, Dodo Payments, and
Creem are Merchants-of-Record: merchantOfRecord is true and they own
tax/invoices, so manual capture isn't part of their flow. Stripe webhooks use
Stripe's signature scheme; Polar, Autumn, and Dodo Payments use Standard Webhooks
(webhook-id / webhook-timestamp / webhook-signature); Creem uses a single
creem-signature HMAC-SHA256 header.
Creem
Creem is a Merchant-of-Record for software with an
EU-friendly global footprint. Unlike a raw PSP, it is product-based (real
product ids), so it slots into the priceId interface directly like Polar/Dodo:
hosted checkout sessions, first-class subscriptions (cancel at period end,
pause/resume, plan upgrades), and a genuine hosted billing portal via
customers.generateBillingLinks (portal is true). Refunds are issued from
the Creem dashboard (no SDK endpoint), so refundPayment throws. Webhooks are
verified with the creem-signature HMAC-SHA256 header.
import type { CreemClientLike } from "@lunora/payment/creem";
import { createCreemAdapter } from "@lunora/payment/creem";
import { Creem } from "creem";
const creem = createCreemAdapter({
client: new Creem() as unknown as CreemClientLike,
webhookSecret: env.CREEM_WEBHOOK_SECRET,
});Autumn
Autumn is an entitlement-first billing layer that runs
on your own Stripe account, so it is not a Merchant-of-Record, but, like
Polar, it abstracts the money movement, so manual authorize/capture/refund
throw. It keys a subscription on the (customerId, planId) pair, which the
adapter encodes as the composite id "<customerId>::<planId>".
Autumn owns entitlement truth: balances, credits, limits, and rollovers are
computed on its side from your plan config. The adapter implements the optional
checkEntitlement / getBalances hooks, so the facade delegates check and
listBalances straight to Autumn's live API, and you do not need to configure
entitlements for an Autumn app. Because of that, the authoritative sync path is
live queries + reconcile, not webhook fan-in (the autumn-js SDK models no
outbound webhook stream). Autumn's dashboard can still emit Standard Webhooks
(Svix) events, so parseWebhook is provided as a best-effort convenience;
reconcile remains the reliable path for subscription drift.
Construct the client from autumn-js and inject it (the cast keeps the package
free of a hard autumn-js dependency):
import type { AutumnClientLike } from "@lunora/payment/autumn";
import { createAutumnAdapter } from "@lunora/payment/autumn";
import { Autumn } from "autumn-js";
const autumn = createAutumnAdapter({
client: new Autumn({ secretKey: env.AUTUMN_SECRET_KEY }) as unknown as AutumnClientLike,
webhookSecret: env.AUTUMN_WEBHOOK_SECRET,
});A companion facade, createAutumnFeatures, holds the Autumn-native concepts that
have no cross-provider equivalent: entities (per-seat/per-workspace
sub-customers), referrals, usage-events analytics, the plan catalog,
and a native checkout (prepaid feature quantities, entity scoping, reward
codes, opting out of a plan's trial). It is kept separate so the
provider-agnostic surface stays generic. Share the same injected client between
the two. (Credit systems need no extra method: Autumn models them as features,
so their balances flow through check / listBalances like any other.)
import type { AutumnFeaturesClientLike } from "@lunora/payment/autumn-features";
import { createAutumnFeatures } from "@lunora/payment/autumn-features";
const autumnFeatures = createAutumnFeatures({ client: client as unknown as AutumnFeaturesClientLike });
await autumnFeatures.entities.create(userId, { featureId: "seats", id: "workspace_a", name: "Workspace A" });
const usage = await autumnFeatures.events.aggregate(userId, { featureId: "api_calls", range: "30d" });
const { url } = await autumnFeatures.checkout(userId, { planId: "pro" });Dodo Payments
Dodo Payments is a Merchant-of-Record: like
Polar, it is the legal seller of record and calculates, collects, and remits tax
across 190+ jurisdictions, and owns chargebacks and disputes. So merchantOfRecord
is true and manual authorize/capture throw, but refunds are first-class
(refunds.create). The flow is checkout-session → subscription/payment, synced
from Standard Webhooks (the dodopayments SDK exposes the full, verified event
catalog: payment.*, subscription.*, refund.*, …). It supports usage-based
billing, so reportUsage ingests Dodo usage-events.
Construct the client from dodopayments and inject it (the cast keeps the
package free of a hard dodopayments dependency):
import type { DodoPaymentsClientLike } from "@lunora/payment/dodopayments";
import { createDodoPaymentsAdapter } from "@lunora/payment/dodopayments";
import DodoPayments from "dodopayments";
const dodo = createDodoPaymentsAdapter({
client: new DodoPayments({ bearerToken: env.DODO_PAYMENTS_API_KEY }) as unknown as DodoPaymentsClientLike,
webhookSecret: env.DODO_PAYMENTS_WEBHOOK_KEY,
});The ctx.payments API
Call the facade from an action. Every method authorizes the caller against the
referenceId first.
| Method | Returns | What it does |
|---|---|---|
createCheckout(input) | CheckoutResult | Start a hosted checkout (mode: "payment" | "subscription"); returns a url. |
attach(input) | CheckoutResult | Plan alias of createCheckout with mode defaulting to "subscription". |
createPortalSession(referenceId, returnUrl) | { url } | Open the provider billing portal; customer derived from the store (no IDOR). |
cancelSubscription(subscriptionId, options?) | Subscription | Cancel now or atPeriodEnd; persists the result. |
refundPayment(input) | PaymentSession | Refund the caller's own captured payment, in full or in part; persists the result. |
capturePayment(input) | PaymentSession | Capture the caller's own authorized payment; persists the result. |
cancelPayment(sessionId, options?) | PaymentSession | Cancel the caller's own uncaptured payment; persists the result. |
listSubscriptions(referenceId) | Subscription[] | Synced subscriptions for a reference. |
check(input) | CheckResult | Is a reference allowed something now? Pass featureId or priceId. |
listBalances(referenceId) | FeatureBalance[] | Resolve every configured feature's allowance in one call. |
track(input) | TrackResult | Record metered usage (exactly-once by idempotency key). |
handleWebhook(request) | Response | Verify + normalize + apply a provider webhook. |
ctx.payments.adapter is the raw provider client, exposed for the surfaces the facade does not wrap (updateSubscription, resumeSubscription,
provider-specific extras). Do not move money through it. adapter.refundPayment / capturePayment / cancelPayment skip everything the facade methods
above do first: the caller authorization, the over-refund guard, the local refund ledger (the only thing stopping a retry from issuing a second real refund
on a provider whose API takes no idempotency key), the derived idempotency key, and the store write. The facade ends in the same provider call.
import { action, query, v } from "./_generated/server";
export const checkout = action.input({ priceId: v.string() }).action(async ({ ctx, args: { priceId } }): Promise<{ url: string }> => {
const { url } = await ctx.payments.createCheckout({
referenceId: ctx.auth.userId,
priceId,
mode: "subscription",
successUrl: "https://app.test/done",
cancelUrl: "https://app.test/cancel",
});
return { url };
});createCheckout reuses a reference's stored provider customer, minting a new
one only on first checkout, and attaches an outbound idempotency key
automatically (override via input.idempotencyKey).
Entitlements: check and track
Entitlements are derived from already-synced subscription state, which is cheap
and in-Worker and needs no external billing service. A plan is granted when an
active (or trialing) subscription holds one of its priceIds. When several
active plans cap the same limit, the most-generous value wins.
- A product check (
priceId) isallowedwhen the reference holds an active subscription on that price. - A boolean feature check (
featureId, no numeric limit) returnsunlimited: truewhen granted. - A metered feature check (
featureIdwith a planlimit) subtracts usage tracked this period and returns{ allowed, balance, limit, used }.
export const recordApiCall = action.action(async ({ ctx }): Promise<{ recorded: boolean }> => {
// `mode: "add"` (default) increments; `"set"` reconciles the period total.
// Both are a single append — a `"set"` records the absolute target as a marker
// the period fold resets to, so concurrent or replayed `"set"` calls resolve
// last-writer-wins instead of double-applying. Neither mode needs a lock.
const result = await ctx.payments.track({ referenceId: ctx.auth.userId, featureId: "api_calls" });
return { recorded: result.recorded };
});
export const apiCallsRemaining = action.action(async ({ ctx }): Promise<{ allowed: boolean; balance?: number }> => {
const result = await ctx.payments.check({ referenceId: ctx.auth.userId, featureId: "api_calls" });
return { allowed: result.allowed, balance: result.balance };
});track writes a durable, append-only usage ledger that check folds over the
current billing period. When the provider advertises usage metering, the event
is also forwarded to its metering API. That forward is best-effort: a reporting
failure is observed, never thrown, and the local ledger check reads is always
updated.
Deduping a retry is opt-in. Pass your own idempotencyKey — a unique index over (provider, idempotencyKey) then makes the append exactly-once, so a
retried call records once. Omit it (as the example above does) and track mints a fresh crypto.randomUUID() per call, which means "always record": the
same call retried after a timeout counts twice. Derive the key from whatever identifies the unit of work — the request id, the message id, the job id.
Webhooks
Signature verification needs the raw request body, so the webhook endpoint
runs at the Worker edge via httpAction (no ctx.db). It forwards the raw body
and signature into the shard via ctx.runAction, where ctx.payments and its
store exist:
// lunora/http.ts
import { webhookResponse } from "@lunora/payment";
import { httpAction, httpRouter } from "lunorash/server";
import { processWebhook } from "./billing";
export const app = httpRouter();
app.post(
"/payment/webhook",
httpAction(async (ctx, request) => {
const body = await request.text();
const signature = request.headers.get("stripe-signature") ?? "";
// `webhookResponse`, not `Response.json(...)`: only the JSON payload crosses the
// `runAction` boundary, so the status has to be re-applied on this side or every
// outcome — including the deliberate 500 below — answers 200.
return webhookResponse(await ctx.runAction(processWebhook, { body, signature }));
}),
);// lunora/billing.ts
import { internalAction, v } from "./_generated/server";
export const processWebhook = internalAction
.input({ body: v.string(), signature: v.string() })
.action(async ({ ctx, args: { body, signature } }): Promise<{ applied: boolean; status: number }> => {
const request = new Request("https://internal/payment/webhook", {
body,
headers: { "stripe-signature": signature },
method: "POST",
});
const response = await ctx.payments.handleWebhook(request);
const result = (await response.json()) as { applied?: boolean };
return { applied: result.applied ?? false, status: response.status };
});handleWebhook verifies the signature, normalizes the event into a
WebhookAction (e.g. subscription.active, payment.captured,
payment.refunded), and applies it through the state machine. A duplicate or
no-op event is acknowledged with 200, not re-applied. The inbound provider
event id keys an append-only events log for idempotency and audit.
There is one deliberate non-200. An orphaned event — one that patches a row
whose create event has not arrived yet, which is how providers deliver
out-of-order — returns 500 so the provider retries it, and the update applies
once the create lands. Do not "fix" that by always answering 200: swallowing
the retry is how an out-of-order delivery is lost for good. That is also why the
route must return webhookResponse(result) rather than Response.json(result) —
the status does not survive the runAction hop on its own. Malformed deliveries
(a missing provider event id) likewise surface as a 5xx rather than a 400,
so every provider retries them instead of some treating a 400 as "stop".
The orphan retry is bounded to exactly one. The row may never appear at all —
a subscription created before the integration existed, a store reset, a
completed-but-unpaid checkout whose create event never arrives — and an event
retried forever makes the provider hammer the endpoint until it disables it,
taking every other event down with it. So the first sighting answers 500 and
the second is acknowledged 200 with reason: "unhandled". That budget is spent
in wall-clock order, not on a schedule: a sender that retries immediately
(svix-backed providers such as Polar and Dodo do) can burn it seconds after the
first delivery, while the create event is still in flight. If the create event
can lag that far behind, do not lean on the webhook retry — sweep non-terminal
rows with reconcile as below.
Webhooks are eventually-but-not-guaranteed: an endpoint down past the provider's retry window can drop an event for good. reconcile re-fetches the
provider's current truth for given payment/subscription ids and overwrites the store when it has drifted. Pair it with a @lunora/scheduler job that sweeps
non-terminal rows.
Data it stores
Codegen discovers tables by parsing your lunora/schema.ts AST, so it cannot
resolve a cross-package defineSchema({ ...paymentTables }) spread. Declare the
tables you use inline in your own lunora/schema.ts, mirroring the columns
in @lunora/payment's exported paymentTables (the canonical column reference
the store reads/writes). Declaring them locally also lets you chain .global()
on read-heavy tables (e.g. subscriptions) for cross-region reads from D1.
paymentTables is exactly five tables — mirror only these:
| Table | Holds |
|---|---|
customers | one row per (provider, referenceId), carrying the provider's customer id |
subscriptions | one row per provider subscription: price, quantity, state, current period |
paymentSessions | one row per payment: amountMinor, capturedMinor, refundedMinor, currency, state |
events | the append-only webhook log — inbound idempotency, audit, debugging |
usageEvents | the append-only metered-usage ledger check folds over |
Money is stored as (amountMinor: bigint, currency: string) columns; every row
carries a provider discriminator so multiple providers can coexist during a
migration. A capture or refund is not its own table: both fold into the
payment's paymentSessions row as capturedMinor / refundedMinor plus a
state (refunded / partially_refunded). Products, prices, checkouts and
invoices stay with the provider — the store does not mirror them.
Backfilling money columns written before the storage fix
Rows written between 2026-08-03 and the v.bigint() storage fix stored their
money columns in a form SQL could not read. Such a row reads back correctly
(ctx.db.get returns the right bigint) but is invisible to comparison:
// A settled session whose amountMinor was written in the affected window.
await ctx.db
.query("paymentSessions")
.filter((q) => q.gte("amountMinor", 1000n))
.collect();
// ^ silently omits it. `ORDER BY amountMinor` mis-sorts it, and `sum` counts it as 0.Any write heals the row, so the exposure is the rows nobody writes again, which
for paymentSessions is precisely the settled sessions, the ones most likely
to be queried by amount and least likely to be touched.
A reserved data migration re-projects them, one per affected table. Preview it first:
lunora migrate up __lunora_reproject__paymentSessions --dry-run
lunora migrate up __lunora_reproject__paymentSessions
lunora migrate status __lunora_reproject__paymentSessionsIt runs through the normal writer path, so triggers fire and live subscribers are
notified; it is resumable and safe to interrupt, and rows already in the current
form are skipped rather than rewritten. Repeat for any other shard-local
table of yours declaring a v.bigint() or v.bytes() column; the id is always
__lunora_reproject__<table>.
.global() tables were never affected: their rows live in the D1/Hyperdrive
store, which has always had a per-column codec. Running the migration against one
returns MIGRATION_NOT_FOUND, which is the expected answer, not a problem to
work around.
Confirming it is complete: a second --dry-run reporting 0 rows to change
means nothing is left in the old form. Apps that only ever ran on the fixed
version have nothing to do and the dry run reports 0 on the first try.
Studio
The Studio Payments panel (under Operations) shows synced customers, subscriptions, and webhook events for the app. The panel only appears once codegen detects payments in use.