Skip to content

Migrating from Stripe Billing

If your current billing stack is “Stripe Billing + a Subscriptions table + some webhook glue”, this recipe is the concept-mapping doc. Most of your Stripe-side primitives translate one-to-one to Paylera; the architectural difference is that Paylera owns the entitlement and metering layer above the charge, so a chunk of code that today lives in your backend (feature flags, quota counters, “is this user on Pro?” queries) is replaced by a single useFeature(code) hook in the frontend.

TL;DR — what changes, what doesn’t

What stays the same:

  • Your Stripe Customer IDs, PaymentMethod IDs, Subscription IDs, and Invoice IDs are preserved when you import — foreign keys in your application keep working.
  • The subscription lifecycle states (trialing, active, past_due, unpaid, canceled, incomplete, incomplete_expired, paused) map one-to-one — same names, same transitions.
  • Hosted checkout still exists; it’s now POST /v1/attach instead of POST /v1/checkout/sessions, but it returns the same https://checkout.stripe.com/c/pay/cs_... URL when payment_provider: "stripe".
  • Stripe Customer Portal still exists as the underlying mechanism for card management; Paylera wraps it in useBillingPortal() and renders its own outer chrome around the Stripe portal frame.
  • Webhook signature shape — Paylera’s signatures use HMAC-SHA-256 in the same t=<ts>,v1=<hex> envelope Stripe uses. Different secret, different header name, same verification algorithm.

What changes:

  • Pricing is declared in code (paylera.config.ts), not in the Stripe Dashboard. The CLI creates the corresponding Stripe Prices for you. You stop logging into the Dashboard to change a price.
  • Entitlements are first-class. You no longer maintain your own features table or user.plan === "pro" checks; you call useFeature(code) and the backend tells you the truth.
  • Metering is built in. Drop meter_event POSTs to Stripe; call track(featureCode, value) instead. Paylera’s counter is the source of truth.
  • Credit grants are built in. No more coupon_redeem hacks for one-off comp credits.
  • Provider-agnostic charging. The same Paylera subscription can be charged via Stripe (USD / EUR / GBP card) or Toss (Korean KRW card, Korean account transfer, Kakao Pay). The frontend doesn’t care which.

Concept mapping table

Stripe Billing conceptPaylera equivalentNotes
CustomerCustomer1:1 ID-preserving import. Email + name + metadata transfer verbatim.
PaymentMethod (pm_...)PaymentMethodTokens are dual-written during the 7-day cutover window (see Migrate guide).
Product (prod_...)ProductPaylera Product = “the thing”; Plans hang off it.
Price (price_...)Plan + Price[]Paylera bundles a recurring interval + currency + tier list under one Plan code.
Subscription (sub_...)SubscriptionSame lifecycle states. Trial / proration / cancellation semantics identical.
SubscriptionItem(implicit — declared on Plan)Paylera Plans declare their features; no per-item juggling.
InvoiceInvoiceSame statuses (draft, open, paid, uncollectible, void).
InvoiceItemInvoiceLine on InvoiceSame shape; renamed for clarity.
PaymentIntent (pi_...)PaymentPaylera abstracts over Stripe and Toss; the typed value is the same.
SetupIntent (seti_...)(covered by attach flow)POST /v1/attach handles signup + first-card capture in one call.
Checkout SessionPOST /v1/attach{ checkout_url }Same hosted-checkout flow; one POST instead of create-session-then-redirect.
Customer PortaluseBillingPortal(){ url }Paylera’s portal wraps Stripe’s. Same self-service capabilities.
Meter (meter_...)BillableMetricSame purpose; declared in paylera.config.ts.
Meter Event (meter_event)POST /v1/trackSame body shape conceptually; Paylera adds quota-aware ack.
Coupon / PromotionCodeCoupon / PromotionCodeSame.
TaxRateTaxRateSame; Paylera also wraps an automatic-tax computation engine.
Webhook EndpointWebhookEndpointSame. Different signature secret.
stripe-signature headerPaylera-Signature headerSame t=<ts>,v1=<hex> shape. Same HMAC-SHA-256 algorithm.
WebhookEventWebhookEventDifferent event-type names; same envelope (id, type, data, created).

Subscription lifecycle parity

Paylera’s subscription state machine is intentionally Stripe-compatible so handlers port without rewriting.

StateStripe BillingPayleraNotes
trialingyesyesSame semantics.
activeyesyesSame semantics.
past_dueyesyesDunning kicks in.
unpaidyesyesDunning exhausted; entitlements may be revoked per tenant config.
canceledyesyesTerminal; idempotent.
incompleteyesyesFirst-invoice payment failed.
incomplete_expiredyesyesincomplete aged out (default 23 h).
pausedyesyesManual pause; entitlements per tenant config.
pending_activationnonewAfter attach but before Stripe Checkout completes.

