Migrating from Lago
Lago is an open-source metering and billing backend. If you run a Lago instance (cloud or self-hosted) you’ve already done most of the conceptual work this recipe describes — billable metrics, plan/feature catalogs, event ingestion. The port to Paylera is mostly an API-shape translation: most Lago primitives have a one-to-one Paylera equivalent, the names are nearly identical, and where Paylera diverges, it does so for explicit reasons (built-in hosted checkout, provider-agnostic charging, code-as-source-of-truth catalog).
TL;DR — what changes, what doesn’t
What stays the same:
- Customer IDs transfer 1:1 — Paylera’s import API accepts your Lago external IDs verbatim, so foreign keys in your app keep working.
- Billable metric codes transfer verbatim — the same
code: "api_call"you used in Lago is the samecode: "api_call"in Paylera’spaylera.config.ts. - Event ingestion is conceptually identical — one POST per event,
with a
transaction_idfor dedup. Different endpoint, same idea. - Plan + feature semantics — flat-rate, per-unit, volume-tier, graduated, and package pricing all translate one-to-one.
What changes:
- Charging is built in. Lago is bring-your-own-payment-provider:
you sync invoices to Stripe / GoCardless / Adyen yourself. Paylera
charges natively against Stripe and Toss; the typed client picks the
provider per
attach()call. - Hosted checkout is built in. No need to write a
Checkout Sessionflow against Stripe directly;POST /v1/attachreturns acheckout_url. - Catalog is code, not API. Lago’s catalog is created via
POST /api/v1/plansetc. from your provisioning scripts. Paylera’s is a declarativepaylera.config.tsapplied via CLI — diff-then-apply semantics like Terraform. - Entitlement reads ship out of the box.
useFeature(code)anduseEntitlements()are part of@paylera/react; you don’t build this yourself on top of Lago’s API. - Event ingestion is a single HTTPS call, not Kafka. Lago’s
high-throughput pattern is to publish to a Kafka topic that Lago
consumes; Paylera takes events directly via
POST /v1/trackwith inline dedup onIdempotency-Key. (If you genuinely need 10k+ events/sec sustained, contact us — there’s anevents.bulkendpoint.)
Concept mapping table
| Lago concept | Paylera equivalent | Notes |
|---|---|---|
Customer (external_id) | Customer (id-preserving import) | 1:1. Lago external_id becomes Paylera id if you pass it on import. |
Organization | Tenant | 1:1. One Lago organisation per Paylera tenant. |
BillableMetric (code) | BillableMetric (code) | Same code-keyed identity. Aggregation type maps directly. |
Plan (code) | Plan (code) | 1:1. |
Charge (on a Plan) | Plan.prices[].tiers[] + Feature grant | Paylera bundles charges into the Plan’s prices + features structure. |
AddOn | Plan.addOns[] | 1:1; declared on the Plan. |
Coupon | Coupon | 1:1. |
Subscription | Subscription | 1:1. Same lifecycle states. |
Event (event ingestion) | POST /v1/track (or POST /api/paylera/track) | Same purpose; HTTPS not Kafka. |
Wallet (prepaid credit) | Wallet | 1:1. |
WalletTransaction | WalletTransaction | 1:1. |
Invoice | Invoice | 1:1. Statuses match. |
Credit Note | CreditNote | 1:1. |
Tax | TaxRate | 1:1. |
Webhook (outbound) | WebhookEndpoint | 1:1. HMAC verification — different format, see below. |
Lago HMAC X-Lago-Signature | Paylera-Signature (different format) | Lago uses bare hex; Paylera uses t=<ts>,v1=<hex>. See Webhook signatures. |
Lago billable-metric aggregation_type | Same aggregation taxonomy | count_agg, sum_agg, max_agg, unique_count_agg, weighted_sum_agg, latest_agg. |
Lago events_aggregation_property | Same | Property name on the event payload to aggregate over. |
Billable-metric aggregation parity
The Lago aggregation taxonomy ports verbatim. Same names, same
semantics — Paylera’s billable_metric.aggregationType enum is
intentionally the same set so a Lago-to-Paylera config diff is
mechanical.
| Aggregation | Lago | Paylera | Meaning |
|---|---|---|---|
| Event count | count_agg | count_agg | Number of events in the period. |
| Sum | sum_agg | sum_agg | Sum of properties.<prop> across events. |
| Max | max_agg | max_agg | Max of properties.<prop>. |
| Unique count | unique_count_agg | unique_count_agg | Distinct values of properties.<prop>. |
| Weighted sum | weighted_sum_agg | weighted_sum_agg | Time-weighted (for gauge-style metrics). |
| Latest | latest_agg | latest_agg | Most recent value (gauge snapshot). |
So a Lago metric declared as:
{ "name": "API Calls", "code": "api_call", "aggregation_type": "count_agg", "field_name": null}becomes the Paylera config entry:
features: [ { code: "api_calls", name: "API calls", kind: "metered", billableMetric: "api_call", // 1:1 with Lago's metric code },],with the billable metric itself declared at the top of the config:
billableMetrics: [ { code: "api_call", aggregationType: "count_agg", }, { code: "data_processed_gb", aggregationType: "sum_agg", fieldName: "gigabytes", // matches Lago's `field_name` },],Side-by-side: event ingestion
The single most-trafficked code path in a Lago integration is event
ingestion. The Paylera equivalent is POST /v1/track — same shape,
same idempotency semantics.
Before — Lago:
import { Client } from "lago-javascript-client";
const lago = new Client({ apiKey: process.env.LAGO_API_KEY! });
await lago.events.createEvent({ event: { transaction_id: requestId, // dedup external_customer_id: customer.externalId, code: "api_call", timestamp: new Date().toISOString(), properties: { bytes_processed: payloadBytes, }, },});After — Paylera (typed-client variant for backend code):
import { Paylera } from "@paylera/sdk";
const paylera = new Paylera({ apiToken: process.env.PAYLERA_API_TOKEN! });
await paylera.track.record({ customer_id: customer.payleraId, feature_code: "api_calls", value: 1, dedup_key: requestId, // → Idempotency-Key header properties: { bytes_processed: payloadBytes, },});After — Paylera (frontend variant, via the relay):
import { useTrack } from "@paylera/react";
function ApiButton() { const { track } = useTrack(); return ( <button onClick={() => track("api_calls", 1, { dedupKey: requestId })}> Run job </button> );}Per-field rename:
| Lago field | Paylera field | Notes |
|---|---|---|
transaction_id | dedup_key | Becomes the Idempotency-Key header server-side. UUIDv7 auto-generated if omitted. |
external_customer_id | customer_id (relay injects) | Frontend never supplies the customer id directly; the relay derives it from identify(). |
code (billable metric) | feature_code | Same identity; renamed because Paylera unifies the “feature” + “billable metric” concepts at the feature layer. |
timestamp | (implicit — now) | Paylera stamps server-side. Backdated ingestion via events.bulk only. |
properties | properties | Identical; same aggregation rules. |
Throughput note. Lago expects you to publish events to its Kafka
topic for high throughput. Paylera takes events over plain HTTPS and
horizontally scales the ingest service per tenant. The break-even
where Kafka was meaningfully cheaper than HTTPS is around 5k events/sec
sustained per tenant — under that, one POST /v1/track per event is
fine. Over that, batch via POST /v1/track/bulk (up to 1000 events
per request) — see Report metered usage for
the batched-write pattern.
Side-by-side: customer signup + subscription
This is where Paylera saves you the most code, because Lago hands the charging back to you.
Before — Lago + your own Stripe integration:
// 1. Create Lago customer.await lago.customers.createCustomer({ customer: { external_id: userId, email, billing_configuration: { payment_provider: "stripe", provider_customer_id: stripeCustomer.id, }, },});
// 2. Create Stripe customer (separately).const stripeCustomer = await stripe.customers.create({ email });
// 3. Create Stripe subscription + return client_secret.const subscription = await stripe.subscriptions.create({ customer: stripeCustomer.id, items: [{ price: stripePriceId }], payment_behavior: "default_incomplete", expand: ["latest_invoice.payment_intent"],});
// 4. Create Lago subscription (linked by external_id).await lago.subscriptions.createSubscription({ subscription: { external_customer_id: userId, plan_code: "pro", external_id: subscription.id, },});
// 5. Return Stripe client_secret to frontend for confirmPayment().return { clientSecret: subscription.latest_invoice.payment_intent.client_secret };// 6. Frontend confirmPayment against Stripe Elements...import { useStripe, useElements } from "@stripe/react-stripe-js";// ...After — Paylera (one call, hosted checkout):
import { useAttach } from "@paylera/react";
function PricingButton({ planCode }: { planCode: string }) { const { attach } = useAttach();
async function handleClick() { const { checkout_url } = await attach({ plan_id: planCode, 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}>Subscribe</button>;}The five Lago-side calls and the Stripe Elements UI all collapse into
one frontend attach() call. Paylera owns the orchestration: it
creates the customer if needed, opens the subscription in
pending_activation, opens the Stripe Checkout session, and forwards
to it. The webhook fires attach.completed when the customer pays;
your handler promotes the subscription to active (or trusts Paylera
to do it — same shape either way).
This is where most teams realise the biggest line-of-code reduction post-migration; in our experience Lago integrations have ~200 LoC of charging glue that simply doesn’t exist on Paylera.
Side-by-side: entitlement read
Lago does not ship an entitlement-read API — the assumption is that
you derive entitlements yourself from “does this customer have an
active subscription on plan X”. You build a getUserEntitlements(userId)
function on top.
Paylera ships entitlement reads natively:
import { useFeature, useEntitlements } from "@paylera/react";
// Single feature, gated rendering.function ExportButton() { const { allowed, balance } = useFeature("export_csv"); if (!allowed) return <UpgradePrompt />; return <button>Export CSV</button>;}
// Bulk read for a settings page.function FeatureMatrix() { const { features } = useEntitlements(); return ( <ul> {features?.map((f) => ( <li key={f.feature_code}> {f.feature_code}: {String(f.value ?? f.balance ?? f.unlimited)} </li> ))} </ul> );}If you previously maintained a user_features table in your app,
populated from Lago’s plan API on subscription change, that table goes
away. Paylera’s relay does the lookup live, and the TanStack Query
cache means it’s effectively free per render.
Webhook signature
Both Lago and Paylera sign outbound webhooks with HMAC-SHA-256 — the
formats differ slightly. Lago: bare hex in X-Lago-Signature. Paylera:
t=<unix>,v1=<hex> envelope in Paylera-Signature. The envelope adds
a timestamp for replay protection, which Lago does not have inline (you
have to compare received_at to the event timestamp yourself).
Before — Lago:
import crypto from "node:crypto";
app.post("/webhooks/lago", express.raw({ type: "application/json" }), (req, res) => { const sig = req.header("x-lago-signature")!; const expected = crypto .createHmac("sha256", process.env.LAGO_WEBHOOK_SECRET!) .update(req.body) .digest("hex"); if (!crypto.timingSafeEqual(Buffer.from(sig, "hex"), Buffer.from(expected, "hex"))) { return res.status(401).end(); } const event = JSON.parse(req.body.toString()); handleEvent(event); res.status(204).end();});After — Paylera:
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).end(); } const event = Webhooks.parse(req.body); handleEvent(event); res.status(204).end();});Webhooks.verify handles the timestamp-comparison + multi-v1
rotation logic for you. See Webhook signatures
for the full spec, including the 5-minute default replay window and
multi-secret rotation behaviour.
Plan/feature catalog migration
Lago’s catalog lives in its database, managed via API. Paylera’s lives
in paylera.config.ts, applied via CLI. The migration is a dump-and-port:
Step 1: dump current Lago state
# Export plans, billable metrics, and add-ons via Lago API.curl -H "Authorization: Bearer $LAGO_API_KEY" \ https://api.getlago.com/api/v1/plans?per_page=100 > lago-plans.jsoncurl -H "Authorization: Bearer $LAGO_API_KEY" \ https://api.getlago.com/api/v1/billable_metrics?per_page=100 > lago-metrics.jsoncurl -H "Authorization: Bearer $LAGO_API_KEY" \ https://api.getlago.com/api/v1/add_ons?per_page=100 > lago-addons.jsonStep 2: hand-port to paylera.config.ts
import { defineConfig } from "@paylera/cli";
export default defineConfig({ apiVersion: "2026-05-01",
billableMetrics: [ { code: "api_call", aggregationType: "count_agg" }, { code: "compute_sec", aggregationType: "sum_agg", fieldName: "seconds" }, { code: "storage_gb", aggregationType: "latest_agg", fieldName: "gigabytes" }, ],
features: [ { code: "api_calls", kind: "metered", billableMetric: "api_call" }, { code: "compute_sec", kind: "metered", billableMetric: "compute_sec" }, { code: "storage_gb", kind: "metered", billableMetric: "storage_gb" }, { code: "premium", kind: "boolean" }, { code: "seats", kind: "numeric" }, ],
plans: [ { code: "starter", name: "Starter", prices: [{ amount: 1900, currency: "USD", interval: "month" }], features: { api_calls: { includedUsage: 10000, usageCycle: "calendar_month" }, compute_sec: { includedUsage: 3600, usageCycle: "calendar_month" }, storage_gb: { includedUsage: 10 }, premium: { value: false }, seats: { value: 3 }, }, }, { code: "growth", name: "Growth", prices: [{ amount: 9900, currency: "USD", interval: "month", tiers: [ { upTo: 100000, unitAmount: 0 }, // included in flat fee { upTo: 1000000, unitAmount: 2 }, // $0.002 per call { upTo: "inf", unitAmount: 1 }, // $0.001 per call ], }], features: { api_calls: { includedUsage: 100000, usageCycle: "calendar_month" }, compute_sec: { includedUsage: 36000, usageCycle: "calendar_month" }, storage_gb: { includedUsage: 100 }, premium: { value: true }, seats: { value: 25 }, }, }, ],});Step 3: dry-run
npx @paylera/cli diff --env testThis prints a diff between paylera.config.ts and the test tenant’s
current state. Inspect carefully — any “delete plan” lines are blocking
errors and require explicit --allow-destructive to apply.
Step 4: apply
npx @paylera/cli deploy --env test# verify in testnpx @paylera/cli deploy --env liveNow your catalog is in Paylera. Re-run diff in CI on every push to
catch drift.
Customer + subscription import
Once the catalog is in place, import customers and active subscriptions. Pseudocode:
import { Paylera } from "@paylera/sdk";import { Client as Lago } from "lago-javascript-client";
const paylera = new Paylera({ apiToken: process.env.PAYLERA_API_TOKEN! });const lago = new Lago({ apiKey: process.env.LAGO_API_KEY! });
// Page through Lago customers.for await (const c of lagoCustomers()) { // Import customer (idempotent on external_id). await paylera.customers.import({ id: c.external_id, // preserve foreign key email: c.email, name: c.name, metadata: { lago_id: c.lago_id }, });
// Import each active subscription on this customer. for await (const s of lagoSubscriptions(c.external_id)) { await paylera.subscriptions.import({ id: s.external_id, customer_id: c.external_id, plan_code: s.plan_code, status: s.status, // active / trialing / canceled current_period_start: s.current_billing_period_started_at, current_period_end: s.current_billing_period_ending_at, trial_end: s.trial_ended_at, }); }}The import variants preserve IDs and skip the usual side-effects
(no welcome email, no first-invoice creation). See Migrate from
another billing system for the full sequencing and
reconciliation plan; the snippet above is the SDK call shape.
What Paylera adds beyond Lago
A short list of net-new capabilities you get for free:
- Hosted checkout —
POST /v1/attachreturns a Stripe-hosted (or Toss-hosted) checkout URL. No payment-element wiring on your side. - Provider-agnostic charging — the same Paylera subscription
can be charged in USD via Stripe or KRW via Toss. Per-call selection
with the
payment_providerfield onattach. - Built-in entitlement reads —
useFeature/useEntitlementshooks replace any DIY “is this user on Pro?” table. - Built-in credit grants —
POST /v1/customers/{id}/credit-grantsfor one-off comp. The grant shows up live onuseFeaturereads withsource: "credit_grant". - Customer portal —
useBillingPortal()returns a one-time URL to the self-service portal (manage card, view invoices, cancel). No bespoke portal page to build. - Idempotency + CSRF baked into the relay — see SDK relay protocol.
- OpenTelemetry built in — every SDK emits stable spans and metrics; no manual instrumentation.
Things that don’t translate one-to-one
- Lago’s pay-in-advance / pay-in-arrears toggle. Paylera handles
this implicitly via the plan’s
pricesinterval and the feature’susageCycle. There’s no globalpay_in_advanceflag — express it per-feature in the config. - Lago’s “lifetime usage” field for non-resetting counters. Paylera
models this as
usageCycle: "never"on a metered feature. - Lago’s grace-period after dunning. Paylera’s dunning policy is
tenant-level (set in the dashboard), not per-subscription. If you
had per-customer grace overrides, port them to per-customer
metadataand read in your handler. - Lago’s Kafka-based event ingestion at extreme throughput. Paylera
takes events over HTTPS with
POST /v1/track/bulkfor batching. If you needed Lago’s Kafka throughput specifically, contact us for the high-throughput ingest tier. - Lago’s “manual invoice creation” workflow. Paylera generates
invoices automatically from subscriptions; one-off invoices use
POST /v1/invoiceswithauto_charge: falsefor the human-review flow. - Lago wallet’s
paid_creditsvsgranted_creditssplit. Paylera unifies these as a singleWalletwith per-transactionkind(“purchased” / “granted”). The numerics are the same; the schema is flatter.
Verification checklist
- Catalog parity.
npx @paylera/cli diff --env livereturns zero drift. Spot-check 3 plans and 5 billable metrics manually. - Customer count parity. Lago’s
GET /customerstotal matchespaylera.customers.list()total. - Active subscription parity. Counts match per status (
active,trialing,past_due). - Event ingest sanity. Track 100 synthetic events on a test
subscription via
POST /v1/track;useFeature(code).usagereports100within seconds. (Lago is eventually-consistent on aggregation; Paylera is strongly-consistent.) - Webhook handler swap.
X-Lago-Signatureverifier removed;Paylera-Signatureverifier in place. Run both for 24 h during cutover to catch any signature-format mismatches. - Wallet balance parity. Lago wallet
balance_centsmatches Paylerawallet.balancefor the customers you imported.
Cutover sequence
A typical Lago-to-Paylera cutover runs over 5–7 days:
day -7 Write paylera.config.ts. Run `deploy --env test`. End-to-end test the attach + track + check flow in the test tenant.day -3 Dry-run customer + subscription import against test tenant. Reconcile totals.day -1 Final Lago snapshot. Lock writes to Lago (read-only mode).day 0 Cutover window: 1. Run customer + subscription import against live tenant. 2. Flip event-ingest writers from Lago to Paylera (POST /v1/track instead of POST /api/v1/events). 3. Flip frontend from /api/lago to /api/paylera relay. 4. Verify dashboards: ingest rate normal, entitlement reads returning expected values.day +3 Reconcile: any Lago invoice issued in the cutover window replays from the Lago-side audit log; manual invoice in Paylera as needed.day +7 Decommission Lago. Archive the DB dump.The relay is provider-agnostic on the frontend side, so you can dual-mount
/api/lago and /api/paylera simultaneously during cutover with cohort-based
flag flipping (newest signups go to Paylera, legacy stays on Lago) — this
de-risks the ingest swap.
Where to next
- Available SDKs — Paylera-flavoured SDK catalog.
- SDK relay protocol — wire-format contract every
backend SDK speaks. Source of truth for
@paylera/server. - Migrate from another billing system — the broader customer-and-subscription-import plan. Read this alongside the current recipe for a real migration.
- Report metered usage —
POST /v1/trackpatterns, includingtrack/bulkfor batched writes. - Webhook signatures — full HMAC verification spec.
- Node.js / TypeScript SDK — typed client surface for backend ingest workers.