Customer portal
Build a hosted customer portal flow with launch sessions, OTP/magic-link auth, and customer-scoped access.
lomi. customer portal provides a hosted customer account area at customers.lomi.africa where end-users can:


- View payment history and open invoice receipts when available
- View and manage subscriptions (pause, resume, cancel at period end, uncancel)
- Add, remove, and set default saved cards (when enabled in portal policy)
- Retry failed subscription payments in-portal before checkout fallback
- Download digital purchases from the library
This guide describes the recommended production integration.
Architecture (recommended)
- Your merchant backend creates a one-time portal launch session.
- You redirect/open the returned
launch_url. - The hosted portal consumes the token once and asks the customer to verify via:
- Email magic link, or
- SMS OTP
- Portal session is established and scoped to
(organization_id, customer_id, environment). - Customer sees only their own records.
Do not mint launch sessions in browser code. Create them from your backend only.
Create a launch session
Use POST /customers/{id}/portal-launch-session:
- Optional
return_url - Optional
flow_type(portal_home,subscription_cancel,subscription_manage) - Optional
flow_subscription_id(required forsubscription_cancel) - Optional
flow_after_completion_url
See endpoint details and payload examples in Customers.
Eligibility model
A customer is eligible for portal access only if they:
- Belong to your organization in the requested environment, and
- Have at least one billing record (transaction or subscription)
This avoids exposing an empty portal to contacts that never transacted.
End-to-end example
import axios from "axios";
const apiKey = process.env.LOMI_SECRET_KEY!;
const customerId = "2d8f4f8b-1ea8-4de9-9fd8-f52f743bb265";
const { data } = await axios.post(
`https://api.lomi.africa/customers/${customerId}/portal-launch-session`,
{
return_url: "https://merchant.example.com/account",
flow_type: "subscription_cancel",
flow_subscription_id: "3d6236f9-2c3e-4f0c-b00d-e2d73d3f0d24",
flow_after_completion_url:
"https://merchant.example.com/account/subscription-cancelled",
},
{
headers: {
"X-API-KEY": apiKey,
"Content-Type": "application/json",
},
},
);
// Redirect customer to hosted portal
return data.launch_url;curl -sS -X POST "https://api.lomi.africa/customers/2d8f4f8b-1ea8-4de9-9fd8-f52f743bb265/portal-launch-session" \
-H "X-API-KEY: $LOMI_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{
"return_url": "https://merchant.example.com/account",
"flow_type": "subscription_cancel",
"flow_subscription_id": "3d6236f9-2c3e-4f0c-b00d-e2d73d3f0d24",
"flow_after_completion_url": "https://merchant.example.com/account/subscription-cancelled"
}'Navigate customers to the portal
There are three ways customers can reach the portal:
Default org URL (self-serve sign-in)
https://customers.lomi.africa/o/{your-org-slug}Customers enter the email or phone used at checkout. No merchant API call required. Copy this URL from Settings → Checkout → Storefront → Customer portal.
Pre-authenticated launch (merchant app)
When the customer is already identified in your app, create a launch session server-side and redirect to launch_url. If the session includes a customer_id, the portal skips the second OTP (trusted launch).
import { CustomersService } from "@lomi/sdk";
const { launch_url } = await CustomersService.createPortalLaunchSession(customerId, {
return_url: "https://your-app.com/account",
});
redirect(launch_url);Next.js example (app/billing/route.ts):
import { redirect } from "next/navigation";
import { CustomersService } from "@lomi/sdk";
export async function GET() {
const customerId = await getLoggedInCustomerId();
const { launch_url } = await CustomersService.createPortalLaunchSession(customerId, {});
redirect(launch_url);
}Transactional emails
Customer receipt emails include your org portal URL (customers.lomi.africa/o/{slug}) so buyers can return from their inbox.
Headless API (optional)
For custom portal UIs, use the portal session bearer token from a trusted launch or OTP flow:
GET /customer-portal/meGET /customer-portal/transactionsGET /customer-portal/subscriptionsPOST /customer-portal/subscriptions/{id}/actionsGET /customer-portal/payment-methodsPOST /customer-portal/payment-methods/setup-intentPOST /customer-portal/payment-methods/{id}/defaultDELETE /customer-portal/payment-methods/{id}POST /customer-portal/subscriptions/{id}/retry-payment
Pass Authorization: Bearer <portal_session_token>.
Payment methods
When allow_payment_method_update is enabled in portal policy (default: on), customers can:
- Create a card setup intent via
POST /customer-portal/payment-methods/setup-intent - Confirm the setup intent client-side with your card payment SDK
- Persist the card with your attach flow (hosted portal calls the attach edge function after confirmation)
Use POST /customer-portal/payment-methods/{id}/default to set the default card and sync active subscriptions. Use DELETE /customer-portal/payment-methods/{id} to remove a card (blocked when it is the sole card on a billable subscription).
Failed payment recovery
For past_due card subscriptions, POST /customer-portal/subscriptions/{id}/retry-payment attempts an off-session charge with the default saved card. On success, renewal is recorded and dunning is cleared. On failure, the response may include a checkout_url fallback (same path as automated renewal dunning).
The hosted portal also exposes Retry payment alongside Pay now on subscription actions.
Dashboard integration
From Settings → Checkout → Storefront, configure the customer portal:
- Allow pause / resume / cancel
- Email magic link and SMS OTP sign-in
- Payment method updates: allow customers to add, remove, and set default cards
- Return URL allowlist: required when using
return_urlorflow_after_completion_urlfrom your backend
From Customers, open a customer with billing history and click Open customer portal to launch the hosted flow in a new tab (same as the API launch session).
Subscription cancellation
Customer-initiated cancellation is at period end by default: access continues until next_billing_date, then the subscription moves to cancelled. Customers can uncancel before that date from the portal.
Schedule finalize_cancel_at_period_end_subscriptions() daily (e.g. pg_cron) so period-end cancellations are applied automatically.
Security controls
- Launch tokens are one-time and short-lived (15 min)
- Tokens are hashed at rest
- OTP/magic challenges are hashed, expiring, and attempt-limited
- Portal sessions are hashed, revocable, and renewed on activity
- Requests are organization/customer scoped at SQL function level
- Audit events recorded for launch, challenge, session, and subscription actions
Production checklist
Required for the hosted portal app (apps/customers):
CUSTOMER_PORTAL_COOKIE_SECRET, seals handoff and flow cookies (min 16 characters)NEXT_PUBLIC_SUPABASE_URLandSUPABASE_SECRET_KEY, RPC access and magic-link email
Optional:
CUSTOMER_PORTAL_SMS_WEBHOOK_URL, POST{ to, body }to your SMS gateway. Without it, OTPs are only logged in non-production.
Launch URLs and magic links default to https://customers.lomi.africa and the request origin respectively. No extra base-URL env vars are required for production.
Also:
- Ensure merchant backend uses server-side API key storage.
- Add merchant return URLs to the portal allowlist before using
return_urlin API calls. - Schedule
finalize_cancel_at_period_end_subscriptions()daily (included inrun_to_prod.sqlcron). - Monitor launch/session/challenge failures and abuse rates.
- Keep customer phone/email normalization consistent in your CRM.
Troubleshooting
- 404 customer not found: wrong customer ID or organization mismatch.
- 400 invalid flow: unsupported
flow_typeor missingflow_subscription_id. - Launch URL opens expired page: token reused or expired.
- Customer cannot proceed after contact input: no eligible billing records.