---
title: Customer self-service
description: 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](/developer-guide/use-cases/product-management.md))
- **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:

```bash
curl -X POST "{BASE_URL}/auth/email/start" \
  -H "Content-Type: application/json" \
  -d '{
    "email": "emma.johnson@example.com"
  }'
```

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:
```json
{
  "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:

```bash
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"
  }'
```

On success you receive an OAuth2-compatible token response:
```json
{
  "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.

> **Note**
>
> 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.

```bash
curl -X GET "{BASE_URL}/users/f47ac10b-58cc-4372-a567-0e02b2c3d479" \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
  -H "X-API-Key: YOUR_API_KEY"
```

```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`.

```bash
# 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"
```

Each subscription embeds everything a portal detail page needs — phone number, SIM details, and the current plan with pricing:
```json
{
  "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.

```bash
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"
```

```json
{
  "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:

```bash
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"
```

### 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.

```bash
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"
```

```json
{
  "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.

```bash
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"
  }'
```

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`:
```json
{
  "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.

```bash
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"
```

```json
{
  "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.

```bash
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"
```

#### Add an addon to the subscription

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

```bash
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"
  }'
```

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).

```bash
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"
```

```json
{
  "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`.

```bash
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"
  }'
```

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.

```bash
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"
  }'
```

A scheduled cancellation shows up in the addon's `pendingStatus`:
```json
{
  "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](/developer-guide/use-cases/product-management.md).

```bash
# 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"
  }'
```

```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.

```bash
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"
```

```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"
}
```

> **Warning**
>
> 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.

```bash
# 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"
```

A single invoice includes the line items, tax breakdown, and a hosted `invoiceUrl` you can link to for viewing or downloading:
```json
{
  "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").

```bash
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"
```

```json
{
  "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](/developer-guide/use-cases/payment-processing.md) explains how orders,
payment sessions, and payment profiles fit together.

```bash
# 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"
```

```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:

```bash
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"
```

> **Warning**
>
> 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.

```bash
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"
  }'
```

The response is the subscription with the scheduled cancellation in `pendingStatus`:
```json
{
  "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`:
```json
{
  "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.
```javascript
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

- [Payment processing](/developer-guide/use-cases/payment-processing.md) — Handle payment sessions, recurring billing, and payment failures
- [Product management](/developer-guide/use-cases/product-management.md) — Model plans, addons, 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 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.