The one new state is pending_activation — Stripe doesn’t expose this because Stripe’s hosted Checkout creates the subscription after payment. Paylera creates the subscription before checkout completion so your application can show “subscription pending, complete checkout to activate” UI. Once the checkout webhook fires, the subscription transitions to trialing or active based on the plan’s trial config.

Side-by-side: signup flow

Before — Stripe Billing (PaymentIntent confirm):

// Backend — create Subscription + first invoice
const customer = await stripe.customers.create({ email });
const subscription = await stripe.subscriptions.create({
customer: customer.id,
items: [{ price: "price_1Pro" }],
payment_behavior: "default_incomplete",
expand: ["latest_invoice.payment_intent"],
});
const clientSecret = subscription.latest_invoice.payment_intent.client_secret;
return { clientSecret };
// Frontend — confirm the PaymentIntent on the page
import { useStripe, useElements, PaymentElement } from "@stripe/react-stripe-js";
function CheckoutForm({ clientSecret }: { clientSecret: string }) {
const stripe = useStripe();
const elements = useElements();
async function handleSubmit() {
const result = await stripe!.confirmPayment({
elements: elements!,
clientSecret,
confirmParams: { return_url: `${window.location.origin}/success` },
});
if (result.error) {
// show error
}
}
return (
<form onSubmit={handleSubmit}>
<PaymentElement />
<button type="submit">Subscribe</button>
</form>
);
}

After — Paylera (hosted-checkout attach):

// Frontend — one call, get a redirect URL, done
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,
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>
);
}

What disappeared:

  • No confirmPayment client logic — the hosted-checkout page (still hosted by Stripe under the hood) handles 3DS / SCA / wallet flows.
  • No client_secret round-trip — Paylera mints the checkout session server-side via POST /v1/attach.
  • No <Elements stripe={stripePromise}> provider — @paylera/react’s <PayleraProvider> is enough.

If you specifically need the embedded Stripe Elements experience (custom checkout page, no redirect), Paylera supports it via a different code path — POST /v1/attach with mode: "embedded" returns the same client_secret, and you can drop in @stripe/react-stripe-js on top. That’s a more involved port; the redirect flow is the recommended default.

Side-by-side: entitlement check

This is where Paylera saves you the most code, because most Stripe-Billing apps build this themselves.

Before — Stripe Billing (DIY entitlements):

// Backend — derive plan from active subscription
export async function getUserPlan(userId: string): Promise<"free" | "pro"> {
const user = await db.users.findById(userId);
if (!user.stripeCustomerId) return "free";
const subs = await stripe.subscriptions.list({
customer: user.stripeCustomerId,
status: "active",
limit: 1,
});
if (subs.data.length === 0) return "free";
const priceId = subs.data[0].items.data[0].price.id;
return priceId === "price_1Pro" ? "pro" : "free";
}
// Frontend — fetch + memo + invalidate on subscription change
const { data: plan } = useQuery({ queryKey: ["user-plan"], queryFn: fetchPlan });
const canExportCsv = plan === "pro";

After — Paylera (built-in entitlements):

import { useFeature } from "@paylera/react";
function ExportButton() {
const { allowed, isLoading } = useFeature("export_csv");
if (isLoading) return <Spinner />;
if (!allowed) return <UpgradePrompt />;
return <button onClick={handleExport}>Export CSV</button>;
}

The getUserPlan function — and the entire plan === "pro" pattern — goes away. Paylera computes the feature’s resolved value from the plan’s features map (declared in paylera.config.ts) and any active credit grants or per-customer overrides. The hook subscribes to that resolved value via the relay’s caching layer.

Metered features are the bigger payoff. With Stripe Billing alone you’d maintain your own counter table:

// Before — DIY metered quota
export async function recordApiCall(userId: string) {
const usage = await db.usage.upsert({
where: { userId_month: { userId, month: thisMonth() } },
create: { userId, month: thisMonth(), count: 1 },
update: { count: { increment: 1 } },
});
await stripe.subscriptionItems.createUsageRecord(stripeItemId, {
quantity: 1,
timestamp: "now",
});
if (usage.count > 100000) throw new QuotaExceededError();
}

With Paylera:

// After — one call; Paylera owns the counter + the quota gate
const { track } = useTrack();
await track("api_calls", 1, { dedupKey: requestId });
// Read back the remaining quota anywhere:
const { balance } = useFeature("api_calls");

The dedup_key makes the call idempotent — replay-safe under retry — without you maintaining a request-log table. See SDK relay protocol — POST /track.

