Pricing & Portal SDK
Sell subscriptions from your own website. Two building blocks make it work:
- 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.
- Portal JS SDK — a single script from
cdn.lunixi.comthat 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
- 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:
| Method | Path | Returns |
|---|---|---|
| GET | /api/public/v1/subscription/plans | Active plans (price, currency, trial, billing cadence, display features) |
| GET | /api/public/v1/subscription/products | Active products |
| GET | /api/public/v1/subscription/branding | Logo, 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>| Mode | Entry point | What you get | Session |
|---|---|---|---|
| 0 — Catalog | Lunixi.portal.catalog(cfg) | Pricing data client (plans / products / branding) | None — publishable key only |
| 1 — Full portal | Lunixi.portal.mount(target, cfg) | The complete, branded portal UI rendered into your page | sdkMode: 'FULL' |
| 2 — Headless | Lunixi.portal.create(cfg) | A pure API client — you own 100% of the UI | sdkMode: 'HEADLESS' |
| 3 — Hybrid | Lunixi.portal.plans.mount(...) / Lunixi.portal.cardForm.mount(...) | Individual widgets (plan picker, secure card form) inside your own layout | sdkMode: '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 URLsThis 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 & permissionsMode 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` firesBest 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
| Status | Where | Meaning / what to do |
|---|---|---|
401 | Catalog | Publishable key missing or invalid — check the Authorization: Bearer mk_live_pk_… header. |
403 | Catalog / SDK | Origin not on the allow-list, or the SDK surface / requested sdkMode is disabled — fix it in the panel's portal settings. |
401 | Portal calls | Session expired or OTP not verified — re-run init() → requestOtp() → verifyOtp(). |
400 | sdk-session | Missing customerId / origin — both are required. |
404 | sdk-session | Unknown customerId — upsert the customer first. |
409 | startSubscription | Idempotent replay conflict — the SDK scopes idempotency keys per action; retry safely or pass your own idempotencyKey. |
Webhooks
| Event | Fired when |
|---|---|
subscription.created | A subscription was created via portal start or Merchant API. |
subscription.activated | The subscription went live — provision now. |
card.added | The card form completed and the card entered the vault. |
invoice.created / invoice.paid | The 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