telnesstech

Customer self-service

Build a portal in which a customer reads their usage, changes their plan, and manages their own subscriptions

Build a self-service portal for your end users. In it a user signs in, reads their subscriptions and their remaining data, and changes their plan up or down. The user also buys an addon or a topup, downloads an eSIM, and manages their invoices and payment methods. None of this needs a call to support.

Prerequisites

You need all of these before you start:

  • API key: An API key created in the portal, sent as the X-API-Key header on every request
  • Product management: Familiarity with product offerings and pricing (see the product management 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 implements these flows:

  1. Authenticate the end user with passwordless email login
  2. Load the user’s profile and customer context
  3. Show the user’s subscriptions and current usage
  4. Change plans (upgrades and downgrades)
  5. Manage addons and sell data topups
  6. Deliver eSIM activation QR codes
  7. Show invoices and manage saved payment methods
  8. Cancel service with structured churn feedback

Every request carries two credentials. Your API key in X-API-Key identifies your integration. The JWT of the user in Authorization: Bearer ... limits the request to what that user can see and do. A user lists their own subscriptions, invoices, and payment methods, and nothing else. The API applies this limit whichever API key you send.

Step-by-step implementation

Step 1: Authenticate the end user

An end user signs in through a passwordless email flow. Start the login, and the API sends a 6-digit verification code. Send the code back, and the API answers with a JWT access token.

Start the login flow:

curl -X POST "{BASE_URL}/auth/email/start" \
  -H "Content-Type: application/json" \
  -d '{
    "email": "emma.johnson@example.com"
  }'