Webhook signature parity

Both Stripe and Paylera use HMAC-SHA-256 in a t=<unix>,v1=<hex> envelope. The verifiers look almost identical.

Before — Stripe:

import express from "express";
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_KEY!);
app.post(
"/webhooks/stripe",
express.raw({ type: "application/json" }),
(req, res) => {
const sig = req.header("stripe-signature")!;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
req.body,
sig,
process.env.STRIPE_WEBHOOK_SECRET!,
);
} catch {
return res.status(400).send("invalid signature");
}
handleEvent(event);
res.status(204).end();
},
);

After — Paylera:

import express from "express";
import { Webhooks } from "@paylera/sdk";
app.post(
"/webhooks/paylera",
express.raw({ type: "application/json" }),
(req, res) => {
const sig = req.header("paylera-signature")!;
if (!Webhooks.verify(req.body, sig, process.env.PAYLERA_WEBHOOK_SECRET!)) {
return res.status(401).send("invalid signature");
}
const event = Webhooks.parse(req.body);
handleEvent(event);
res.status(204).end();
},
);

The differences:

  • Header: stripe-signaturepaylera-signature.
  • Secret env var: STRIPE_WEBHOOK_SECRETPAYLERA_WEBHOOK_SECRET.
  • Helper: stripe.webhooks.constructEvent (verify + parse) → Webhooks.verify + Webhooks.parse (split apart for clarity).

The HMAC body composition is the same: ${t}.${raw_body_bytes} — literal dot, raw bytes (do not parse + re-serialise the JSON). The algorithm is the same: HMAC-SHA-256 with the secret as key, hex output. Multi-v1 rotation is the same: deliveries are signed with both the old and new secrets during rotation; verifiers accept if any v1 matches.

See Webhook signatures for the full spec.

Event-type rename

Paylera’s event-type vocabulary is similar to Stripe’s but slightly flatter. Top hits:

Stripe eventPaylera eventNotes
customer.createdcustomer.createdIdentical.
customer.updatedcustomer.updatedIdentical.
customer.deletedcustomer.deletedIdentical.
customer.subscription.createdsubscription.createdFlatter namespace.
customer.subscription.updatedsubscription.updatedFlatter.
customer.subscription.deletedsubscription.canceledClearer verb.
customer.subscription.trial_will_endsubscription.trial_will_endSame content.
invoice.createdinvoice.createdIdentical.
invoice.finalizedinvoice.finalizedIdentical.
invoice.paidinvoice.paidIdentical.
invoice.payment_failedinvoice.payment_failedIdentical.
checkout.session.completedattach.completedRenamed to match the relay op.
payment_intent.succeededpayment.succeededProvider-agnostic name.
payment_intent.payment_failedpayment.failedProvider-agnostic name.

See Event catalog for the full list. The event data payload is shape-compatible with Stripe’s for shared event types, with extra fields added (e.g. paylera.feature_grants on subscription.created records the plan’s feature snapshot at activation time).

What Paylera adds beyond Stripe Billing

Three large-surface features that have no direct Stripe Billing equivalent, plus a handful of smaller ones.

Entitlements + metering

The whole entitlement layer (useFeature, useTrack, useEntitlements) is net-new. Stripe Billing has metering (via Meter + MeterEvent) but it’s a billing primitive, not a runtime gate — Stripe will charge you for the usage but won’t tell you whether the customer’s quota is exhausted.

Paylera’s POST /track does both: it records the usage event for billing purposes and decrements the live quota counter, so the next useFeature(code) read reflects the new balance immediately.

// Decrement quota AND charge for it, atomically.
await track("api_calls", 1);

Credit grants

Stripe’s only mechanism for “give this customer 500 free API calls” is a coupon-on-an-invoice-item dance. Paylera has first-class credit grants:

import { Paylera } from "@paylera/sdk";
const paylera = new Paylera({ apiToken: process.env.PAYLERA_API_TOKEN! });
await paylera.creditGrants.create({
customer_id: "cus_...",
feature_code: "api_calls",
amount: 500,
reason: "manual_comp",
expires_at: "2026-12-31T23:59:59Z",
});

The grant shows up on useFeature("api_calls") immediately with source: "credit_grant" in the result. It’s consumed before plan-grant quota, expires at the configured date, and rolls over per the plan’s rolloverMax / rolloverPercent policy.

Provider-agnostic charging

A Paylera subscription can be charged via Stripe (USD / EUR / GBP / …), Toss Payments (Korean KRW), or — in self-hosted deployments — future PayPal/Adyen/Mercado Pago adapters. The choice is per POST /v1/attach call:

