Customer Self-Service
Enable customers to manage their own subscriptions, view usage, update plans, and handle service requests through the Seamless OS Connect API. This use case covers building customer portal features that reduce support costs while improving customer satisfaction.
Build a self-service portal where end users sign in, see their subscriptions and remaining data, upgrade or downgrade plans, buy add-ons and top-ups, download eSIMs, and manage their invoices and payment methods — all without contacting support.
Prerequisites
Before you begin, ensure you have:
- API Key: An API key created in the portal, sent as the
X-API-Keyheader on every request - Product Catalog: Familiarity with product offerings and pricing (see the product catalog guide)
- Active Subscriptions: Customers with provisioned subscriptions to manage
- Payment Provider: A configured payment provider (for the saved payment method features)
Overview
A self-service portal typically implements these flows:
- Authenticate the end user with passwordless email login
- Load the user’s profile and customer context
- Show the user’s subscriptions and current usage
- Change plans (upgrades and downgrades)
- Manage add-ons and sell data top-ups
- Deliver eSIM activation QR codes
- Show invoices and manage saved payment methods
- Cancel service with structured churn feedback
All requests use two credentials: your API key (X-API-Key) identifies your integration, and the user’s JWT (Authorization: Bearer ...) scopes every request to what that user is allowed to see and do. A user can only list their own subscriptions, invoices, and payment methods — the API enforces this regardless of which API key is used.
Step-by-Step Implementation
Step 1: Authenticate the End User
End users sign in with a passwordless email flow: start a login to send a 6-digit verification code, then verify the code to receive a JWT access token.
Start the login flow:
curl -X POST "https://apiv2.example.com/api/v2/auth/email/start" \
-H "Content-Type: application/json" \
-d '{
"email": "emma.johnson@example.com"
}'const startEmailLogin = async (email) => {
const response = await fetch('https://apiv2.example.com/api/v2/auth/email/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
});
if (!response.ok) {
const error = await response.json();
throw new Error(`Failed to start login: ${error.message}`);
}
// Keep the nonce; it is required to verify the code
return response.json();
};The endpoint always returns 202 Accepted — even for unknown email addresses — to prevent email enumeration. The response contains a nonce that references this login attempt:
{
"nonce": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"expiresIn": 300,
"createdAt": "2026-07-12T10:00:00Z",
"expiresAt": "2026-07-12T10:05:00Z"
}
The user receives a 6-digit code by email. Verify it together with the email and nonce:
curl -X POST "https://apiv2.example.com/api/v2/auth/email/verify" \
-H "Content-Type: application/json" \
-d '{
"email": "emma.johnson@example.com",
"nonce": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"code": "482913"
}'const verifyEmailLogin = async (email, nonce, code) => {
const response = await fetch('https://apiv2.example.com/api/v2/auth/email/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, nonce, code }),
});
if (!response.ok) {
const error = await response.json();
throw new Error(`Login verification failed: ${error.message}`);
}
const token = await response.json();
console.log('Logged in as user:', token.userId);
return token;
};On success you receive an OAuth2-compatible token response:
{
"accessToken": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
"tokenType": "Bearer",
"expiresIn": 604800,
"userId": "f47ac10b-58cc-4372-a567-0e02b2c3d479"
}
The login endpoints require no authentication headers and are rate limited (429 Too Many Requests). Verification codes expire after expiresIn seconds (300 in the example above); if a
code expires, start a new login. Store the accessToken securely and send it as Authorization: Bearer YOUR_ACCESS_TOKEN on every subsequent request, alongside your X-API-Key.
Step 2: Load the User’s Profile
Use the userId from the token response to load the user’s profile. The customers array tells you which customer accounts the user belongs to — you need a customerId later for invoices and payment methods.
curl -X GET "https://apiv2.example.com/api/v2/users/f47ac10b-58cc-4372-a567-0e02b2c3d479" \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "X-API-Key: YOUR_API_KEY"const getUser = async (userId) => {
const response = await fetch(`https://apiv2.example.com/api/v2/users/${userId}`, {
method: 'GET',
headers: {
Authorization: 'Bearer YOUR_ACCESS_TOKEN',
'X-API-Key': 'YOUR_API_KEY',
},
});
if (!response.ok) {
const error = await response.json();
throw new Error(`Failed to load user: ${error.message}`);
}
return response.json();
};{
"userId": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
"name": "Emma Johnson",
"email": "emma.johnson@example.com",
"msisdn": "+12065550142",
"customers": [
{
"customerId": "6b1f44a0-52d3-4f7a-9b3e-2c8d5e91f0a4",
"name": "Emma Johnson"
}
],
"createdAt": "2026-03-10T14:22:05Z",
"updatedAt": "2026-03-10T14:22:05Z"
}
Step 3: Get the User’s Subscriptions
List the user’s subscriptions to render the portal’s home screen. With a user JWT, the list is automatically scoped to subscriptions the user has access to. Filter by status to hide cancelled services, and page through results with limit and cursor.
# List the user's active subscriptions
curl -X GET "https://apiv2.example.com/api/v2/subscriptions?status=ACTIVATED" \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "X-API-Key: YOUR_API_KEY"
# Get a single subscription
curl -X GET "https://apiv2.example.com/api/v2/subscriptions/d8174435-6378-4be5-a9f5-8b4aaadae5d4" \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "X-API-Key: YOUR_API_KEY"const listSubscriptions = async () => {
const response = await fetch('https://apiv2.example.com/api/v2/subscriptions?status=ACTIVATED', {
method: 'GET',
headers: {
Authorization: 'Bearer YOUR_ACCESS_TOKEN',
'X-API-Key': 'YOUR_API_KEY',
},
});
if (!response.ok) {
const error = await response.json();
throw new Error(`Failed to list subscriptions: ${error.message}`);
}
const { items, pagination } = await response.json();
console.log('Subscriptions:', items.length, 'next cursor:', pagination.nextCursor);
return items;
};
const getSubscription = async (subscriptionId) => {
const response = await fetch(`https://apiv2.example.com/api/v2/subscriptions/${subscriptionId}`, {
method: 'GET',
headers: {
Authorization: 'Bearer YOUR_ACCESS_TOKEN',
'X-API-Key': 'YOUR_API_KEY',
},
});
if (!response.ok) {
const error = await response.json();
throw new Error(`Failed to get subscription: ${error.message}`);
}
return response.json();
};Each subscription embeds everything a portal detail page needs — phone number, SIM details, and the current plan with pricing:
{
"subscriptionId": "d8174435-6378-4be5-a9f5-8b4aaadae5d4",
"status": "ACTIVATED",
"type": "CELL",
"display": "(206) 555-0142",
"msisdn": "+12065550142",
"customer": {
"customerId": "6b1f44a0-52d3-4f7a-9b3e-2c8d5e91f0a4",
"name": "Emma Johnson"
},
"productOffering": {
"productOfferingId": "cell-10gb",
"name": "Seamless 10GB",
"price": {
"netPrice": 35,
"currency": "USD",
"priceType": "RECURRING",
"billingCycle": {
"period": "MONTHLY",
"interval": 1
}
}
},
"subscriber": {
"subscriberId": "2c7e91f0-3a4b-4c5d-8e6f-7a8b9c0d1e2f",
"name": "Emma Johnson"
},
"sim": {
"esim": true,
"iccid": "89012608522901821364"
},
"activatedAt": "2026-03-15T09:30:00Z",
"createdAt": "2026-03-10T14:22:05Z",
"updatedAt": "2026-07-12T08:45:00Z"
}
Subscription status is one of PENDING, ACTIVATED, BLOCKED, CANCELLED, PAUSED, or SUSPENDED. Scheduled changes surface as pendingStatus, pendingMsisdn, and pendingProductOffering objects on the subscription, so the portal can show banners like “Your plan changes on August 1”.
Step 4: Show Current Usage
Retrieve the current period’s usage to render data, voice, and SMS meters. Usage is grouped by service (data, voice, sms, mms) and scope (national, roaming, ild), with one entry per package.
curl -X GET "https://apiv2.example.com/api/v2/subscriptions/d8174435-6378-4be5-a9f5-8b4aaadae5d4/usage" \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "X-API-Key: YOUR_API_KEY"const getSubscriptionUsage = async (subscriptionId) => {
const response = await fetch(
`https://apiv2.example.com/api/v2/subscriptions/${subscriptionId}/usage`,
{
method: 'GET',
headers: {
Authorization: 'Bearer YOUR_ACCESS_TOKEN',
'X-API-Key': 'YOUR_API_KEY',
},
},
);
if (!response.ok) {
const error = await response.json();
throw new Error(`Failed to get usage: ${error.message}`);
}
const usage = await response.json();
const toGb = (bytes) => (bytes / 1024 ** 3).toFixed(1);
for (const pkg of usage.data?.national ?? []) {
console.log(`${pkg.name}: ${toGb(pkg.dataBytesUsed)} of ${toGb(pkg.dataBytesTotal)} GB used`);
}
return usage;
};{
"data": {
"national": [
{
"name": "Seamless 10GB Data",
"dataBytesUsed": 4831838208,
"dataBytesRemaining": 5905580032,
"dataBytesTotal": 10737418240,
"status": "ACTIVE",
"validFrom": "2026-07-01T00:00:00Z",
"validTo": "2026-08-01T00:00:00Z"
}
]
},
"voice": {
"national": [
{
"name": "National Minutes",
"callSeconds": 5460,
"callCount": 32,
"callRemainingSeconds": 30540,
"callTotalSeconds": 36000,
"status": "ACTIVE",
"validFrom": "2026-07-01T00:00:00Z",
"validTo": "2026-08-01T00:00:00Z"
}
]
},
"sms": {
"national": [
{
"name": "National SMS",
"smsCount": 118,
"smsRemaining": 382,
"smsTotal": 500,
"status": "ACTIVE",
"validFrom": "2026-07-01T00:00:00Z",
"validTo": "2026-08-01T00:00:00Z"
}
]
},
"updatedAt": "2026-07-12T08:45:00Z"
}
Data amounts are in bytes. Each package’s status is ACTIVE, NOT_ACTIVE, or EXPIRED, and packages that come from an add-on carry a subscriptionAddonId so you can label them separately from the base plan.
For an overview screen that shows usage across several subscriptions, fetch up to 100 at once:
curl -X GET "https://apiv2.example.com/api/v2/subscriptions/usage?subscriptionIds=d8174435-6378-4be5-a9f5-8b4aaadae5d4&subscriptionIds=b9285546-7489-4cf6-b0a6-9c5bbbebf6e5" \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "X-API-Key: YOUR_API_KEY"const getUsageForSubscriptions = async (subscriptionIds) => {
const query = new URLSearchParams();
subscriptionIds.forEach((id) => query.append('subscriptionIds', id));
const response = await fetch(
`https://apiv2.example.com/api/v2/subscriptions/usage?${query.toString()}`,
{
method: 'GET',
headers: {
Authorization: 'Bearer YOUR_ACCESS_TOKEN',
'X-API-Key': 'YOUR_API_KEY',
},
},
);
if (!response.ok) {
const error = await response.json();
throw new Error(`Failed to get usage: ${error.message}`);
}
// items: [{ subscriptionId, usage }, ...]
const { items } = await response.json();
return items;
};Step 5: Change Plan
Get change options for the subscription
Get all available product offerings a subscription can be changed to and when the change can take effect.
When the subscription can be changed typically depends on the network setup, billing cycle, and current product offering. As a rule of thumb (though not always), upgrades and lateral moves are immediate, while downgrades take effect at the next renewal date.
curl -X GET "https://apiv2.example.com/api/v2/subscriptions/d8174435-6378-4be5-a9f5-8b4aaadae5d4/product-offering-options" \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "X-API-Key: YOUR_API_KEY"const getPlanChangeOptions = async (subscriptionId) => {
const response = await fetch(
`https://apiv2.example.com/api/v2/subscriptions/${subscriptionId}/product-offering-options`,
{
method: 'GET',
headers: {
Authorization: 'Bearer YOUR_ACCESS_TOKEN',
'X-API-Key': 'YOUR_API_KEY',
},
},
);
if (!response.ok) {
const error = await response.json();
throw new Error(`Failed to get change options: ${error.message}`);
}
const { items } = await response.json();
return items;
};{
"items": [
{
"productOffering": {
"productOfferingId": "cell-unlimited",
"name": "Seamless Unlimited",
"price": {
"netPrice": 55,
"currency": "USD",
"priceType": "RECURRING",
"billingCycle": {
"period": "MONTHLY",
"interval": 1
}
}
},
"changeSchedule": "INSTANT",
"changeScheduleDate": "2026-07-12"
},
{
"productOffering": {
"productOfferingId": "cell-5gb",
"name": "Seamless 5GB",
"price": {
"netPrice": 25,
"currency": "USD",
"priceType": "RECURRING",
"billingCycle": {
"period": "MONTHLY",
"interval": 1
}
}
},
"changeSchedule": "NEXT_RENEWAL_DAY",
"changeScheduleDate": "2026-08-01"
}
]
}
changeSchedule tells you when each option takes effect:
INSTANT— change takes effect immediatelyFIRST_OF_NEXT_MONTH— first day of the next calendar monthNEXT_RENEWAL_DAY— next renewal dateNEXT_PAYMENT_DAY— end of the prepaid period, the next payment day
Render changeScheduleDate next to each plan so users know exactly when the switch happens.
Change the subscription’s product offering
Submit the change with the chosen productOfferingId. When the change takes effect is dictated by what product offering is chosen, which in turn depends on the network setup and billing cycle. Optionally pass scheduledAt as the earliest date to perform the change; if the change schedule does not fit that date, the earliest date after it is chosen.
curl -X PUT "https://apiv2.example.com/api/v2/subscriptions/d8174435-6378-4be5-a9f5-8b4aaadae5d4/product-offering-change" \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "X-API-Key: YOUR_API_KEY" \
-H "X-Idempotency-Key: plan-change-7f3a2b1c" \
-H "Content-Type: application/json" \
-d '{
"productOfferingId": "cell-5gb"
}'const changePlan = async (subscriptionId, productOfferingId) => {
const response = await fetch(
`https://apiv2.example.com/api/v2/subscriptions/${subscriptionId}/product-offering-change`,
{
method: 'PUT',
headers: {
Authorization: 'Bearer YOUR_ACCESS_TOKEN',
'X-API-Key': 'YOUR_API_KEY',
'X-Idempotency-Key': `plan-change-${crypto.randomUUID()}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ productOfferingId }),
},
);
if (!response.ok) {
const error = await response.json();
throw new Error(`Plan change failed: ${error.message}`);
}
return response.json();
};The response is the updated subscription. For a non-instant change (like this downgrade), the current plan stays in place and the scheduled change appears under pendingProductOffering:
{
"subscriptionId": "d8174435-6378-4be5-a9f5-8b4aaadae5d4",
"status": "ACTIVATED",
"type": "CELL",
"display": "(206) 555-0142",
"msisdn": "+12065550142",
"customer": {
"customerId": "6b1f44a0-52d3-4f7a-9b3e-2c8d5e91f0a4",
"name": "Emma Johnson"
},
"productOffering": {
"productOfferingId": "cell-10gb",
"name": "Seamless 10GB",
"price": {
"netPrice": 35,
"currency": "USD",
"priceType": "RECURRING",
"billingCycle": {
"period": "MONTHLY",
"interval": 1
}
}
},
"pendingProductOffering": {
"scheduledAt": "2026-08-01",
"product": {
"productOfferingId": "cell-5gb",
"name": "Seamless 5GB",
"price": {
"netPrice": 25,
"currency": "USD",
"priceType": "RECURRING",
"billingCycle": {
"period": "MONTHLY",
"interval": 1
}
}
}
},
"sim": {
"esim": true,
"iccid": "89012608522901821364"
},
"activatedAt": "2026-03-15T09:30:00Z",
"createdAt": "2026-03-10T14:22:05Z",
"updatedAt": "2026-07-12T09:12:41Z"
}
For an INSTANT option, the response instead shows the new plan directly in productOffering with no pendingProductOffering.
Step 6: Manage Add-Ons
List active add-ons
Get all active and pending add-ons currently attached to a subscription. Filter by status (PENDING, ACTIVE, CANCELLED, EXPIRED) if needed.
curl -X GET "https://apiv2.example.com/api/v2/subscriptions/d8174435-6378-4be5-a9f5-8b4aaadae5d4/addons?status=ACTIVE" \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "X-API-Key: YOUR_API_KEY"const listActiveAddons = async (subscriptionId) => {
const response = await fetch(
`https://apiv2.example.com/api/v2/subscriptions/${subscriptionId}/addons?status=ACTIVE`,
{
method: 'GET',
headers: {
Authorization: 'Bearer YOUR_ACCESS_TOKEN',
'X-API-Key': 'YOUR_API_KEY',
},
},
);
if (!response.ok) {
const error = await response.json();
throw new Error(`Failed to list add-ons: ${error.message}`);
}
const { items } = await response.json();
return items;
};{
"items": [
{
"subscriptionAddonId": "a47ac10b-58cc-4372-a567-0e02b2c3d479",
"subscriptionId": "d8174435-6378-4be5-a9f5-8b4aaadae5d4",
"status": "ACTIVE",
"productOffering": {
"productOfferingId": "addon-roaming-na",
"name": "North America Roaming",
"price": {
"netPrice": 15,
"currency": "USD",
"priceType": "RECURRING",
"billingCycle": {
"period": "MONTHLY",
"interval": 1
}
}
},
"addedAt": "2026-05-01T12:00:00Z",
"updatedAt": "2026-05-01T12:00:00Z"
}
]
}
Find add-ons available to purchase
To show a store of add-ons the user can buy, list product offerings with types=SUBSCRIPTION_ADDON. The customerType parameter is required; use categories to narrow down (for example PRODUCT_CATEGORY_EXTRA_DATA for data packages or PRODUCT_CATEGORY_ABROAD for roaming). Each add-on offering’s addonCategories field lists which subscription categories it can attach to.
curl -X GET "https://apiv2.example.com/api/v2/product-offerings?types=SUBSCRIPTION_ADDON&customerType=CONSUMER" \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "X-API-Key: YOUR_API_KEY"const listAddonOfferings = async () => {
const query = new URLSearchParams({
types: 'SUBSCRIPTION_ADDON',
customerType: 'CONSUMER',
});
const response = await fetch(
`https://apiv2.example.com/api/v2/product-offerings?${query.toString()}`,
{
method: 'GET',
headers: {
Authorization: 'Bearer YOUR_ACCESS_TOKEN',
'X-API-Key': 'YOUR_API_KEY',
},
},
);
if (!response.ok) {
const error = await response.json();
throw new Error(`Failed to list add-on offerings: ${error.message}`);
}
const { items } = await response.json();
return items;
};Add an add-on to the subscription
Add the chosen offering to the subscription. The add-on activates immediately, or on scheduledAt if provided.
curl -X POST "https://apiv2.example.com/api/v2/subscriptions/d8174435-6378-4be5-a9f5-8b4aaadae5d4/addons" \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "X-API-Key: YOUR_API_KEY" \
-H "X-Idempotency-Key: add-addon-2c9e4f7a" \
-H "Content-Type: application/json" \
-d '{
"productOfferingId": "addon-roaming-na"
}'const addAddon = async (subscriptionId, productOfferingId) => {
const response = await fetch(
`https://apiv2.example.com/api/v2/subscriptions/${subscriptionId}/addons`,
{
method: 'POST',
headers: {
Authorization: 'Bearer YOUR_ACCESS_TOKEN',
'X-API-Key': 'YOUR_API_KEY',
'X-Idempotency-Key': `add-addon-${crypto.randomUUID()}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ productOfferingId }),
},
);
if (!response.ok) {
const error = await response.json();
throw new Error(`Failed to add add-on: ${error.message}`);
}
return response.json();
};Returns 201 Created with the new add-on, including its subscriptionAddonId for later changes or cancellation.
Get change options for a subscription add-on
Get all product offerings an existing add-on can be changed to and when the change can take effect. Pass the add-on’s current offering as currentProductOfferingId (required).
curl -X GET "https://apiv2.example.com/api/v2/subscriptions/d8174435-6378-4be5-a9f5-8b4aaadae5d4/addons/product-offering-options?currentProductOfferingId=addon-roaming-na" \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "X-API-Key: YOUR_API_KEY"const getAddonChangeOptions = async (subscriptionId, currentProductOfferingId) => {
const query = new URLSearchParams({ currentProductOfferingId });
const response = await fetch(
`https://apiv2.example.com/api/v2/subscriptions/${subscriptionId}/addons/product-offering-options?${query.toString()}`,
{
method: 'GET',
headers: {
Authorization: 'Bearer YOUR_ACCESS_TOKEN',
'X-API-Key': 'YOUR_API_KEY',
},
},
);
if (!response.ok) {
const error = await response.json();
throw new Error(`Failed to get add-on change options: ${error.message}`);
}
const { items } = await response.json();
return items;
};{
"items": [
{
"productOffering": {
"productOfferingId": "addon-roaming-global",
"name": "Global Roaming",
"price": {
"netPrice": 25,
"currency": "USD",
"priceType": "RECURRING",
"billingCycle": {
"period": "MONTHLY",
"interval": 1
}
}
},
"changeSchedule": "INSTANT",
"changeScheduleDate": "2026-07-12"
}
]
}
Change a subscription add-on’s product offering
Change an existing add-on to a different offering (upgrade or downgrade). Identify the add-on with its subscriptionAddonId.
curl -X PUT "https://apiv2.example.com/api/v2/subscriptions/d8174435-6378-4be5-a9f5-8b4aaadae5d4/addons/product-offering-change" \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "X-API-Key: YOUR_API_KEY" \
-H "X-Idempotency-Key: change-addon-9b4d1e6f" \
-H "Content-Type: application/json" \
-d '{
"subscriptionAddonId": "a47ac10b-58cc-4372-a567-0e02b2c3d479",
"productOfferingId": "addon-roaming-global",
"reason": "Customer upgrade request"
}'const changeAddon = async (subscriptionId, subscriptionAddonId, productOfferingId) => {
const response = await fetch(
`https://apiv2.example.com/api/v2/subscriptions/${subscriptionId}/addons/product-offering-change`,
{
method: 'PUT',
headers: {
Authorization: 'Bearer YOUR_ACCESS_TOKEN',
'X-API-Key': 'YOUR_API_KEY',
'X-Idempotency-Key': `change-addon-${crypto.randomUUID()}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
subscriptionAddonId,
productOfferingId,
reason: 'Customer upgrade request',
}),
},
);
if (!response.ok) {
const error = await response.json();
throw new Error(`Failed to change add-on: ${error.message}`);
}
return response.json();
};The response is the updated add-on. Like plan changes, a scheduled change appears under the add-on’s pendingProductOffering until it takes effect.
Cancel an add-on
Cancel an active add-on. Without scheduledAt, the add-on is cancelled immediately or according to the default schedule.
curl -X POST "https://apiv2.example.com/api/v2/subscriptions/d8174435-6378-4be5-a9f5-8b4aaadae5d4/addons/cancel" \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "X-API-Key: YOUR_API_KEY" \
-H "X-Idempotency-Key: cancel-addon-5e8c3a2d" \
-H "Content-Type: application/json" \
-d '{
"subscriptionAddonId": "a47ac10b-58cc-4372-a567-0e02b2c3d479",
"reason": "No longer needed"
}'const cancelAddon = async (subscriptionId, subscriptionAddonId) => {
const response = await fetch(
`https://apiv2.example.com/api/v2/subscriptions/${subscriptionId}/addons/cancel`,
{
method: 'POST',
headers: {
Authorization: 'Bearer YOUR_ACCESS_TOKEN',
'X-API-Key': 'YOUR_API_KEY',
'X-Idempotency-Key': `cancel-addon-${crypto.randomUUID()}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
subscriptionAddonId,
reason: 'No longer needed',
}),
},
);
if (!response.ok) {
const error = await response.json();
throw new Error(`Failed to cancel add-on: ${error.message}`);
}
return response.json();
};A scheduled cancellation shows up in the add-on’s pendingStatus:
{
"subscriptionAddonId": "a47ac10b-58cc-4372-a567-0e02b2c3d479",
"subscriptionId": "d8174435-6378-4be5-a9f5-8b4aaadae5d4",
"status": "ACTIVE",
"productOffering": {
"productOfferingId": "addon-roaming-na",
"name": "North America Roaming",
"price": {
"netPrice": 15,
"currency": "USD",
"priceType": "RECURRING",
"billingCycle": {
"period": "MONTHLY",
"interval": 1
}
}
},
"pendingStatus": {
"status": "CANCELLED",
"scheduledAt": "2026-08-01"
},
"addedAt": "2026-05-01T12:00:00Z",
"updatedAt": "2026-07-12T09:30:12Z"
}
Step 7: Sell Data Top-Ups
A data top-up is a one-time add-on: a SUBSCRIPTION_ADDON offering in the PRODUCT_CATEGORY_EXTRA_DATA category with a ONE_TIME price. The flow is the same as any add-on purchase — find the offering, then add it to the subscription.
# Find available data top-up offerings
curl -X GET "https://apiv2.example.com/api/v2/product-offerings?types=SUBSCRIPTION_ADDON&categories=PRODUCT_CATEGORY_EXTRA_DATA&customerType=CONSUMER" \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "X-API-Key: YOUR_API_KEY"
# Buy the top-up
curl -X POST "https://apiv2.example.com/api/v2/subscriptions/d8174435-6378-4be5-a9f5-8b4aaadae5d4/addons" \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "X-API-Key: YOUR_API_KEY" \
-H "X-Idempotency-Key: topup-1d7f9c3b" \
-H "Content-Type: application/json" \
-d '{
"productOfferingId": "addon-data-5gb"
}'const buyDataTopUp = async (subscriptionId) => {
const query = new URLSearchParams({
types: 'SUBSCRIPTION_ADDON',
categories: 'PRODUCT_CATEGORY_EXTRA_DATA',
customerType: 'CONSUMER',
});
const offeringsResponse = await fetch(
`https://apiv2.example.com/api/v2/product-offerings?${query.toString()}`,
{
method: 'GET',
headers: {
Authorization: 'Bearer YOUR_ACCESS_TOKEN',
'X-API-Key': 'YOUR_API_KEY',
},
},
);
if (!offeringsResponse.ok) {
const error = await offeringsResponse.json();
throw new Error(`Failed to list top-up offerings: ${error.message}`);
}
const { items: offerings } = await offeringsResponse.json();
const topUp = offerings[0];
console.log(`Buying ${topUp.name} for ${topUp.price.netPrice} ${topUp.price.currency}`);
const purchaseResponse = await fetch(
`https://apiv2.example.com/api/v2/subscriptions/${subscriptionId}/addons`,
{
method: 'POST',
headers: {
Authorization: 'Bearer YOUR_ACCESS_TOKEN',
'X-API-Key': 'YOUR_API_KEY',
'X-Idempotency-Key': `topup-${crypto.randomUUID()}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ productOfferingId: topUp.productOfferingId }),
},
);
if (!purchaseResponse.ok) {
const error = await purchaseResponse.json();
throw new Error(`Top-up purchase failed: ${error.message}`);
}
return purchaseResponse.json();
};{
"subscriptionAddonId": "b58a1c7e-9d24-4f6a-8e13-5c2d7b9f0a46",
"subscriptionId": "d8174435-6378-4be5-a9f5-8b4aaadae5d4",
"status": "ACTIVE",
"productOffering": {
"productOfferingId": "addon-data-5gb",
"name": "Extra Data 5GB",
"price": {
"netPrice": 10,
"currency": "USD",
"priceType": "ONE_TIME"
}
},
"addedAt": "2026-07-12T10:15:00Z",
"updatedAt": "2026-07-12T10:15:00Z"
}
Once active, the top-up appears as an extra package in the usage response (Step 4) with its subscriptionAddonId set, so the usage meter can show “Extra Data 5GB: 0 of 5 GB used” alongside the base plan.
Step 8: Deliver eSIM Activation Codes
For eSIM subscriptions (sim.esim: true), let users retrieve their activation QR code directly from the portal instead of contacting support. The response contains both the raw LPA activation string and a hosted QR code image URL.
curl -X GET "https://apiv2.example.com/api/v2/subscriptions/d8174435-6378-4be5-a9f5-8b4aaadae5d4/esim/qrcode" \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "X-API-Key: YOUR_API_KEY"const getEsimQrCode = async (subscriptionId) => {
const response = await fetch(
`https://apiv2.example.com/api/v2/subscriptions/${subscriptionId}/esim/qrcode`,
{
method: 'GET',
headers: {
Authorization: 'Bearer YOUR_ACCESS_TOKEN',
'X-API-Key': 'YOUR_API_KEY',
},
},
);
if (!response.ok) {
const error = await response.json();
throw new Error(`Failed to get eSIM QR code: ${error.message}`);
}
return response.json();
};{
"subscriptionId": "d8174435-6378-4be5-a9f5-8b4aaadae5d4",
"qrCodeData": "LPA:1$rsp-prod.example.com$K2-1EA0C7-8834B2",
"qrCodeUrl": "https://esim.example.com/qr/d8174435-6378-4be5-a9f5-8b4aaadae5d4",
"expiresAt": "2026-07-13T10:15:00Z"
}
QR codes are sensitive — anyone who scans one can install the eSIM profile. They also expire (see
expiresAt); request a fresh QR code when the user opens the installation screen rather than
caching it.
Step 9: Show Invoices
List the customer’s invoices for a billing history page. Filter by status and date ranges (fromDate/toDate, dueDateFrom/dueDateTo); fetch a single invoice for its full line-item breakdown.
# List invoices for the customer
curl -X GET "https://apiv2.example.com/api/v2/invoices?customerId=6b1f44a0-52d3-4f7a-9b3e-2c8d5e91f0a4&status=SENT&status=PAID&status=OVERDUE" \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "X-API-Key: YOUR_API_KEY"
# Get a single invoice with line items
curl -X GET "https://apiv2.example.com/api/v2/invoices/094f10ca-616e-441c-b264-9a2305d6692d" \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "X-API-Key: YOUR_API_KEY"const listInvoices = async (customerId) => {
const query = new URLSearchParams({ customerId });
['SENT', 'PAID', 'OVERDUE'].forEach((status) => query.append('status', status));
const response = await fetch(`https://apiv2.example.com/api/v2/invoices?${query.toString()}`, {
method: 'GET',
headers: {
Authorization: 'Bearer YOUR_ACCESS_TOKEN',
'X-API-Key': 'YOUR_API_KEY',
},
});
if (!response.ok) {
const error = await response.json();
throw new Error(`Failed to list invoices: ${error.message}`);
}
const { items } = await response.json();
return items;
};
const getInvoice = async (invoiceId) => {
const response = await fetch(`https://apiv2.example.com/api/v2/invoices/${invoiceId}`, {
method: 'GET',
headers: {
Authorization: 'Bearer YOUR_ACCESS_TOKEN',
'X-API-Key': 'YOUR_API_KEY',
},
});
if (!response.ok) {
const error = await response.json();
throw new Error(`Failed to get invoice: ${error.message}`);
}
return response.json();
};A single invoice includes the line items, tax breakdown, and a hosted invoiceUrl you can link to for viewing or downloading:
{
"invoiceId": "094f10ca-616e-441c-b264-9a2305d6692d",
"customerId": "6b1f44a0-52d3-4f7a-9b3e-2c8d5e91f0a4",
"invoiceNumber": "INV-2026-0042",
"status": "SENT",
"dueDate": "2026-07-25",
"lineItems": [
{
"description": "Seamless 10GB - (206) 555-0142",
"subscriptionId": "d8174435-6378-4be5-a9f5-8b4aaadae5d4",
"productOfferingId": "cell-10gb",
"quantity": 1,
"unitPrice": 35,
"subtotal": 35,
"taxAmount": 3.15,
"taxIncluded": false,
"total": 38.15
},
{
"description": "Extra Data 5GB - (206) 555-0142",
"subscriptionId": "d8174435-6378-4be5-a9f5-8b4aaadae5d4",
"productOfferingId": "addon-data-5gb",
"quantity": 1,
"unitPrice": 10,
"subtotal": 10,
"taxAmount": 0.9,
"taxIncluded": false,
"total": 10.9
}
],
"subtotalAmount": 45,
"taxAmount": 4.05,
"totalAmount": 49.05,
"currency": "USD",
"sentAt": "2026-07-01T06:00:00Z",
"invoiceUrl": "https://invoices.example.com/094f10ca-616e-441c-b264-9a2305d6692d",
"createdAt": "2026-07-01T06:00:00Z",
"updatedAt": "2026-07-01T06:00:00Z"
}
Invoice status is one of DRAFT, SENT, PAID, VOID, or OVERDUE — highlight OVERDUE invoices prominently in the portal.
Step 10: Manage Saved Payment Methods
List the customer’s saved payment methods so users can see and manage what is on file. The displayName is safe to show as-is (for example “Visa ending in 4242”).
curl -X GET "https://apiv2.example.com/api/v2/payment-profiles?customerId=6b1f44a0-52d3-4f7a-9b3e-2c8d5e91f0a4" \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "X-API-Key: YOUR_API_KEY"const listPaymentProfiles = async (customerId) => {
const query = new URLSearchParams({ customerId });
const response = await fetch(
`https://apiv2.example.com/api/v2/payment-profiles?${query.toString()}`,
{
method: 'GET',
headers: {
Authorization: 'Bearer YOUR_ACCESS_TOKEN',
'X-API-Key': 'YOUR_API_KEY',
},
},
);
if (!response.ok) {
const error = await response.json();
throw new Error(`Failed to list payment profiles: ${error.message}`);
}
const { items } = await response.json();
return items;
};{
"items": [
{
"paymentProfileId": "e1f2a3b4-c5d6-7890-1234-f01234567890",
"paymentProvider": "STRIPE",
"type": "CARD",
"status": "ACTIVE",
"displayName": "Visa ending in 4242",
"isDefault": true,
"expiresAt": "2027-08-31",
"createdAt": "2026-03-10T14:25:11Z"
}
]
}
Profile status is ACTIVE, INACTIVE, EXPIRED, or REQUIRES_ACTION. Surface EXPIRED cards with a prompt to add a new payment method.
To save a new payment method, create a payment profile session and redirect the user to its hosted setup page. Payment profile sessions are always associated with an order (they are used for zero-cost orders where payment collection is not needed but a payment method must be set up) — see the payment processing guide for how orders, payment sessions, and payment profiles fit together.
# Create the session
curl -X POST "https://apiv2.example.com/api/v2/payment-profiles/sessions" \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "X-API-Key: YOUR_API_KEY" \
-H "X-Idempotency-Key: setup-payment-4a1c8e2f" \
-H "Content-Type: application/json" \
-d '{
"orderId": "9f8e7d6c-5b4a-3210-9876-543210987654",
"paymentProvider": "STRIPE",
"returnUrl": "https://portal.example.com/billing/payment-methods?setup=complete",
"cancelUrl": "https://portal.example.com/billing/payment-methods",
"setAsDefaultPaymentProfile": true
}'
# Check the session after the user returns
curl -X GET "https://apiv2.example.com/api/v2/payment-profiles/sessions/69321a62-f1fe-461f-8761-a19ae6587bb2" \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "X-API-Key: YOUR_API_KEY"const createPaymentProfileSession = async (orderId) => {
const response = await fetch('https://apiv2.example.com/api/v2/payment-profiles/sessions', {
method: 'POST',
headers: {
Authorization: 'Bearer YOUR_ACCESS_TOKEN',
'X-API-Key': 'YOUR_API_KEY',
'X-Idempotency-Key': `setup-payment-${crypto.randomUUID()}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
orderId,
paymentProvider: 'STRIPE',
returnUrl: 'https://portal.example.com/billing/payment-methods?setup=complete',
cancelUrl: 'https://portal.example.com/billing/payment-methods',
setAsDefaultPaymentProfile: true,
}),
});
if (!response.ok) {
const error = await response.json();
throw new Error(`Failed to create payment profile session: ${error.message}`);
}
const session = await response.json();
// Redirect the user to the hosted setup page
window.location.href = session.hostedUrl;
return session;
};
const getPaymentProfileSession = async (paymentProfileSessionId) => {
const response = await fetch(
`https://apiv2.example.com/api/v2/payment-profiles/sessions/${paymentProfileSessionId}`,
{
method: 'GET',
headers: {
Authorization: 'Bearer YOUR_ACCESS_TOKEN',
'X-API-Key': 'YOUR_API_KEY',
},
},
);
if (!response.ok) {
const error = await response.json();
throw new Error(`Failed to get payment profile session: ${error.message}`);
}
return response.json();
};{
"paymentProfileSessionId": "69321a62-f1fe-461f-8761-a19ae6587bb2",
"orderId": "9f8e7d6c-5b4a-3210-9876-543210987654",
"paymentProvider": "STRIPE",
"status": "PENDING",
"hostedUrl": "https://payments.example.com/setup/69321a62-f1fe-461f-8761-a19ae6587bb2",
"metadata": {},
"createdAt": "2026-07-12T10:40:00Z",
"updatedAt": "2026-07-12T10:40:00Z"
}
Session status moves through PENDING, REQUIRES_ACTION, and finally COMPLETED, FAILED, or CANCELED. When the user lands back on your returnUrl, fetch the session and refresh the payment profile list once it is COMPLETED. You can abandon an in-progress session with POST /payment-profiles/sessions/{paymentProfileSessionId}/cancel.
To remove a saved payment method:
curl -X DELETE "https://apiv2.example.com/api/v2/payment-profiles/e1f2a3b4-c5d6-7890-1234-f01234567890" \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "X-API-Key: YOUR_API_KEY"const deletePaymentProfile = async (paymentProfileId) => {
const response = await fetch(
`https://apiv2.example.com/api/v2/payment-profiles/${paymentProfileId}`,
{
method: 'DELETE',
headers: {
Authorization: 'Bearer YOUR_ACCESS_TOKEN',
'X-API-Key': 'YOUR_API_KEY',
},
},
);
if (!response.ok) {
const error = await response.json();
throw new Error(`Failed to delete payment profile: ${error.message}`);
}
// 204 No Content on success
};Deletion is permanent, and the customer’s default payment profile cannot be deleted — another
profile must be made the default first. Attempting to delete the default returns 409 Conflict.
Step 11: Cancel a Subscription
Offer self-service cancellation with structured churn feedback. The cancelAt field accepts exactly one of three timing options: {"nextDay": true}, {"nextMonth": true} (beginning of next month), or {"date": "2026-09-01"} for a specific date.
curl -X POST "https://apiv2.example.com/api/v2/subscriptions/d8174435-6378-4be5-a9f5-8b4aaadae5d4/cancel" \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "X-API-Key: YOUR_API_KEY" \
-H "X-Idempotency-Key: cancel-sub-8f2b6d4a" \
-H "Content-Type: application/json" \
-d '{
"cancelAt": { "nextMonth": true },
"churn": "NO_NEED",
"comment": "Moving abroad later this year"
}'const cancelSubscription = async (subscriptionId, churn, comment) => {
const response = await fetch(
`https://apiv2.example.com/api/v2/subscriptions/${subscriptionId}/cancel`,
{
method: 'POST',
headers: {
Authorization: 'Bearer YOUR_ACCESS_TOKEN',
'X-API-Key': 'YOUR_API_KEY',
'X-Idempotency-Key': `cancel-sub-${crypto.randomUUID()}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
cancelAt: { nextMonth: true },
churn,
comment,
}),
},
);
if (!response.ok) {
const error = await response.json();
throw new Error(`Cancellation failed: ${error.message}`);
}
const subscription = await response.json();
console.log('Cancellation scheduled for:', subscription.pendingStatus?.scheduledAt);
return subscription;
};The response is the subscription with the scheduled cancellation in pendingStatus:
{
"subscriptionId": "d8174435-6378-4be5-a9f5-8b4aaadae5d4",
"status": "ACTIVATED",
"type": "CELL",
"display": "(206) 555-0142",
"msisdn": "+12065550142",
"customer": {
"customerId": "6b1f44a0-52d3-4f7a-9b3e-2c8d5e91f0a4",
"name": "Emma Johnson"
},
"pendingStatus": {
"status": "CANCELLED",
"scheduledAt": "2026-08-01"
},
"sim": {
"esim": true,
"iccid": "89012608522901821364"
},
"activatedAt": "2026-03-15T09:30:00Z",
"createdAt": "2026-03-10T14:22:05Z",
"updatedAt": "2026-07-12T11:02:33Z"
}
Valid churn values: BETTER_DEAL_PRICE, NOT_HAPPY_MISSING_FUNCTIONS, NOT_HAPPY_COVERAGE_SLA, NOT_HAPPY_COMPLEX_ADMIN, NOT_HAPPY_SUPPORT_ENGAGEMENT, FRAUD, FRAUD_ATTEMPT, TEST_OR_MARKETING, NO_NEED, WRONG_ORDER, and OTHER. If the user picks OTHER, also collect a comment. Present these as a dropdown in the cancellation flow — the standardized reasons feed churn reporting.
Error Handling
All errors share a common shape with a machine-readable code, a human-readable message, optional per-field details, and sometimes a hint:
{
"message": "The request was malformed or invalid.",
"code": "BAD_REQUEST",
"details": [
{
"message": "must match pattern ^[0-9]{6}$",
"code": "INVALID_FORMAT",
"property": "code"
}
],
"hint": "Check the verification code and try again."
}
Handle the statuses that matter most in a portal:
- 401 Unauthorized — the JWT is missing or expired. Send the user back through the email login flow (Step 1).
- 403 Forbidden — the user’s token does not grant access to that resource. Never show another customer’s data; treat this as a hard stop, not a retry.
- 404 Not Found — the resource does not exist or is outside the user’s scope.
- 409 Conflict — a conflicting change is already pending, or an
X-Idempotency-Keywas reused with a modified request body. Refresh the resource and let the user retry deliberately. - 429 Too Many Requests — rate limited (the login endpoints in particular). Back off exponentially before retrying.
const withPortalErrorHandling = async (operation) => {
try {
return await operation();
} catch (error) {
if (error.status === 401) {
return redirectToLogin();
}
if (error.status === 429) {
return retryWithBackoff(operation);
}
console.error('Portal request failed:', error.message);
showErrorToast('Something went wrong. Please try again.');
throw error;
}
};
const retryWithBackoff = async (operation, maxRetries = 3) => {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
return await operation();
} catch (error) {
if (attempt === maxRetries) throw error;
const delay = Math.pow(2, attempt) * 1000;
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
};
Send an X-Idempotency-Key header on every mutating call (plan changes, add-on purchases, cancellations) so a double-click or a retried network request never applies the same change twice. Keys expire after 24 hours; use a fresh key per distinct operation.
Best Practices
- Scope with the user’s JWT, not filters: List endpoints already restrict results to what the authenticated user can access. Do not rely on client-side
customerIdfiltering for access control. - Show pending changes everywhere:
pendingProductOffering,pendingStatus, andpendingMsisdntell users what is already scheduled — surfacing them prevents duplicate change requests and confused support tickets. - Always fetch change options first: Only offer plans and add-ons returned by the
product-offering-optionsendpoints, and displaychangeScheduleDatebefore the user confirms. Submitting an offering that is not a valid option fails. - Refresh usage on view, not on a timer: Usage carries an
updatedAttimestamp; show it (“Updated 5 minutes ago”) instead of hammering the endpoint. - Keep tokens short-lived on shared devices: The access token’s
expiresInis the ceiling, not a target — clear tokens on logout and re-authenticate rather than persisting them long term.
Next Steps
Payment processing
Handle payment sessions, recurring billing, and payment failures
Product catalog
Model plans, add-ons, and pricing that power your self-service store
Common Questions
Q: How do end users get API access — do they need their own API keys? A: No. Your integration uses one API key, and each end user authenticates with the passwordless email flow to get a personal JWT. The JWT scopes every request to that user’s own subscriptions, invoices, and payment methods.
Q: Why is there no dedicated top-up endpoint?
A: Top-ups are modeled as one-time add-ons: SUBSCRIPTION_ADDON offerings in the PRODUCT_CATEGORY_EXTRA_DATA category with a ONE_TIME price. Purchasing one through the add-ons endpoint immediately grants an extra usage package.
Q: When does a plan change actually take effect?
A: It depends on the offering’s changeSchedule: INSTANT changes apply immediately, while FIRST_OF_NEXT_MONTH, NEXT_RENEWAL_DAY, and NEXT_PAYMENT_DAY changes are scheduled and appear under the subscription’s pendingProductOffering until they land.