const startEmailLogin = async (email) => {
  const response = await fetch('{BASE_URL}/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 "{BASE_URL}/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('{BASE_URL}/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"
}

Store the accessToken safely. Send it as Authorization: Bearer YOUR_ACCESS_TOKEN on every later request, beside your X-API-Key. A verification code expires after expiresIn seconds, which is 300 seconds in this example. If a code expires, start a new login.

The two login endpoints need no authentication header. Both are rate limited, and both answer 429 Too Many Requests when you reach the limit.

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 "{BASE_URL}/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(`{BASE_URL}/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 "{BASE_URL}/subscriptions?status=ACTIVATED" \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
  -H "X-API-Key: YOUR_API_KEY"

# Get a single subscription
curl -X GET "{BASE_URL}/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('{BASE_URL}/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(`{BASE_URL}/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": {
      "netPriceMinor": 2999,
      "currency": "USD",
      "priceType": "RECURRING",
      "billingCycle": {
        "period": "MONTHLY",
        "interval": 1
      },
      "standardDiscount": { "amountMinor": 500 },
      "bindingContract": {
        "duration": {
          "unit": "MONTHS",
          "value": 12
        },
        "discount": {
          "amountMinor": 200
        }
      },
      "customUpfrontPayment": {
        "billingCycles": 3,
        "discount": {
          "amountMinor": 300,
          "duration": { "unit": "MONTHS", "value": 3 }
        }
      },
      "currencyOptionsMinor": {
        "USD": 2999,
        "SEK": 29900
      }
    }
  },
  "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 "{BASE_URL}/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(`{BASE_URL}/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 addon 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 "{BASE_URL}/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(`{BASE_URL}/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.

The date a subscription can change 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 "{BASE_URL}/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(
    `{BASE_URL}/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": {
          "netPriceMinor": 5499,
          "currency": "USD",
          "priceType": "RECURRING",
          "billingCycle": {
            "period": "MONTHLY",
            "interval": 1
          },
          "standardDiscount": { "amountMinor": 500 },
          "bindingContract": {
            "duration": {
              "unit": "MONTHS",
              "value": 12
            },
            "discount": {
              "amountMinor": 500
            }
          },
          "customUpfrontPayment": {
            "billingCycles": 3,
            "discount": {
              "amountMinor": 500,
              "duration": { "unit": "MONTHS", "value": 3 }
            }
          },
          "currencyOptionsMinor": {
            "USD": 5499,
            "SEK": 54900
          }
        }
      },
      "changeSchedule": "INSTANT",
      "changeScheduleDate": "2026-07-12"
    },
    {
      "productOffering": {
        "productOfferingId": "cell-5gb",
        "name": "Seamless 5GB",
        "price": {
          "netPriceMinor": 1999,
          "currency": "USD",
          "priceType": "RECURRING",
          "billingCycle": {
            "period": "MONTHLY",
            "interval": 1
          },
          "standardDiscount": { "amountMinor": 300 },
          "bindingContract": {
            "duration": {
              "unit": "MONTHS",
              "value": 12
            },
            "discount": {
              "amountMinor": 200
            }
          },
          "customUpfrontPayment": {
            "billingCycles": 3,
            "discount": {
              "amountMinor": 200,
              "duration": { "unit": "MONTHS", "value": 3 }
            }
          },
          "currencyOptionsMinor": {
            "USD": 1999,
            "SEK": 19900
          }
        }
      },
      "changeSchedule": "NEXT_RENEWAL_DAY",
      "changeScheduleDate": "2026-08-01"
    }
  ]
}

changeSchedule tells you when each option takes effect:

  • INSTANT — change takes effect immediately
  • FIRST_OF_NEXT_MONTH — first day of the next calendar month
  • NEXT_RENEWAL_DAY — next renewal date
  • NEXT_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 productOfferingId that the user selected. The offering decides when the change takes effect, and that date follows from the network setup and the billing cycle. You can also pass scheduledAt as the earliest date for the change. If the change schedule does not permit that date, the API takes the first permitted date after it.

curl -X PUT "{BASE_URL}/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(
    `{BASE_URL}/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": {
      "netPriceMinor": 2999,
      "currency": "USD",
      "priceType": "RECURRING",
      "billingCycle": {
        "period": "MONTHLY",
        "interval": 1
      },
      "standardDiscount": { "amountMinor": 500 },
      "bindingContract": {
        "duration": {
          "unit": "MONTHS",
          "value": 12
        },
        "discount": {
          "amountMinor": 200
        }
      },
      "customUpfrontPayment": {
        "billingCycles": 3,
        "discount": {
          "amountMinor": 300,
          "duration": { "unit": "MONTHS", "value": 3 }
        }
      },
      "currencyOptionsMinor": {
        "USD": 2999,
        "SEK": 29900
      }
    }
  },
  "pendingProductOffering": {
    "scheduledAt": "2026-08-01",
    "product": {
      "productOfferingId": "cell-5gb",
      "name": "Seamless 5GB",
      "price": {
        "netPriceMinor": 1999,
        "currency": "USD",
        "priceType": "RECURRING",
        "billingCycle": {
          "period": "MONTHLY",
          "interval": 1
        },
        "standardDiscount": { "amountMinor": 300 },
        "bindingContract": {
          "duration": {
            "unit": "MONTHS",
            "value": 12
          },
          "discount": {
            "amountMinor": 200
          }
        },
        "customUpfrontPayment": {
          "billingCycles": 3,
          "discount": {
            "amountMinor": 200,
            "duration": { "unit": "MONTHS", "value": 3 }
          }
        },
        "currencyOptionsMinor": {
          "USD": 1999,
          "SEK": 19900
        }
      }
    }
  },
  "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 addons

List active addons

Get all active and pending addons currently attached to a subscription. Filter by status (PENDING, ACTIVE, CANCELLED, EXPIRED) if needed.

curl -X GET "{BASE_URL}/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(`{BASE_URL}/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": {
          "netPriceMinor": 1499,
          "currency": "USD",
          "priceType": "RECURRING",
          "billingCycle": {
            "period": "MONTHLY",
            "interval": 1
          },
          "standardDiscount": { "amountMinor": 200 },
          "bindingContract": {
            "duration": {
              "unit": "MONTHS",
              "value": 12
            },
            "discount": {
              "amountMinor": 100
            }
          },
          "customUpfrontPayment": {
            "billingCycles": 3,
            "discount": {
              "amountMinor": 100,
              "duration": { "unit": "MONTHS", "value": 3 }
            }
          },
          "currencyOptionsMinor": {
            "USD": 1499,
            "SEK": 14900
          }
        }
      },
      "addedAt": "2026-05-01T12:00:00Z",
      "updatedAt": "2026-05-01T12:00:00Z"
    }
  ]
}

