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:
| Method | Path | Returns |
|---|---|---|
GET | /api/portal/v1/me | The customer's profile |
GET | /api/portal/v1/plans | Plans available to subscribe to / switch to |
GET | /api/portal/v1/subscriptions | The customer's subscriptions |
GET | /api/portal/v1/invoices | Invoice history |
GET | /api/portal/v1/cards | Stored 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):
| Method | Path | What it does |
|---|---|---|
POST | /api/portal/v1/subscriptions/start | Start a new subscription with a paymentMethodToken from the card form — idempotencyKey required |
POST | /api/portal/v1/subscriptions/cancel | Cancel — requires emailConfirmationToken (see below) |
POST | /api/portal/v1/subscriptions/change-plan | Switch to another plan (newPlanId) — also requires emailConfirmationToken |
POST | /api/portal/v1/cards/update | Replace the payment card (paymentMethodToken) |
POST | /api/portal/v1/session/revoke | Log 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 portal | SDK embed | |
|---|---|---|
| Where it runs | A ready-made page the portal link opens directly | Inside your own app/site |
| Effort | Zero front-end work; your branding applied from validate | You control layout; SDK handles the flows |
| Best for | Fast launch, e-mail-driven self-service | Portal 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
| Status | Meaning | What to do |
|---|---|---|
400 | Malformed token or missing fields | Check the cp_ token and body shape |
401 | Invalid/expired cp_ token, wrong OTP, or expired session JWT | Re-issue the portal link, or repeat the OTP step |
403 | Action not in the link's permissions, or emailConfirmationToken missing/invalid on cancel | Only offer permitted actions; restart the e-mail confirmation for cancel |
404 | Subscription/card not found for this customer | The 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 action | Events |
|---|---|
subscriptions/start | subscription.created, subscription.activated, invoice.created/invoice.paid |
subscriptions/cancel | subscription.canceled |
subscriptions/change-plan | subscription.upgraded / subscription.downgraded / subscription.plan_change_scheduled |
cards/update | card.added, card.set_default, subscription.payment_card_swapped |
Next steps
- Pricing & Portal SDK — embed the portal or the pricing catalog
- Subscriptions — the merchant-side view of the same lifecycle
- Invoices — what customers see under their invoice tab