Payment Orchestrator
Subscription
Customer Portal

Customer Portal

The customer portal lets your end-customers manage their own subscriptions — view invoices, update cards, change plans, cancel — without touching your support team. Access is bootstrapped from a portal link containing a cp_xxx token, then hardened with an email OTP: nobody gets a session just by holding the link.

Portal endpoints live under https://api-gateway.lunixi.com/api/portal/v1 and are called by the customer's browser (hosted portal or your embed) — never with your secret key.

How it works

Generate a portal link

Portal links are created from the merchant panel for a given customer. The link carries a scoped, expiring cp_xxx token.

Validate the token

The portal (hosted or embedded) calls POST /api/portal/v1/validate with the token. The response returns your branding and the set of permissions this portal session may use.

Verify the customer with OTP

POST /api/portal/v1/otp/request sends a one-time code to the customer's registered e-mail and returns an otpChallengeId; POST /api/portal/v1/otp/verify exchanges the challenge id and code for a session JWT (sessionToken).

Use the session

Read endpoints (profile, plans, subscriptions, invoices, cards) and session/revoke send the JWT in the x-portal-session header. The self-service write actions (subscriptions/start, subscriptions/cancel, subscriptions/change-plan, cards/update) carry the same JWT as sessionToken in the request body instead.

Validate the portal token

curl -X POST https://api-gateway.lunixi.com/api/portal/v1/validate \
  -H "Content-Type: application/json" \
  -d '{ "token": "cp_9f3ab27c41d0" }'
{
  "success": true,
  "requireEmailVerification": true,
  "effectivePlanSelectionMode": "SELF_SERVICE",
  "allowedPlanIds": ["plan_01JX2G3M8V"],
  "permissions": {
    "canStartSubscription": true,
    "canCancelSubscription": true,
    "canChangePlan": true,
    "canUpdatePaymentMethod": true,
    "canViewInvoices": true,
    "canDownloadInvoicePdf": true
  },
  "brandLogoUrl": "…",
  "brandPrimaryColor": "#4F46E5",
  "supportEmail": "[email protected]",
  "customerFirstName": "Ayşe",
  "customerEmailMasked": "a***@example.com",
  "hasActiveSubscription": false
}

permissions is a set of can* booleans reflecting what you configured for the link — render only the actions that are true.

OTP verification

# 1) send the code to the customer's registered e-mail
curl -X POST https://api-gateway.lunixi.com/api/portal/v1/otp/request \
  -H "Content-Type: application/json" \
  -d '{ "token": "cp_9f3ab27c41d0" }'
# → { "otpChallengeId": "…", "channel": "EMAIL",
#     "destinationMasked": "a***@example.com", "expiresAt": "…" }
 
# 2) exchange the challenge id + code for a session JWT
curl -X POST https://api-gateway.lunixi.com/api/portal/v1/otp/verify \
  -H "Content-Type: application/json" \
  -d '{
    "token": "cp_9f3ab27c41d0",
    "otpChallengeId": "1f0c7f2e-3f4a-4b1e-9a34-1f4f0b6c2d81",
    "otpCode": "482913"
  }'

The OTP goes to the e-mail address already on file for the customer — the portal link alone is never enough to act on an account. Sessions are short-lived JWTs; when one expires, repeat the OTP step.

Reading portal data

All read endpoints take the session JWT in x-portal-session:

MethodPathReturns
GET/api/portal/v1/meThe customer's profile
GET/api/portal/v1/plansPlans available to subscribe to / switch to
GET/api/portal/v1/subscriptionsThe customer's subscriptions
GET/api/portal/v1/invoicesInvoice history
GET/api/portal/v1/cardsStored cards (masked)
curl https://api-gateway.lunixi.com/api/portal/v1/subscriptions \
  -H "x-portal-session: <session-jwt>"

Self-service actions

All four write actions take the session JWT as sessionToken in the request body (not the x-portal-session header):

MethodPathWhat it does
POST/api/portal/v1/subscriptions/startStart a new subscription with a paymentMethodToken from the card form — idempotencyKey required
POST/api/portal/v1/subscriptions/cancelCancel — requires emailConfirmationToken (see below)
POST/api/portal/v1/subscriptions/change-planSwitch to another plan (newPlanId) — also requires emailConfirmationToken
POST/api/portal/v1/cards/updateReplace the payment card (paymentMethodToken)
POST/api/portal/v1/session/revokeLog out — invalidates the session JWT immediately (header-based)

Start a subscription

curl -X POST https://api-gateway.lunixi.com/api/portal/v1/subscriptions/start \
  -H "Content-Type: application/json" \
  -d '{
    "sessionToken": "<session-jwt>",
    "planId": "plan_01JX2G3M8V",
    "paymentMethodToken": "pmt_01JX7D2W5K",
    "idempotencyKey": "portal-start-84172"
  }'

Cancel — with e-mail confirmation

⚠️

Cancellation requires an emailConfirmationToken on top of the live session. The customer receives a confirmation link/token by e-mail and the cancel call must present it. This is deliberate fraud protection: a hijacked browser session — or a leaked portal link — cannot silently kill a subscription without access to the customer's mailbox.

curl -X POST https://api-gateway.lunixi.com/api/portal/v1/subscriptions/cancel \
  -H "Content-Type: application/json" \
  -d '{
    "sessionToken": "<session-jwt>",
    "subscriptionId": "sub_01JX5B8R3M",
    "cancelAtPeriodEnd": true,
    "emailConfirmationToken": "ect_01JX8E4Y7N"
  }'

Hosted portal vs SDK embed

Hosted portalSDK embed
Where it runsA ready-made page the portal link opens directlyInside your own app/site
EffortZero front-end work; your branding applied from validateYou control layout; SDK handles the flows
Best forFast launch, e-mail-driven self-servicePortal as a native part of your product UI

For embedding, the JS SDK offers Lunixi.portal.mount() (drop-in portal UI), Lunixi.portal.create() (headless — you build the UI on top of the same flows) and hybrid pieces like Lunixi.portal.plans.mount() / cardForm.mount(). The embed is bootstrapped server-side with your secret key via POST /api/v1/subscriptions/portal/sdk-session (customerId, origin, sdkMode: FULL | HEADLESS | HYBRID). Full SDK reference: Pricing & Portal SDK.

Errors

StatusMeaningWhat to do
400Malformed token or missing fieldsCheck the cp_ token and body shape
401Invalid/expired cp_ token, wrong OTP, or expired session JWTRe-issue the portal link, or repeat the OTP step
403Action not in the link's permissions, or emailConfirmationToken missing/invalid on cancelOnly offer permitted actions; restart the e-mail confirmation for cancel
404Subscription/card not found for this customerThe portal session is customer-scoped — verify ids from portal reads

Webhooks

Portal actions produce the same events as their merchant-API equivalents, so your backend hears about self-service changes automatically:

Portal actionEvents
subscriptions/startsubscription.created, subscription.activated, invoice.created/invoice.paid
subscriptions/cancelsubscription.canceled
subscriptions/change-plansubscription.upgraded / subscription.downgraded / subscription.plan_change_scheduled
cards/updatecard.added, card.set_default, subscription.payment_card_swapped

Next steps