Skip to content

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 hookPaylera hookStatusNotes
useEntitlement(code)useFeature(code)renamedSame input, same { allowed, balance, usage, unlimited, ... } result.
useEntitlements()useEntitlements()identicalBulk-read of every feature on the resolved subscription.
useTrack()useTrack()identicaltrack(featureCode, value, { dedupKey }).
useAttach()useAttach()identicalattach({ plan_id, success_url, cancel_url, ... }) returns { checkout_url, ... }.
useCustomer()useCustomer()identicalReturns the resolved Paylera customer DTO.
useBillingPortal()useBillingPortal()identicalopenBillingPortal({ return_url }) returns { url, expires_at }.
usePricingTable()usePricingTable()identicalPublic plan catalog; also exported as usePlans().
useInvoices()useInvoices()identicalPaginated invoice history.
useAutumn() (composite)usePaylera() (composite)renamedSame shape — exposes { attach, check, track } on a single hook.
useUpgrade()useUpgrade()identicalImperative plan change framed as an upgrade.

The single behavioural rename is useEntitlementuseFeature. 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 optionPaylera optionNotes
apiKeyapiTokenSame secret-token shape. Prefix pl_live_ / pl_test_ determines the base URL.
identifyidentifySame signature; returns { customerId, email, name, currency?, metadata? }.
(implicit)autoCreateCustomerPaylera 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 routers
import { payleraRelayFastify } from "@paylera/server/fastify"; // Fastify
import { payleraRelayHono } from "@paylera/server/hono"; // Hono (Cloudflare, Bun, Deno)
import { payleraRelayKoa } from "@paylera/server/koa"; // Koa
import { 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:

  1. Plan catalog is yours, not Stripe’s. Your paylera.config.ts declares 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.
  2. payment_provider is configurable per attach() call. Pass payment_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.
  3. 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.
  4. Webhooks come from Paylera, not Stripe. Your stripe-signature verifier becomes a paylera-signature verifier. Same HMAC-SHA-256, same t=<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

FieldAutumnPayleraNotes
Feature keyfeatureCode: stringfeature_code: stringsnake_case in Paylera; matches OpenAPI spec.
Valuevalue: numbervalue: numberIdentical.
Idempotency / dedupdedupKey: string?dedup_key: string?Becomes the Idempotency-Key header. UUIDv7 auto-generated if omitted.
Response204 No Content204 No ContentBoth invalidate the matching useFeature(code) query.

POST /api/<vendor>/attach

FieldAutumnPayleraNotes
Plan pickerproductId: stringplan_id: stringPaylera distinguishes product (the “thing”) from plan (the “tier”).
Add-onsquantity: numberadd_ons: [{ id, quantity }]Paylera supports multi-addon.
Return URLssuccessUrl, cancelUrlsuccess_url, cancel_urlSame semantics. {CHECKOUT_SESSION_ID} placeholder works on both.
Provider(always Stripe)payment_provider: "stripe" | "toss"Optional in Paylera; tenant default fires when omitted.
TrialtrialPeriodDays: 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)

FieldAutumnPayleraNotes
Feature keyfeatureCodefeature_codesnake_case.
Boolean gateallowed: booleanallowed: booleanIdentical.
Numeric balancebalance: numberbalance: numberIdentical.
Included usageincluded: numberincluded_usage: numberRenamed for clarity.
Usageusage: numberusage: numberIdentical.
Unlimited flagunlimited: booleanunlimited: booleanIdentical.
Cycle resetnextResetAt: stringnext_reset_at: stringsnake_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:

paylera.config.ts
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:

Terminal window
npx @paylera/cli deploy --env live

The 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 read
import { getEntitlement } from "autumn-js/server";
const { allowed } = await getEntitlement({
customerId,
apiKey: process.env.AUTUMN_SECRET_KEY!,
featureCode: "api_calls",
});
// Paylera — RSC entitlement read
import { 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.ts instead — 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-grants from 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 featureGroup aggregations. Paylera does not have a vendor concept of feature groups — model groupings client-side, or use the privileges array returned by useEntitlements() to flag capability bundles.
  • Stripe-side proration overrides. Paylera’s POST /subscriptions/{id}/upgrade accepts proration_behavior with 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:

  1. Hook rename pass complete. grep -r "useEntitlement(" src/ returns no results (renamed to useFeature). grep -r "AutumnProvider" src/ returns no results.
  2. Relay protocol smoke test. curl http://localhost:3000/api/paylera/csrf-token returns { "token": "...", "expires_at": "..." } — proves the new @paylera/server mount is wired.
  3. Catalog parity. npx @paylera/cli diff --env live shows zero drift between paylera.config.ts and the live tenant.
  4. Entitlement parity. For a handful of test customers, the useFeature("api_calls") balance matches what Autumn’s useEntitlement("api_calls") reported before the cutover.
  5. Webhook handler swapped. Your old Stripe-Signature (or Autumn-Signature) verifier now reads Paylera-Signature and uses the secret from PAYLERA_WEBHOOK_SECRET. See Webhook signatures.
  6. scope-multi check passes. Repo-level: a per-package commit discipline holds; the migration doc itself lives outside sdks/.

Cutover sequence

For a low-traffic app:

  1. Land the code change on a branch (Provider rename, hook rename, relay swap, paylera.config.ts written).
  2. Run npx @paylera/cli deploy --env test against the Paylera test tenant. Verify catalog matches.
  3. Run a synthetic end-to-end test on staging: signup → attach → track → check → cancel.
  4. Run npx @paylera/cli deploy --env live. Catalog is now in live.
  5. 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).
  6. Flip the frontend’s backendUrl from /api/autumn to /api/paylera (or, if you ran them in parallel, remove the /api/autumn mount).
  7. Watch the OpenTelemetry dashboards for 24 hours — the paylera.requests.duration histogram 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