Payment Orchestrator
Subscription
Pricing & Portal SDK

Pricing & Portal SDK

Sell subscriptions from your own website. Two building blocks make it work:

  1. Public Catalog API — a read-only, browser-safe API that serves your plans, products and branding with a publishable key. Render your pricing table from live data; change a price in one place and your site follows.
  2. Portal JS SDK — a single script from cdn.lunixi.com that takes a visitor from "pick a plan" to "card saved, subscription active". Use the ready-made portal UI, go fully headless against your own components, or mix both.

No redirect to an external checkout. Card data is collected in the SDK's secure form and never touches your server.

Amounts are minor-unit integers (kuruş): "price": 49900 = ₺499.00.

How the pieces fit

Anonymous visitor and identified customerArchitecture: an anonymous visitor reaches the public catalog endpoint from the browser with a publishable key; for an identified customer the session is opened on your server with a secret key. Both feed the same pricing table.Anonymous visitorbrowser · publishable keyYour backendsecret key · server-to-serverGET /api/public/v1/subscription/*POST /subscriptions/portal/sdk-sessionPricing tableLEGENDBROWSERENDPOINTSHARED OUTPUT
The secret key never reaches the browser. The anonymous lane only reads the public catalog with a publishable key; customer-specific pricing and the session go through your server.
  • Before sign-up the visitor only needs prices: the catalog layer, keyed by your publishable key.
  • At subscribe time the visitor becomes a customer: your backend bootstraps an SDK session for that customer, and the browser SDK finishes the job — or your backend creates the subscription itself via the Merchant API.

Layer 1 — Public Catalog API

Read-only endpoints designed to be called directly from the browser:

MethodPathReturns
GET/api/public/v1/subscription/plansActive plans (price, currency, trial, billing cadence, display features)
GET/api/public/v1/subscription/productsActive products
GET/api/public/v1/subscription/brandingLogo, brand colors, terms / privacy URLs

Authentication: publishable key + Origin allow-list

Catalog calls authenticate with your publishable key (mk_live_pk_…) in the Authorization header. It is safe to embed in client-side code — it can only read the public catalog, never move money or expose customer data.

Two switches gate the whole surface, both managed from the merchant panel (Portal → SDK settings):

  • Origin allow-list — add every origin that will call the API (e.g. https://www.example.com). Requests from unlisted origins are rejected.
  • SDK enabled — the master toggle for the embedded surface.
⚠️

The publishable key is public by design; the Origin allow-list is what stops other sites from wearing your catalog. Keep the list tight — add exactly the origins you serve your pricing page from. Your Ed25519 private key — and the access token you mint from it (at_live_…) — must never appear in the browser.

Request

curl "https://api-gateway.lunixi.com/api/public/v1/subscription/plans?locale=en" \
  -H "Authorization: Bearer mk_live_pk_xxxxxxxxxxxx" \
  -H "Origin: https://www.example.com"

The locale parameter returns catalog translations — send locale=tr and localized names and descriptions come back where you defined them.

Response

Plans come back in a data array, with nextPageToken for cursor pagination:

{
  "success": true,
  "data": [
    {
      "id": "pln_9f3a1c",
      "productId": "prd_4b2e88",
      "code": "pro-monthly",
      "name": "Pro Monthly",
      "description": "For growing teams",
      "price": 49900,
      "currency": "TRY",
      "trialPeriodDays": 7,
      "billing": { "frequency": "MONTHLY", "interval": 1 }
    }
  ],
  "nextPageToken": ""
}

Layer 2 — Portal JS SDK

One script, four modes:

<script src="https://cdn.lunixi.com/lunixi-portal.min.js"></script>
ModeEntry pointWhat you getSession
0 — CatalogLunixi.portal.catalog(cfg)Pricing data client (plans / products / branding)None — publishable key only
1 — Full portalLunixi.portal.mount(target, cfg)The complete, branded portal UI rendered into your pagesdkMode: 'FULL'
2 — HeadlessLunixi.portal.create(cfg)A pure API client — you own 100% of the UIsdkMode: 'HEADLESS'
3 — HybridLunixi.portal.plans.mount(...) / Lunixi.portal.cardForm.mount(...)Individual widgets (plan picker, secure card form) inside your own layoutsdkMode: 'HYBRID'

Mode 0 — pricing data, zero backend

const catalog = Lunixi.portal.catalog({
  publishableKey: 'mk_live_pk_xxxxxxxxxxxx',
  locale: 'en',
});
 
const plans    = await catalog.plans();     // all active plans
const products = await catalog.products();  // all active products
const branding = await catalog.branding();  // logo, colors, terms URLs

This is the same Public Catalog API, wrapped — ideal for rendering the pricing table before the visitor has signed in anywhere.

Session bootstrap — your backend mints the session

Modes 1–3 act on behalf of a specific customer, so they need a session that only your backend can create. The browser never sees your secret key; it receives a short-lived, origin-bound session instead.

POST /api/v1/subscriptions/portal/sdk-session — Bearer (at_live_…), server-to-server only:

curl -X POST https://api-gateway.lunixi.com/api/v1/subscriptions/portal/sdk-session \
  -H "Authorization: Bearer at_live_5f3e2b1a..." \
  -H "Content-Type: application/json" \
  -d '{
    "customerId": "<customer-id>",
    "origin": "https://www.example.com",
    "sdkMode": "HYBRID"
  }'

The response — { portalToken, sessionProof, proofNonce, proofExpiresAt } — is handed to the browser SDK. The requested origin must be on your allow-list, and sdkMode must be among the modes you enabled in the panel.

The simplest wiring: give the SDK the URL of your bootstrap endpoint and let it fetch the session itself:

const portal = Lunixi.portal.create({
  sessionBootstrapUrl: '/portal-session', // your backend endpoint from above
});
await portal.init(); // validates the session, loads branding & permissions

Mode 1 — drop-in portal UI

<div id="portal"></div>
<script>
  Lunixi.portal.mount('#portal', {
    sessionBootstrapUrl: '/portal-session',
    locale: 'en',
  });
</script>

Renders the full self-service portal — plans, active subscription, invoices, card management — styled with the branding you configured. One div, done.

Mode 2 — headless

Every portal capability as plain async methods; you render everything:

const portal = Lunixi.portal.create({ sessionBootstrapUrl: '/portal-session' });
await portal.init();
 
// The customer proves ownership with a one-time code (email/SMS):
await portal.requestOtp();
await portal.verifyOtp(codeFromInput);   // → session JWT held inside the client
 
const me      = await portal.me();
const plans   = await portal.plans();
const subs    = await portal.subscriptions();
const invoices = await portal.invoices();
 
// Start a subscription with a saved card / card token:
await portal.startSubscription({
  planId: selectedPlan.id,
  paymentMethodToken: cardToken,
});

Portal sessions are OTP-verified: after init(), the customer confirms a one-time code before any account data or money-touching call is allowed. Sensitive operations such as cancel additionally require an email confirmation token — see Customer Portal.

Mode 3 — hybrid widgets

Keep your own page layout, borrow only the hard parts:

// A ready-made plan picker inside your layout:
Lunixi.portal.plans.mount('#plans', {
  sessionBootstrapUrl: '/portal-session',
  onSelect: (plan) => showCheckout(plan),
});
 
// The PCI-safe card form — card data never touches your code:
Lunixi.portal.cardForm.mount('#card', {
  sessionBootstrapUrl: '/portal-session',
  onSuccess: (cardToken) => subscribe(cardToken),
});

The card form is the piece you should never rebuild yourself: it collects the PAN inside the SDK's secure context and hands you back only a token.

End to end: pricing table → plan → active subscription

Render the pricing table (Mode 0)

const catalog = Lunixi.portal.catalog({ publishableKey: 'mk_live_pk_xxxxxxxxxxxx' });
const [plans, branding] = await Promise.all([catalog.plans(), catalog.branding()]);
 
for (const plan of plans) {
  renderPlanCard(plan, {
    onSubscribe: () => startSignup(plan), // ₺: format plan.price / 100
  });
}

Identify the customer

When the visitor picks a plan, run them through your own sign-up/login. Then your backend upserts the customer (PUT /api/v1/subscriptions/customers with your externalId) and mints an SDK session (POST /api/v1/subscriptions/portal/sdk-session).

Collect the card (Mode 3 card form)

Lunixi.portal.cardForm.mount('#card', {
  sessionBootstrapUrl: '/portal-session',
  onSuccess: (cardToken) => activate(plan, cardToken),
});

Start the subscription — two equally valid paths

The customer completes everything in the browser, inside the OTP-verified portal session:

const portal = Lunixi.portal.create({ sessionBootstrapUrl: '/portal-session' });
await portal.init();
await portal.requestOtp();
await portal.verifyOtp(codeFromInput);
 
const sub = await portal.startSubscription({
  planId: plan.id,
  paymentMethodToken: cardToken,
});
// sub.status → provisioned the moment `subscription.activated` fires

Best when the end customer is present and you want the platform to own consent and verification end-to-end.

Confirm via webhooks

Provision access on subscription.created / subscription.activated — not on the HTTP response. The webhook is the source of truth even if the user closes the tab mid-flow.

Errors

StatusWhereMeaning / what to do
401CatalogPublishable key missing or invalid — check the Authorization: Bearer mk_live_pk_… header.
403Catalog / SDKOrigin not on the allow-list, or the SDK surface / requested sdkMode is disabled — fix it in the panel's portal settings.
401Portal callsSession expired or OTP not verified — re-run init() → requestOtp() → verifyOtp().
400sdk-sessionMissing customerId / origin — both are required.
404sdk-sessionUnknown customerId — upsert the customer first.
409startSubscriptionIdempotent replay conflict — the SDK scopes idempotency keys per action; retry safely or pass your own idempotencyKey.

Webhooks

EventFired when
subscription.createdA subscription was created via portal start or Merchant API.
subscription.activatedThe subscription went live — provision now.
card.addedThe card form completed and the card entered the vault.
invoice.created / invoice.paidThe first billing cycle ran.

Next steps

  • Getting Started — the same chain, fully server-side
  • Plans — trials, billing rules, dunning
  • Customer Portal — hosted portal links, OTP flow, permissions
  • Products — catalog translations for multi-language pricing pages