// Stripe — same behaviour as Autumn / Stripe Billing
await attach({ plan_id, payment_provider: "stripe", ... });
// Toss — Korean customers, KRW pricing
await attach({ plan_id, payment_provider: "toss", ... });

The tenant has a default provider (set in the dashboard) so single-region merchants don’t have to specify it. Multi-region merchants pick at attach time based on customer locale.

The frontend doesn’t care: useFeature, useTrack, useEntitlements all work the same regardless of which provider charged the underlying invoice.

Smaller adds

  • useInvoices() — paginated invoice history hook, with no bespoke fetch wrapper required.
  • useBillingPortal() — single-call portal-URL minting, no custom backend route required.
  • CSRF + idempotency on every relay call — Paylera’s relay protocol bakes in the security primitives you’d otherwise wire by hand; see SDK relay protocol — CSRF.
  • OpenTelemetry-native — every SDK call emits a span and a metric with stable names; you don’t need to instrument the Stripe-call layer yourself. See SDK relay protocol — OpenTelemetry contract.

Things that don’t translate one-to-one

A short list of Stripe Billing features that need a rethink, not a rename:

  • Stripe Sigma queries. No direct equivalent. Paylera exports the same data into BigQuery / Snowflake via a per-tenant connector — see Data export when it ships. In the meantime, the REST API supports cursor-based paginated reads of all billing entities.
  • Stripe Issuing (cards). Out of scope — Paylera is a billing product, not a card-issuing one. Keep Issuing on Stripe; the two products coexist on the same underlying Stripe customer.
  • Stripe Connect (multi-party). Out of scope today; on the roadmap for 2026.
  • Stripe-Radar fraud rules. Stripe still runs Radar under the hood when payment_provider: "stripe". There’s no Paylera equivalent rule engine — you configure Radar in the Stripe Dashboard as before.
  • Stripe Tax automatic tax (different SKU). Paylera’s automatic-tax engine is built in and free; Stripe Tax is a paid Stripe add-on. If you were paying for Stripe Tax, you can stop after the migration.
  • Stripe Adaptive Acceptance / Network Tokens. Pass-through — Paylera asks Stripe to use them; nothing for you to configure.

Plan catalog port

Export your Stripe Prices, then declare them in paylera.config.ts:

Terminal window
# 1. Dump current Stripe state for reference.
stripe prices list --limit 100 --expand "data.product" > prices.json
# 2. Hand-port to paylera.config.ts (mid-sized catalog is a 1-3h task).
# 3. Deploy to test tenant.
npx @paylera/cli deploy --env test
# 4. Diff against test tenant; verify nothing surprising.
npx @paylera/cli diff --env test
# 5. Deploy to live tenant during the cutover window.
npx @paylera/cli deploy --env live

A minimal example:

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" },
],
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 },
},
},
{
code: "pro",
name: "Pro",
prices: [
{ amount: 2900, currency: "USD", interval: "month" },
{ amount: 29000, currency: "USD", interval: "year" },
],
features: {
api_calls: { includedUsage: 100000, usageCycle: "calendar_month" },
seats: { value: 10 },
premium: { value: true },
},
},
{
code: "enterprise",
name: "Enterprise",
prices: [{
amount: 0, // negotiated; sales-led
currency: "USD",
interval: "year",
tiers: [
{ upTo: 1000000, unitAmount: 8 }, // $0.008 / call up to 1M
{ upTo: "inf", unitAmount: 5 }, // $0.005 / call thereafter
],
}],
features: {
api_calls: { /* unlimited via FeatureDef */ },
seats: { value: 100 },
premium: { value: true },
},
},
],
});

The CLI calls PUT /v1/admin/{plans,features,products}/{code} and mirrors the catalog into Stripe automatically — you don’t manage Stripe Prices directly any more.

Verification checklist

  1. Customer import complete. Spot-check 10 random customers: paylera.customers.retrieve(stripeCustomerId) returns the same email, name, and metadata as the source-of-truth Stripe object.
  2. Active subscriptions imported. Counts match: stripe subscriptions list --status active | wc -l == paylera.subscriptions.list({ status: "active" }) count.
  3. Webhook handler accepts both signatures during cutover. Run both verifiers in parallel for the 24 h cutover; alert on mismatch.
  4. Entitlement reads work. A test customer on the Pro plan sees useFeature("export_csv").allowed === true and useFeature("api_calls").balance matches their Stripe metering counter at cutover.
  5. paylera deploy is idempotent. A second deploy --env live with no config changes prints “no drift” and exits 0.
  6. OTel dashboards green. paylera.requests.duration p99 < 300 ms, paylera.requests.total shape matches your usual traffic curve.

Where to next