@lunora/payment — Autumn
End-to-end tutorial for wiring the Autumn adapter into ctx.payments — attach a product, check/track features Autumn owns, verify Svix webhooks, and reconcile.
Autumn is an open-source, entitlement-first billing
layer that runs on your own Stripe account. This page is a standalone,
copy-pasteable walkthrough: install the adapter, wire it onto ctx.payments,
attach your first product, gate features and meter usage (both of which Autumn
computes for you), verify inbound webhooks, and reconcile. Every API detail below
maps to a real method on the adapter — nothing is invented. For the
provider-agnostic concepts (ctx.payments surface, the state machine, the store,
reconcile) read the package overview first.
Autumn is entitlement-first — and runs on your own Stripe
Autumn sits between your app and your Stripe account and becomes your source of truth for subscription state, credit balances, feature entitlements, and usage enforcement (docs.useautumn.com/welcome). Two facts shape everything else:
- It is not a Merchant-of-Record. Because Autumn charges through your Stripe account, you remain the merchant of record — you own tax calculation, invoicing, and remittance, exactly as with the raw Stripe adapter. This is the opposite of Polar / Dodo Payments / Creem, where the provider is the legal seller. But like those providers, Autumn abstracts the money movement, so the adapter exposes no manual authorize / capture / refund.
- Autumn owns entitlement truth. Balances, credits, limits, and rollovers are computed on Autumn's side from your plan config. The model is attach a product, then check / track features (gating docs).
The adapter advertises its shape in capabilities:
Prop
Type
Because Autumn owns entitlements, the adapter implements the optional
checkEntitlement / getBalances hooks, and the facade delegates check and
listBalances straight to Autumn's live API — you do not configure a local
entitlements map for an Autumn app. The consequence for keeping your store in
sync: the authoritative path is live queries + reconcile, not webhook
fan-in. Autumn's dashboard can emit Standard Webhooks over
Svix, so parseWebhook is
provided as a best-effort convenience; reconcile (built on
getSubscriptionStatus) remains the reliable path for subscription drift.
Choose Autumn when you want to own your Stripe relationship and tax posture
but offload pricing/entitlement modelling (credits, prepaid quantities, per-seat
entities, usage alerts, auto top-ups) to a purpose-built layer, and drive it all
through one ctx.payments API.
Prerequisites
An Autumn account. Create one at useautumn.com and connect your Stripe account in the Autumn dashboard. Keep Autumn in its sandbox environment while you build.
Your secret key. Autumn dashboard → Developer
(app.useautumn.com/dev). Copy the secret key
(it looks like am_sk_…). This is the value for AUTUMN_SECRET_KEY.
A webhook signing secret. Autumn dashboard → webhook settings → add an
endpoint (see Webhooks below), then copy its signing secret.
Autumn delivers via Svix, so this is a Standard Webhooks signing secret. This is
the value for AUTUMN_WEBHOOK_SECRET.
At least one product/plan. In the Autumn dashboard, model a product with the
features and prices you want to sell, and note its product id (a slug you
choose, e.g. pro). You'll pass this to attach / createCheckout as the
priceId.
Lunora scaffolds the two secrets into your .dev.vars automatically when it
detects @lunora/payment:
# .dev.vars
AUTUMN_SECRET_KEY=am_sk_... # Autumn dashboard → Developer
AUTUMN_WEBHOOK_SECRET=... # Autumn dashboard → webhook settings (signing secret)These are the exact env-var names Lunora's .dev.vars scaffolder writes for the Autumn adapter. Add them to your production secrets with wrangler secret put AUTUMN_SECRET_KEY / wrangler secret put AUTUMN_WEBHOOK_SECRET before deploying.
Install
The package never imports the autumn-js SDK — you inject the client, so
autumn-js stays an optional peer dependency you install alongside it:
pnpm add @lunora/payment autumn-jsnpm install @lunora/payment autumn-jsyarn add @lunora/payment autumn-jsbun add @lunora/payment autumn-jsConfigure
ctx.payments is wired by codegen onto ActionCtx whenever a lunora/ source
imports @lunora/payment or reads ctx.payments. The Autumn adapter — which
carries your secret key — comes from a config.payment(env) thunk you pass to
createShardDO().
Construct an Autumn client from env.AUTUMN_SECRET_KEY:
import { Autumn } from "autumn-js";
const client = new Autumn({ secretKey: env.AUTUMN_SECRET_KEY });Build the adapter with createAutumnAdapter. A real Autumn instance
satisfies the structural AutumnClientLike; 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";
const adapter = createAutumnAdapter({
client: client as unknown as AutumnClientLike,
webhookSecret: env.AUTUMN_WEBHOOK_SECRET,
webhookToleranceSeconds: 300, // optional clock-skew tolerance for the signed timestamp
});Wire it into createShardDO. Because Autumn owns entitlement truth, you do
not pass an entitlements map — check / listBalances delegate to Autumn:
import type { AutumnClientLike } from "@lunora/payment/autumn";
import { createAutumnAdapter } from "@lunora/payment/autumn";
import { createShardDO } from "./_generated/shard";
import { Autumn } from "autumn-js";
export const ShardDO = createShardDO({
payment: (env) => ({
adapter: createAutumnAdapter({
client: new Autumn({ secretKey: env.AUTUMN_SECRET_KEY }) as unknown as AutumnClientLike,
webhookSecret: env.AUTUMN_WEBHOOK_SECRET,
}),
// Override the default "caller owns the referenceId" rule for org/workspace keys.
// authorize: (referenceId) => referenceId === ctx.auth.orgId,
// No `entitlements` map: Autumn is authoritative for check/listBalances.
observability: (event) => console.log("[payment]", event.type, event),
}),
});The adapter options:
Prop
Type
Pin the store's read-heavy tables (e.g. subscriptions) with .global() in your own lunora/schema.ts for cross-region reads — see Data it
stores. The provider is stateless; the store owns all state.
Autumn-native features
The generic ctx.payments surface covers checkout, subscriptions, entitlements,
and usage. Autumn also ships concepts with no cross-provider equivalent —
entities (per-seat / per-workspace sub-customers with their own balances),
referrals (codes and redemptions), usage-events analytics
(list /
aggregate), the
plan catalog, and a native checkout (prepaid feature quantities, entity
scoping, reward codes, opting out of a plan's trial). These live in a companion facade,
createAutumnFeatures, kept separate so the provider-agnostic surface stays
generic. Share the same injected client between the two:
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" });createAutumnFeatures does not authorize the caller. Unlike the ctx.payments facade — which checks the caller owns the referenceId on every call —
these methods take the referenceId (and entityId) positionally and trust them. Forwarding an unvalidated reference straight from a request is an
app-layer IDOR: before exposing any of these on a handler, match the reference against the authenticated user (e.g. ctx.auth.userId). Credit systems need
no method here — Autumn models them as features, so their balances flow through check / listBalances like any other.
Attach a product, then check and track
Call the facade from an action. attach (or createCheckout) starts Autumn's
attach flow: when the change needs a payment it returns a hosted checkout URL
to redirect the browser to; when Autumn can apply the change directly (e.g. a free
plan or an update within an existing subscription) the url is empty. attach is
the plan-oriented alias with mode defaulting to "subscription", so attaching a
product is just { referenceId, priceId, successUrl, cancelUrl }:
import { action, v } from "./_generated/server";
export const subscribe = action.input({ productId: v.string() }).action(async ({ ctx, args: { productId } }): Promise<{ url: string }> => {
const { url } = await ctx.payments.attach({
referenceId: ctx.auth.userId,
priceId: productId, // an Autumn product id, e.g. "pro"
successUrl: "https://app.test/done",
cancelUrl: "https://app.test/cancel",
});
return { url };
});Under the hood the adapter calls Autumn's billing.attach with customerId
(your referenceId) and planId (the product/plan id). Autumn keys everything on
the customer id — which is your reference id — so there is no metadata to pin and
no separate customer to look up: customers.getOrCreate is idempotent on the
app-supplied id.
Gate a feature with check. Because Autumn owns entitlement truth, check
delegates to Autumn's live balance math (gating docs)
— pass a featureId for a feature allowance:
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" });
// For a metered feature Autumn returns balance/limit/used; a boolean feature returns { allowed, unlimited }.
return { allowed: result.allowed, balance: result.balance };
});The adapter maps Autumn's balance fields onto CheckResult:
remaining → balance, granted → limit, usage → used, plus unlimited. The
quantity you pass becomes Autumn's requiredBalance (default 1) — the check
passes only when at least that many units remain.
Meter usage with track. Because Autumn advertises usageMetering, track
writes the durable local ledger and forwards the event to Autumn (which then
decrements the balance check reads). Upstream metering is best-effort: a
reporting failure is observed, never thrown, and the local ledger is always
updated.
export const recordApiCall = action.action(async ({ ctx }): Promise<{ recorded: boolean }> => {
// `mode: "add"` (default) increments; `"set"` reconciles the period total.
const result = await ctx.payments.track({ referenceId: ctx.auth.userId, featureId: "api_calls" });
return { recorded: result.recorded };
});Pass a stable idempotencyKey to track for any usage event you might retry (e.g. keyed on a message/request id). The local Lunora ledger dedupes on it
exactly-once, so a retried action never double-counts in your store. See the correctness note on how Autumn itself
dedupes.
Webhooks
Autumn delivers state changes over Svix
(Standard Webhooks). Signature verification needs the raw request body, so the
endpoint runs at the Worker edge via httpAction (no ctx.db) and forwards the
raw body + the Svix headers into the shard, where ctx.payments and its store
exist. Autumn's canonical headers are svix-id, svix-timestamp, and
svix-signature:
// lunora/http.ts
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();
return Response.json(
await ctx.runAction(processWebhook, {
body,
svixId: request.headers.get("svix-id") ?? "",
svixSignature: request.headers.get("svix-signature") ?? "",
svixTimestamp: request.headers.get("svix-timestamp") ?? "",
}),
);
}),
);// lunora/billing.ts
import { internalAction, v } from "./_generated/server";
export const processWebhook = internalAction
.input({ body: v.string(), svixId: v.string(), svixSignature: v.string(), svixTimestamp: v.string() })
.action(async ({ ctx, args }): Promise<{ applied: boolean; status: number }> => {
const request = new Request("https://internal/payment/webhook", {
body: args.body,
headers: {
"svix-id": args.svixId,
"svix-signature": args.svixSignature,
"svix-timestamp": args.svixTimestamp,
},
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 };
});Register the endpoint
In the Autumn dashboard → webhook settings → add an endpoint pointing at your
deployed route (e.g. https://your-worker.example.com/payment/webhook), then copy
its signing secret into AUTUMN_WEBHOOK_SECRET. Autumn's event catalog is
dashboard-configured; the events it can emit are
billing.updated, balances.limit_reached, balances.usage_alert_triggered, and
billing.auto_topup_succeeded
(webhooks docs).
The signature scheme
parseWebhook reads the svix-id / svix-timestamp / svix-signature headers
(falling back to the bare webhook-* aliases some Standard-Webhooks gateways
forward) and verifies the HMAC over {svix-id}.{svix-timestamp}.{rawBody} against
the signing secret, rejecting any delivery outside webhookToleranceSeconds
(default 300s). Verification runs over the raw body; never re-serialize the
JSON before verifying. Use the Svix libraries
as the reference for the scheme.
Reconcile, not webhooks, is the authoritative sync path for Autumn. Autumn owns entitlement truth, and its outbound event catalog is best-effort — an
endpoint down past the retry window can drop an event for good. Pair handleWebhook with a @lunora/scheduler sweep that calls
reconcile: it re-fetches Autumn's current subscription truth (via getSubscriptionStatus) for given subscription ids and overwrites the store when it has
drifted. Because check / listBalances already read Autumn live, entitlement decisions are never stale even when the store is.
Composite ids and what throws
Autumn identifies a subscription by the pair (customerId, planId)
rather than a single id. The adapter therefore encodes the Lunora
Subscription.id as the composite "<customerId>::<planId>" and splits it
back apart (on the last ::) for getSubscriptionStatus, cancelSubscription,
updateSubscription, and resumeSubscription. createCheckout returns this
composite id as its CheckoutResult.id.
Lifecycle operations map onto Autumn's billing.* model:
- Cancel (
cancelSubscription) —billing.updatewith acancelAction:cancel_immediately, orcancel_end_of_cyclewhenatPeriodEnd: true. A cancel-at-period-end re-reads Autumn's real status and only flips the schedule flag, so apast_due/pausedgrant is never silently promoted toactive. - Update / switch plan (
updateSubscription) — a plan change is abilling.attachof the new plan; the adapter then re-reads Autumn's authoritative state. - Resume (
resumeSubscription) —billing.updatewithcancelAction: "uncancel"to undo a scheduled cancellation, then re-reads the real state.
Because Autumn abstracts the money movement through Stripe, there is no payment
intent to act on directly. These adapter methods throw PROVIDER_ERROR:
capturePayment(manual capture)cancelPayment(manual payment cancellation)refundPayment(refunds)getPaymentStatus(one-time payment-session reconciliation)
Issue refunds and one-off payment operations from your Stripe dashboard or the Autumn dashboard instead.
Troubleshooting
Signature verification fails (400). The most common cause is a body that was parsed/re-serialized before verification — always verify the raw
request text. Confirm you forward all three svix-id / svix-timestamp / svix-signature headers intact, and that AUTUMN_WEBHOOK_SECRET is the signing
secret of the specific endpoint receiving the event (each endpoint has its own secret).
"malformed autumn subscription id". cancelSubscription / getSubscriptionStatus expect the composite "<customerId>::<planId>" id the adapter
minted (returned by attach / createCheckout and stored on the subscription row). Pass that id back verbatim — don't reconstruct it from a bare plan id.
check throws "requires a featureId or priceId". The facade validates the argument shape before delegating to Autumn, so a bare check({referenceId})
is rejected the same way on every provider. Pass a featureId for a feature allowance (the common Autumn case).
Subscription looks active but a balance is wrong. Autumn owns the balance math, so check / listBalances read it live — a stale store row never
affects an entitlement decision. If a stored subscriptions row itself drifts (e.g. after a missed webhook), run the reconcile sweep to re-fetch Autumn's
truth.
Sandbox vs production. An Autumn sandbox secret key only sees sandbox customers and products, and its webhook endpoint has its own signing secret. Keep sandbox and production keys/secrets in separate environments — mixing them yields empty results or signature failures.
See also
- @lunora/payment — the provider-agnostic overview and
ctx.paymentssurface - @lunora/scheduler — drive the
reconcilesweep - Studio — the Payments panel (synced customers, subscriptions, webhook events)
- Autumn docs · Autumn webhooks · gating: check & track