Packages@lunora/payment@lunora/payment — Autumn

@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:

  1. 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.
  2. 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-js
npm install @lunora/payment autumn-js
yarn add @lunora/payment autumn-js
bun add @lunora/payment autumn-js

Configure

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.update with a cancelAction: cancel_immediately, or cancel_end_of_cycle when atPeriodEnd: true. A cancel-at-period-end re-reads Autumn's real status and only flips the schedule flag, so a past_due / paused grant is never silently promoted to active.
  • Update / switch plan (updateSubscription) — a plan change is a billing.attach of the new plan; the adapter then re-reads Autumn's authoritative state.
  • Resume (resumeSubscription) — billing.update with cancelAction: "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