Find addons available to purchase

To build a store of the addons that a user can buy, list the product offerings with types=SUBSCRIPTION_ADDON. The customerType parameter is required. Use categories to narrow the list, such as PRODUCT_CATEGORY_EXTRA_DATA for a data package or PRODUCT_CATEGORY_ABROAD for roaming. The addonCategories field of an addon offering lists the subscription categories that it attaches to.

curl -X GET "{BASE_URL}/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(`{BASE_URL}/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 addon to the subscription

Add the chosen offering to the subscription. The addon activates immediately, or on scheduledAt if provided.

curl -X POST "{BASE_URL}/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(`{BASE_URL}/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 addon, including its subscriptionAddonId for later changes or cancellation.

Get change options for a subscription addon

Get all product offerings an existing addon can be changed to and when the change can take effect. Pass the addon’s current offering as currentProductOfferingId (required).

curl -X GET "{BASE_URL}/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(
    `{BASE_URL}/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": {
          "netPriceMinor": 2499,
          "currency": "USD",
          "priceType": "RECURRING",
          "billingCycle": {
            "period": "MONTHLY",
            "interval": 1
          },
          "standardDiscount": { "amountMinor": 300 },
          "bindingContract": {
            "duration": {
              "unit": "MONTHS",
              "value": 12
            },
            "discount": {
              "amountMinor": 200
            }
          },
          "customUpfrontPayment": {
            "billingCycles": 3,
            "discount": {
              "amountMinor": 200,
              "duration": { "unit": "MONTHS", "value": 3 }
            }
          },
          "currencyOptionsMinor": {
            "USD": 2499,
            "SEK": 24900
          }
        }
      },
      "changeSchedule": "INSTANT",
      "changeScheduleDate": "2026-07-12"
    }
  ]
}

Change a subscription addon’s product offering

Change an existing addon to a different offering (upgrade or downgrade). Identify the addon with its subscriptionAddonId.

curl -X PUT "{BASE_URL}/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(
    `{BASE_URL}/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 addon. Like plan changes, a scheduled change appears under the addon’s pendingProductOffering until it takes effect.

Cancel an addon

Cancel an active addon. Without scheduledAt, the addon is cancelled immediately or according to the default schedule.

curl -X POST "{BASE_URL}/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(`{BASE_URL}/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 addon’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": {
      "netPriceMinor": 1499,
      "currency": "USD",
      "priceType": "RECURRING",
      "billingCycle": {
        "period": "MONTHLY",
        "interval": 1
      },
      "standardDiscount": { "amountMinor": 200 },
      "bindingContract": {
        "duration": {
          "unit": "MONTHS",
          "value": 12
        },
        "discount": {
          "amountMinor": 100
        }
      },
      "customUpfrontPayment": {
        "billingCycles": 3,
        "discount": {
          "amountMinor": 100,
          "duration": { "unit": "MONTHS", "value": 3 }
        }
      },
      "currencyOptionsMinor": {
        "USD": 1499,
        "SEK": 14900
      }
    }
  },
  "pendingStatus": {
    "status": "CANCELLED",
    "scheduledAt": "2026-08-01"
  },
  "addedAt": "2026-05-01T12:00:00Z",
  "updatedAt": "2026-07-12T09:30:12Z"
}

Step 7: Sell data topups

A data topup is a one-time addon: a SUBSCRIPTION_ADDON offering in the PRODUCT_CATEGORY_EXTRA_DATA category with a ONE_TIME price. The flow is the same as any addon purchase — find the offering, then add it to the subscription.

The price of an offering is the catalog price. It carries no discount, no promotion, and no price list. Present it as the list price. The amount that the customer pays is settled when the addon is invoiced. For the full rules, read interpreting pricing.

