Skip to content

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 same code: "api_call" in Paylera’s paylera.config.ts.
  • Event ingestion is conceptually identical — one POST per event, with a transaction_id for 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 Session flow against Stripe directly; POST /v1/attach returns a checkout_url.
  • Catalog is code, not API. Lago’s catalog is created via POST /api/v1/plans etc. from your provisioning scripts. Paylera’s is a declarative paylera.config.ts applied via CLI — diff-then-apply semantics like Terraform.
  • Entitlement reads ship out of the box. useFeature(code) and useEntitlements() 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/track with inline dedup on Idempotency-Key. (If you genuinely need 10k+ events/sec sustained, contact us — there’s an events.bulk endpoint.)

Concept mapping table

Lago conceptPaylera equivalentNotes
Customer (external_id)Customer (id-preserving import)1:1. Lago external_id becomes Paylera id if you pass it on import.
OrganizationTenant1: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 grantPaylera bundles charges into the Plan’s prices + features structure.
AddOnPlan.addOns[]1:1; declared on the Plan.
CouponCoupon1:1.
SubscriptionSubscription1:1. Same lifecycle states.
Event (event ingestion)POST /v1/track (or POST /api/paylera/track)Same purpose; HTTPS not Kafka.
Wallet (prepaid credit)Wallet1:1.
WalletTransactionWalletTransaction1:1.
InvoiceInvoice1:1. Statuses match.
Credit NoteCreditNote1:1.
TaxTaxRate1:1.
Webhook (outbound)WebhookEndpoint1:1. HMAC verification — different format, see below.
Lago HMAC X-Lago-SignaturePaylera-Signature (different format)Lago uses bare hex; Paylera uses t=<ts>,v1=<hex>. See Webhook signatures.
Lago billable-metric aggregation_typeSame aggregation taxonomycount_agg, sum_agg, max_agg, unique_count_agg, weighted_sum_agg, latest_agg.
Lago events_aggregation_propertySameProperty 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.

AggregationLagoPayleraMeaning
Event countcount_aggcount_aggNumber of events in the period.
Sumsum_aggsum_aggSum of properties.<prop> across events.
Maxmax_aggmax_aggMax of properties.<prop>.
Unique countunique_count_aggunique_count_aggDistinct values of properties.<prop>.
Weighted sumweighted_sum_aggweighted_sum_aggTime-weighted (for gauge-style metrics).
Latestlatest_agglatest_aggMost 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 fieldPaylera fieldNotes
transaction_iddedup_keyBecomes the Idempotency-Key header server-side. UUIDv7 auto-generated if omitted.
external_customer_idcustomer_id (relay injects)Frontend never supplies the customer id directly; the relay derives it from identify().
code (billable metric)feature_codeSame 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.
propertiespropertiesIdentical; 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

Terminal window
# 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.json
curl -H "Authorization: Bearer $LAGO_API_KEY" \
https://api.getlago.com/api/v1/billable_metrics?per_page=100 > lago-metrics.json
curl -H "Authorization: Bearer $LAGO_API_KEY" \
https://api.getlago.com/api/v1/add_ons?per_page=100 > lago-addons.json

Step 2: hand-port to paylera.config.ts

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

Terminal window
npx @paylera/cli diff --env test

This 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

Terminal window
npx @paylera/cli deploy --env test
# verify in test
npx @paylera/cli deploy --env live

Now 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 checkoutPOST /v1/attach returns 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_provider field on attach.
  • Built-in entitlement readsuseFeature / useEntitlements hooks replace any DIY “is this user on Pro?” table.
  • Built-in credit grantsPOST /v1/customers/{id}/credit-grants for one-off comp. The grant shows up live on useFeature reads with source: "credit_grant".
  • Customer portaluseBillingPortal() 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 prices interval and the feature’s usageCycle. There’s no global pay_in_advance flag — 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 metadata and read in your handler.
  • Lago’s Kafka-based event ingestion at extreme throughput. Paylera takes events over HTTPS with POST /v1/track/bulk for 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/invoices with auto_charge: false for the human-review flow.
  • Lago wallet’s paid_credits vs granted_credits split. Paylera unifies these as a single Wallet with per-transaction kind (“purchased” / “granted”). The numerics are the same; the schema is flatter.

Verification checklist

  1. Catalog parity. npx @paylera/cli diff --env live returns zero drift. Spot-check 3 plans and 5 billable metrics manually.
  2. Customer count parity. Lago’s GET /customers total matches paylera.customers.list() total.
  3. Active subscription parity. Counts match per status (active, trialing, past_due).
  4. Event ingest sanity. Track 100 synthetic events on a test subscription via POST /v1/track; useFeature(code).usage reports 100 within seconds. (Lago is eventually-consistent on aggregation; Paylera is strongly-consistent.)
  5. Webhook handler swap. X-Lago-Signature verifier removed; Paylera-Signature verifier in place. Run both for 24 h during cutover to catch any signature-format mismatches.
  6. Wallet balance parity. Lago wallet balance_cents matches Paylera wallet.balance for 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