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/attachinstead ofPOST /v1/checkout/sessions, but it returns the samehttps://checkout.stripe.com/c/pay/cs_...URL whenpayment_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
featurestable oruser.plan === "pro"checks; you calluseFeature(code)and the backend tells you the truth. - Metering is built in. Drop
meter_eventPOSTs to Stripe; calltrack(featureCode, value)instead. Paylera’s counter is the source of truth. - Credit grants are built in. No more
coupon_redeemhacks 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 concept | Paylera equivalent | Notes |
|---|---|---|
Customer | Customer | 1:1 ID-preserving import. Email + name + metadata transfer verbatim. |
PaymentMethod (pm_...) | PaymentMethod | Tokens are dual-written during the 7-day cutover window (see Migrate guide). |
Product (prod_...) | Product | Paylera 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_...) | Subscription | Same lifecycle states. Trial / proration / cancellation semantics identical. |
SubscriptionItem | (implicit — declared on Plan) | Paylera Plans declare their features; no per-item juggling. |
Invoice | Invoice | Same statuses (draft, open, paid, uncollectible, void). |
InvoiceItem | InvoiceLine on Invoice | Same shape; renamed for clarity. |
PaymentIntent (pi_...) | Payment | Paylera 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 Session | POST /v1/attach → { checkout_url } | Same hosted-checkout flow; one POST instead of create-session-then-redirect. |
Customer Portal | useBillingPortal() → { url } | Paylera’s portal wraps Stripe’s. Same self-service capabilities. |
Meter (meter_...) | BillableMetric | Same purpose; declared in paylera.config.ts. |
Meter Event (meter_event) | POST /v1/track | Same body shape conceptually; Paylera adds quota-aware ack. |
Coupon / PromotionCode | Coupon / PromotionCode | Same. |
TaxRate | TaxRate | Same; Paylera also wraps an automatic-tax computation engine. |
Webhook Endpoint | WebhookEndpoint | Same. Different signature secret. |
stripe-signature header | Paylera-Signature header | Same t=<ts>,v1=<hex> shape. Same HMAC-SHA-256 algorithm. |
WebhookEvent | WebhookEvent | Different 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.
| State | Stripe Billing | Paylera | Notes |
|---|---|---|---|
trialing | yes | yes | Same semantics. |
active | yes | yes | Same semantics. |
past_due | yes | yes | Dunning kicks in. |
unpaid | yes | yes | Dunning exhausted; entitlements may be revoked per tenant config. |
canceled | yes | yes | Terminal; idempotent. |
incomplete | yes | yes | First-invoice payment failed. |
incomplete_expired | yes | yes | incomplete aged out (default 23 h). |
paused | yes | yes | Manual pause; entitlements per tenant config. |
pending_activation | no | new | After 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 invoiceconst 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 pageimport { 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, doneimport { 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
confirmPaymentclient logic — the hosted-checkout page (still hosted by Stripe under the hood) handles 3DS / SCA / wallet flows. - No
client_secretround-trip — Paylera mints the checkout session server-side viaPOST /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 subscriptionexport 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 changeconst { 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 quotaexport 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 gateconst { 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-signature→paylera-signature. - Secret env var:
STRIPE_WEBHOOK_SECRET→PAYLERA_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 event | Paylera event | Notes |
|---|---|---|
customer.created | customer.created | Identical. |
customer.updated | customer.updated | Identical. |
customer.deleted | customer.deleted | Identical. |
customer.subscription.created | subscription.created | Flatter namespace. |
customer.subscription.updated | subscription.updated | Flatter. |
customer.subscription.deleted | subscription.canceled | Clearer verb. |
customer.subscription.trial_will_end | subscription.trial_will_end | Same content. |
invoice.created | invoice.created | Identical. |
invoice.finalized | invoice.finalized | Identical. |
invoice.paid | invoice.paid | Identical. |
invoice.payment_failed | invoice.payment_failed | Identical. |
checkout.session.completed | attach.completed | Renamed to match the relay op. |
payment_intent.succeeded | payment.succeeded | Provider-agnostic name. |
payment_intent.payment_failed | payment.failed | Provider-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 Billingawait attach({ plan_id, payment_provider: "stripe", ... });
// Toss — Korean customers, KRW pricingawait 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:
# 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 liveA minimal example:
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
- 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. - Active subscriptions imported. Counts match:
stripe subscriptions list --status active | wc -l==paylera.subscriptions.list({ status: "active" })count. - Webhook handler accepts both signatures during cutover. Run both verifiers in parallel for the 24 h cutover; alert on mismatch.
- Entitlement reads work. A test customer on the Pro plan sees
useFeature("export_csv").allowed === trueanduseFeature("api_calls").balancematches their Stripe metering counter at cutover. paylera deployis idempotent. A seconddeploy --env livewith no config changes prints “no drift” and exits 0.- OTel dashboards green.
paylera.requests.durationp99 < 300 ms,paylera.requests.totalshape matches your usual traffic curve.
Where to next
- Migrate from another billing system — the customer-and-subscription-level import plan that this SDK recipe complements. The two are read together for a real migration.
- SDK relay protocol — wire-format contract every
backend SDK speaks. Source of truth for
@paylera/server. - Webhook signatures — full HMAC verification spec.
- Event catalog — every event type Paylera fires.
- Node.js / TypeScript SDK — typed client surface for backend cron jobs and webhook handlers.
- Available SDKs — full SDK catalog.