# Find available data top-up offerings
curl -X GET "{BASE_URL}/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 "{BASE_URL}/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(`{BASE_URL}/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];
  const price = new Intl.NumberFormat('en-US', {
    style: 'currency',
    currency: topUp.price.currency,
  }).format(topUp.price.netPriceMinor / 100);
  console.log(`Buying ${topUp.name} for ${price}`);

  const purchaseResponse = await fetch(`{BASE_URL}/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": {
      "netPriceMinor": 1000,
      "currency": "USD",
      "priceType": "ONE_TIME",
      "standardDiscount": {
        "amountMinor": 100
      },
      "currencyOptionsMinor": {
        "USD": 1000,
        "SEK": 9900
      }
    }
  },
  "addedAt": "2026-07-12T10:15:00Z",
  "updatedAt": "2026-07-12T10:15:00Z"
}

After the topup is active, it appears as an extra package in the usage response of Step 4, with its subscriptionAddonId set. Your usage meter can then show “Extra Data 5GB: 0 of 5 GB used” beside 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 "{BASE_URL}/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(`{BASE_URL}/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"
}

Do not cache a QR code. Request a new one when the user opens the installation screen. Anybody who scans a QR code can install the eSIM profile, and each code expires at its expiresAt.

Step 9: Show invoices

List the invoices of the customer for a billing history page. Filter by status, and by the date ranges fromDate/toDate and dueDateFrom/dueDateTo. Get one invoice to read its full line-item breakdown.

# List invoices for the customer
curl -X GET "{BASE_URL}/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 "{BASE_URL}/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(`{BASE_URL}/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(`{BASE_URL}/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,
      "unitPriceMinor": 2999,
      "subtotalMinor": 2999,
      "taxAmountMinor": 270,
      "taxIncluded": false,
      "totalMinor": 3269
    },
    {
      "description": "Extra Data 5GB - (206) 555-0142",
      "subscriptionId": "d8174435-6378-4be5-a9f5-8b4aaadae5d4",
      "productOfferingId": "addon-data-5gb",
      "quantity": 1,
      "unitPriceMinor": 1000,
      "subtotalMinor": 1000,
      "taxAmountMinor": 90,
      "taxIncluded": false,
      "totalMinor": 1090
    }
  ],
  "subtotalAmountMinor": 3999,
  "taxAmountMinor": 360,
  "totalAmountMinor": 4359,
  "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 "{BASE_URL}/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(`{BASE_URL}/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. Then send the user to its hosted page. A payment profile session always belongs to an order. Its purpose is an order with a total of zero, where no payment is due but a payment method must be stored. The payment processing guide explains how orders, payment sessions, and payment profiles fit together.

# Create the session
curl -X POST "{BASE_URL}/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 "{BASE_URL}/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('{BASE_URL}/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(`{BASE_URL}/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 "{BASE_URL}/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(`{BASE_URL}/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 "{BASE_URL}/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(`{BASE_URL}/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 token of the user does not reach that resource. Stop there. Never show the data of another customer, and never retry the request.
  • 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-Key was 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 call that changes something: a plan change, an addon purchase, and a cancellation. The same change then never happens twice after a double-click or a retried request. A key expires after 24 hours. Use a new key for each distinct operation.

Best practices

  • Scope with the JWT of the user, not with a filter. A list endpoint already restricts its results to what the authenticated user can reach. Do not use a customerId filter in your own code as access control.
  • Show every pending change. pendingProductOffering, pendingStatus, and pendingMsisdn tell the user what is scheduled already. Show all three. A user who sees them does not request the same change twice.
  • Get the change options first. Offer only the plans and the addons that the product-offering-options endpoints return, and show changeScheduleDate before the user accepts the change. An offering that is not in the options fails on submit.
  • Refresh the usage when the user opens the view, not on a timer. The usage carries an updatedAt timestamp. Show it, as in “Updated 5 minutes ago”.
  • Keep a token short-lived on a shared device. The expiresIn of an access token is the maximum, not a target. Erase the token at logout and authenticate again.

Next steps

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 topup endpoint? A: Topups are modeled as one-time addons: SUBSCRIPTION_ADDON offerings in the PRODUCT_CATEGORY_EXTRA_DATA category with a ONE_TIME price. Purchasing one through the addons 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.