Migrating from Autumn
This recipe ports a React app that uses Autumn’s
SDK to @paylera/react. The two libraries solve the same problem
(server-relayed entitlement checks, usage tracking, and hosted-checkout
attach), so the port is almost mechanical: the hook names line up
one-for-one, the JSX shapes line up one-for-one, and the relay protocol
is the same /api/<vendor>/* pattern. What changes is the Provider, the
import paths, the backend relay package, and one architectural
assumption — Autumn is Stripe-Billing-coupled, Paylera is
provider-agnostic.
TL;DR — the rename pass
import { AutumnProvider, useEntitlement, useTrack, useAttach } from "autumn-js/react";import { PayleraProvider, useFeature, useTrack, useAttach } from "@paylera/react";<AutumnProvider backendUrl="/api/autumn"><PayleraProvider backendUrl="/api/paylera">
const { allowed, balance } = useEntitlement("api_calls");const { allowed, balance } = useFeature("api_calls");Backend:
import { autumnHandler } from "autumn-js/express";import { payleraRelayExpress } from "@paylera/server/express";The rest of your component tree compiles unchanged. The whole port is
typically under an hour for a small app; the bulk of the time goes into
recreating your plan catalog in paylera.config.ts (covered below).
Hook-for-hook translation table
Every Autumn React hook has a one-to-one Paylera counterpart. The same
input arguments, the same flat-result-object shape, the same TanStack
Query semantics (isLoading, error, refetch on reads; isPending,
error, reset on mutations).
| Autumn hook | Paylera hook | Status | Notes |
|---|---|---|---|
useEntitlement(code) | useFeature(code) | renamed | Same input, same { allowed, balance, usage, unlimited, ... } result. |
useEntitlements() | useEntitlements() | identical | Bulk-read of every feature on the resolved subscription. |
useTrack() | useTrack() | identical | track(featureCode, value, { dedupKey }). |
useAttach() | useAttach() | identical | attach({ plan_id, success_url, cancel_url, ... }) returns { checkout_url, ... }. |
useCustomer() | useCustomer() | identical | Returns the resolved Paylera customer DTO. |
useBillingPortal() | useBillingPortal() | identical | openBillingPortal({ return_url }) returns { url, expires_at }. |
usePricingTable() | usePricingTable() | identical | Public plan catalog; also exported as usePlans(). |
useInvoices() | useInvoices() | identical | Paginated invoice history. |
useAutumn() (composite) | usePaylera() (composite) | renamed | Same shape — exposes { attach, check, track } on a single hook. |
useUpgrade() | useUpgrade() | identical | Imperative plan change framed as an upgrade. |
The single behavioural rename is useEntitlement → useFeature. Autumn
calls a feature gate an “entitlement check”; Paylera calls a single-feature
read a useFeature and reserves useEntitlements (plural) for the bulk
read. This makes the bulk vs single distinction more obvious at the
call-site and matches the OpenAPI spec.
Side-by-side: simplest entitlement gate
Before — Autumn:
import { AutumnProvider, useEntitlement } from "autumn-js/react";
function App({ children }: { children: React.ReactNode }) { return ( <AutumnProvider backendUrl="/api/autumn"> {children} </AutumnProvider> );}
function ExportButton() { const { allowed, isLoading } = useEntitlement("export_csv"); if (isLoading) return <Spinner />; if (!allowed) return <UpgradePrompt />; return <button onClick={handleExport}>Export CSV</button>;}After — Paylera:
import { PayleraProvider, useFeature } from "@paylera/react";
function App({ children }: { children: React.ReactNode }) { return ( <PayleraProvider backendUrl="/api/paylera"> {children} </PayleraProvider> );}
function ExportButton() { const { allowed, isLoading } = useFeature("export_csv"); if (isLoading) return <Spinner />; if (!allowed) return <UpgradePrompt />; return <button onClick={handleExport}>Export CSV</button>;}The only changes are the four imports, the Provider name, the
backendUrl, and the hook rename. Everything else — the destructure,
the JSX, the conditional logic — is identical.
Side-by-side: usage tracking
Before — Autumn:
import { useTrack } from "autumn-js/react";
function ApiCallButton() { const { track, isPending } = useTrack();
async function handleClick() { const result = await fetch("/api/run-job", { method: "POST" }); await track("api_calls", 1, { dedupKey: result.headers.get("x-job-id") }); }
return ( <button onClick={handleClick} disabled={isPending}> Run job </button> );}After — Paylera:
import { useTrack } from "@paylera/react";
function ApiCallButton() { const { track, isPending } = useTrack();
async function handleClick() { const result = await fetch("/api/run-job", { method: "POST" }); await track("api_calls", 1, { dedupKey: result.headers.get("x-job-id") }); }
return ( <button onClick={handleClick} disabled={isPending}> Run job </button> );}Identical. The only diff is the import path. The dedupKey argument
becomes the Idempotency-Key header on the outbound request, same as
Autumn — see SDK relay protocol — Idempotency
for the exact UUID-v7 derivation.
Side-by-side: hosted-checkout attach
Before — Autumn:
import { useAttach } from "autumn-js/react";
function PricingButton({ planId }: { planId: string }) { const { attach, isPending } = useAttach();
async function handleClick() { const { checkout_url } = await attach({ plan_id: planId, success_url: `${window.location.origin}/success?session={CHECKOUT_SESSION_ID}`, cancel_url: `${window.location.origin}/pricing`, }); window.location.href = checkout_url; }
return ( <button onClick={handleClick} disabled={isPending}> Subscribe </button> );}After — Paylera:
import { useAttach } from "@paylera/react";
function PricingButton({ planId }: { planId: string }) { const { attach, isPending } = useAttach();
async function handleClick() { const { checkout_url } = await attach({ plan_id: planId, payment_provider: "stripe", // optional; new in Paylera — "stripe" or "toss" success_url: `${window.location.origin}/success?session={CHECKOUT_SESSION_ID}`, cancel_url: `${window.location.origin}/pricing`, }); window.location.href = checkout_url; }
return ( <button onClick={handleClick} disabled={isPending}> Subscribe </button> );}The one addition: an optional payment_provider field. Omit it and the
tenant default fires (almost always "stripe" if you’re migrating from
Autumn). Add it explicitly when you want to offer Korean Toss alongside
Stripe — see the “Provider-agnostic” section below for the architectural
reason this matters.
Side-by-side: composite usePaylera() / useAutumn()
Autumn’s useAutumn() returns a { attach, check, track } triple for
apps that prefer one import. Paylera ships the same shape under
usePaylera() for migration ergonomics.
Before — Autumn:
import { useAutumn } from "autumn-js/react";
function Demo() { const { attach, check, track } = useAutumn(); // ...}After — Paylera:
import { usePaylera } from "@paylera/react";
function Demo() { const { attach, check, track } = usePaylera(); // ...}check() here is the imperative one-shot variant — it bypasses the
TanStack-Query cache, returns the raw FeatureCheck payload, and is
intended for use inside event handlers (e.g. “verify this user can still
run this job at submit time”). For render-loop entitlement gates,
prefer the cached read hook useFeature() — same as Autumn’s distinction
between useEntitlement() and check().
Backend relay swap — @paylera/server
Autumn ships autumn-js as a single isomorphic package with an Express
adapter; Paylera splits the backend into a dedicated @paylera/server
package so non-React backends (and non-Express frameworks) get clean
imports.
Before — Autumn (Express):
import express from "express";import { autumnHandler } from "autumn-js/express";
const app = express();
app.use( "/api/autumn", autumnHandler({ apiKey: process.env.AUTUMN_SECRET_KEY!, identify: async (req) => { const session = await getSession(req); return { customerId: session.user.autumnCustomerId, email: session.user.email, name: session.user.name, }; }, }),);After — Paylera (Express):
import express from "express";import { payleraRelayExpress } from "@paylera/server/express";
const app = express();
app.use( "/api/paylera", payleraRelayExpress({ apiToken: process.env.PAYLERA_API_TOKEN!, identify: async (req) => { const session = await getSession(req); return { customerId: session.user.payleraCustomerId, email: session.user.email, name: session.user.name, }; }, autoCreateCustomer: true, }),);Per-field rename:
| Autumn option | Paylera option | Notes |
|---|---|---|
apiKey | apiToken | Same secret-token shape. Prefix pl_live_ / pl_test_ determines the base URL. |
identify | identify | Same signature; returns { customerId, email, name, currency?, metadata? }. |
| (implicit) | autoCreateCustomer | Paylera makes auto-create explicit — set true if identify may return customerId: null. See Customer identity envelope. |
Non-Express adapters live at:
import { payleraRelayNext } from "@paylera/server/next"; // Next.js app & pages routersimport { payleraRelayFastify } from "@paylera/server/fastify"; // Fastifyimport { payleraRelayHono } from "@paylera/server/hono"; // Hono (Cloudflare, Bun, Deno)import { payleraRelayKoa } from "@paylera/server/koa"; // Koaimport { payleraRelay } from "@paylera/server"; // Bring-your-own framework (low-level)Same options on every adapter — the abstraction is just over the framework’s request/response idioms.
Provider-agnostic — the one architectural difference
Autumn is, in practice, a thin layer on top of Stripe Billing. Every Autumn subscription corresponds to a Stripe Subscription; every Autumn Customer Portal session is a Stripe Customer Portal session; the Customer Portal URL is owned by Stripe.
Paylera is provider-agnostic. A Paylera subscription is a first-class Paylera entity that delegates charging to a payment provider (Stripe, Toss, or — in self-hosted deployments — a future PayPal/Adyen adapter) but owns the entitlement model, the metering, the invoicing, and the customer portal itself.
What this means in practice for an ex-Autumn merchant:
- Plan catalog is yours, not Stripe’s. Your
paylera.config.tsdeclares plans and features; the CLI provisions them in Paylera and creates the corresponding Stripe Prices automatically. You no longer manage prices in the Stripe Dashboard. payment_provideris configurable perattach()call. Passpayment_provider: "stripe"to keep current behaviour; pass"toss"to charge in KRW via Toss Payments. The tenant has a default so you don’t have to thread it through every call.- The customer portal is hosted by Paylera, not Stripe. Users
manage payment methods, view invoices, and cancel from
https://billing.paylera.dev/p/sess_.... The portal still talks to Stripe for card management, but Paylera renders the UI and owns branding. - Webhooks come from Paylera, not Stripe. Your
stripe-signatureverifier becomes apaylera-signatureverifier. Same HMAC-SHA-256, samet=<ts>,v1=<hex>shape — see Webhook signatures.
If your app was happy as an Autumn-flavoured Stripe Billing app, you
can keep it that way: leave payment_provider unspecified, the tenant
default fires, and Stripe-coupled behaviour persists. The new
capabilities are additive.
Wire-shape comparison
The two relay protocols are 95 % identical on the wire — Paylera fixed a couple of Autumn rough edges but kept the shapes. Side-by-side for the high-traffic calls:
POST /api/<vendor>/track
| Field | Autumn | Paylera | Notes |
|---|---|---|---|
| Feature key | featureCode: string | feature_code: string | snake_case in Paylera; matches OpenAPI spec. |
| Value | value: number | value: number | Identical. |
| Idempotency / dedup | dedupKey: string? | dedup_key: string? | Becomes the Idempotency-Key header. UUIDv7 auto-generated if omitted. |
| Response | 204 No Content | 204 No Content | Both invalidate the matching useFeature(code) query. |
POST /api/<vendor>/attach
| Field | Autumn | Paylera | Notes |
|---|---|---|---|
| Plan picker | productId: string | plan_id: string | Paylera distinguishes product (the “thing”) from plan (the “tier”). |
| Add-ons | quantity: number | add_ons: [{ id, quantity }] | Paylera supports multi-addon. |
| Return URLs | successUrl, cancelUrl | success_url, cancel_url | Same semantics. {CHECKOUT_SESSION_ID} placeholder works on both. |
| Provider | (always Stripe) | payment_provider: "stripe" | "toss" | Optional in Paylera; tenant default fires when omitted. |
| Trial | trialPeriodDays: number? | trial_period_days: number? | Identical. |
| Response | { checkout_url } | { checkout_url, checkout_expires_at, ... } | Paylera returns more — see protocol. |
GET /api/<vendor>/check (or useEntitlement / useFeature)
| Field | Autumn | Paylera | Notes |
|---|---|---|---|
| Feature key | featureCode | feature_code | snake_case. |
| Boolean gate | allowed: boolean | allowed: boolean | Identical. |
| Numeric balance | balance: number | balance: number | Identical. |
| Included usage | included: number | included_usage: number | Renamed for clarity. |
| Usage | usage: number | usage: number | Identical. |
| Unlimited flag | unlimited: boolean | unlimited: boolean | Identical. |
| Cycle reset | nextResetAt: string | next_reset_at: string | snake_case. |
| Source | (not exposed) | source: "plan_grant" | "override" | "credit_grant" | New — surfaces where the quota came from. |
The hook result objects flatten snake_case to camelCase at the React
layer; only the wire body uses snake_case. Inside your components,
balance, usage, includedUsage, nextResetAt all read the same
across vendors.
Plan catalog — porting from Autumn dashboard to paylera.config.ts
Autumn’s plans + features live in the Autumn dashboard. Paylera’s are
declared in code (paylera.config.ts) and applied via the
CLI — same model as Terraform, Pulumi, or
SST. This is the biggest single porting cost; budget half a day for a
mid-sized catalog.
Export your Autumn plans (Settings → Plans → Export JSON), then write the equivalent:
import { defineConfig } from "@paylera/cli";
export default defineConfig({ apiVersion: "2026-05-01",
features: [ { code: "api_calls", kind: "metered", billableMetric: "api_call_total" }, { code: "seats", kind: "numeric" }, { code: "premium", kind: "boolean" }, { code: "export_csv", kind: "boolean" }, ],
plans: [ { code: "free", name: "Free", prices: [{ amount: 0, currency: "USD", interval: "month" }], features: { api_calls: { includedUsage: 1000, usageCycle: "calendar_month" }, seats: { value: 1 }, premium: { value: false }, export_csv: { value: false }, }, }, { code: "pro", name: "Pro", prices: [{ amount: 2900, currency: "USD", interval: "month" }], features: { api_calls: { includedUsage: 100000, usageCycle: "calendar_month" }, seats: { value: 10 }, premium: { value: true }, export_csv: { value: true }, }, }, ],});Apply with:
npx @paylera/cli deploy --env liveThe CLI diffs against current state and shows you what’s about to
change before applying. Re-run on every config change; CI runs it on
merge to main.
See SDK relay protocol — CLI declarative config schema for the full Zod schema reference.
SSR — @paylera/react/server
Autumn ships server helpers under autumn-js/server. Paylera ships
the equivalent under @paylera/react/server:
// Autumn — RSC entitlement readimport { getEntitlement } from "autumn-js/server";
const { allowed } = await getEntitlement({ customerId, apiKey: process.env.AUTUMN_SECRET_KEY!, featureCode: "api_calls",});// Paylera — RSC entitlement readimport { getFeature } from "@paylera/react/server";
const { allowed } = await getFeature("api_calls", { customerId, apiToken: process.env.PAYLERA_API_TOKEN!,});Same shape, same hydration pattern via <PayleraProvider initialState={...}>.
See SDK relay protocol — SSR for the
full hydration contract.
Things that don’t translate one-to-one
A short list of Autumn behaviours that need a small rethink rather than a rename:
- Stripe-Dashboard-managed prices. If you had non-Autumn-managed
Stripe Prices that Autumn referenced by ID, port the pricing into
paylera.config.tsinstead — Paylera’s CLI creates the corresponding Stripe Prices for you. Mixing CLI-managed and Dashboard-managed prices on the same plan is supported but discouraged. - Autumn-style “credit grant” CSV uploads. Paylera grants credits
programmatically —
POST /v1/customers/{id}/credit-grantsfrom the backend. There is no CSV ingestion path; if you ran monthly credit refresh from a spreadsheet, port that to a small worker that calls the API. - Autumn
featureGroupaggregations. Paylera does not have a vendor concept of feature groups — model groupings client-side, or use theprivilegesarray returned byuseEntitlements()to flag capability bundles. - Stripe-side proration overrides. Paylera’s
POST /subscriptions/{id}/upgradeacceptsproration_behaviorwith Stripe-compatible values (create_prorations,none,always_invoice). Autumn-specific proration knobs (e.g.prorationOverride: "ignore_credits") are not honoured — Paylera computes prorations natively and is the source of truth.
If you hit one of these and aren’t sure how to map it, file an issue against paylera/paylera-react with the Autumn config snippet — the SDK team adds Autumn-compat shims when the pattern is common.
Verification checklist
Before you ship the migration:
- Hook rename pass complete.
grep -r "useEntitlement(" src/returns no results (renamed touseFeature).grep -r "AutumnProvider" src/returns no results. - Relay protocol smoke test.
curl http://localhost:3000/api/paylera/csrf-tokenreturns{ "token": "...", "expires_at": "..." }— proves the new@paylera/servermount is wired. - Catalog parity.
npx @paylera/cli diff --env liveshows zero drift betweenpaylera.config.tsand the live tenant. - Entitlement parity. For a handful of test customers, the
useFeature("api_calls")balancematches what Autumn’suseEntitlement("api_calls")reported before the cutover. - Webhook handler swapped. Your old
Stripe-Signature(orAutumn-Signature) verifier now readsPaylera-Signatureand uses the secret fromPAYLERA_WEBHOOK_SECRET. See Webhook signatures. scope-multicheck passes. Repo-level: a per-package commit discipline holds; the migration doc itself lives outsidesdks/.
Cutover sequence
For a low-traffic app:
- Land the code change on a branch (Provider rename, hook rename,
relay swap,
paylera.config.tswritten). - Run
npx @paylera/cli deploy --env testagainst the Paylera test tenant. Verify catalog matches. - Run a synthetic end-to-end test on staging: signup → attach → track → check → cancel.
- Run
npx @paylera/cli deploy --env live. Catalog is now in live. - One-time import customers + active subscriptions from Stripe into Paylera via the import API (see Migrate from another billing system for the higher-level cutover plan — the SDK port is orthogonal to that).
- Flip the frontend’s
backendUrlfrom/api/autumnto/api/paylera(or, if you ran them in parallel, remove the/api/autumnmount). - Watch the OpenTelemetry dashboards
for 24 hours — the
paylera.requests.durationhistogram should look like a normal traffic shape with no error spikes.
For a high-traffic app, run the two stacks in parallel on a per-cohort
basis (<PayleraProvider> for new signups, <AutumnProvider> for
existing customers) until the active-subscription import completes,
then flip everyone at once.
Where to next
- Available SDKs — Paylera-flavoured SDK catalog.
- SDK relay protocol — wire-format contract every
backend SDK speaks. This is the source of truth for
@paylera/server. - Node.js / TypeScript SDK — typed client surface for the imperative-server-only use cases (cron jobs, webhook handlers).
- Migrate from another billing system — the broader Stripe-Billing-to-Paylera customer-and-subscription import plan; this recipe is the React-layer companion.
- Webhooks — Verifying signatures — port your Autumn (or Stripe) webhook verifier to Paylera.