Product management
Discover product catalogs and offerings, understand pricing and billing cycles, and use offerings in orders, addons, and subscription changes
Everything that a customer can buy through the API is a product offering: a mobile plan, a travel eSIM, an addon, and a license. This guide shows you how to find what is available and how to read a price. It then resolves the exact set of offerings that one customer can buy. Last, it puts an offering ID into an order and into a subscription change.
Prerequisites
You need all of these before you start:
- API credentials: A valid access token and API key for the API
- Customer context: Whether you are selling to
CONSUMERorBUSINESScustomers - Order basics: Familiarity with placing an order helps for the later steps
Overview
A typical catalog integration follows this flow:
Explore catalogs
List product catalogs to understand how offerings are segmented.
List offerings
Fetch product offerings, filtered by type, category, or catalog.
Interpret pricing
Read prices, billing cycles, and promotional discounts correctly.
Resolve per-customer catalogs
Fetch the exact offerings and groups available to one customer.
Sell and change
Use offering IDs in orders, addons, and subscription changes.
The object model
Four concepts make up the catalog, from the technical core outward:
Product
The technical definition of a service: its type, category, network provider, and included features (data, calls, SMS, coverage). Products are reusable — several offerings can wrap the same product at different prices.
Product offering
A product combined with a price. This is the unit customers actually buy, and its
productOfferingId is what you pass to orders, addons, and change endpoints.
Product offering group
Organizes related offerings of the same category — for example all mobile plans. Groups are the natural unit for rendering plan pickers and upgrade ladders.
Product catalog
A curated set of offerings for a context such as a customer segment, region, or sales channel. A catalog can extend the default catalog, inheriting all of its offerings.
Every offering carries its product inline, so one list call gives you the whole picture. The
product field says what the service is. The price field says what it costs. The group,
name, description, and imageUrl fields say how to present it.
Offering types and categories
The product.type field determines what buying the offering creates:
| Type | Creates | Examples |
|---|---|---|
SUBSCRIPTION | A standalone subscription with its own lifecycle | Mobile plan, broadband, travel eSIM |
SUBSCRIPTION_ADDON | A feature or resource attached to an existing subscription | Extra data package, travel eSIM package |
LICENSE | A license for business/PBX features | Enterprise telephony seat |
EXTERNAL_PRODUCT | A purchasable item outside the core telecom platform | Hardware, accessories |
The product.category field is a sub-type within each type, such as PRODUCT_CATEGORY_SUBSCRIPTION_CELL, PRODUCT_CATEGORY_SUBSCRIPTION_BROADBAND, PRODUCT_CATEGORY_TRAVEL_ESIM, or PRODUCT_CATEGORY_TRAVEL_ESIM_PACKAGE. Offerings of the same type and category are generally interchangeable — that is what makes upgrades and downgrades within a group possible.
Addon offerings additionally carry addonCategories: the subscription categories the addon can be
attached to. For example, a PRODUCT_CATEGORY_TRAVEL_ESIM_PACKAGE addon that applies to
PRODUCT_CATEGORY_TRAVEL_ESIM subscriptions.
Step-by-step implementation
Step 1: List product catalogs
Start by listing the catalogs configured for your tenant. Catalogs segment offerings by market or channel, and their IDs can be used to filter offering lists:
# List product catalogs, optionally filtered by name
curl -X GET "{BASE_URL}/product-catalogs?filter=US&limit=100" \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "X-API-Key: YOUR_API_KEY"const listProductCatalogs = async (filter) => {
const query = new URLSearchParams({ limit: '100' });
if (filter) {
query.set('filter', filter);
}
const response = await fetch(`{BASE_URL}/product-catalogs?${query}`, {
method: 'GET',
headers: {
Authorization: 'Bearer YOUR_ACCESS_TOKEN',
'X-API-Key': 'YOUR_API_KEY',
},
});
const { items, pagination } = await response.json();
console.log('Catalogs:', items.length, 'next cursor:', pagination.nextCursor);
return items;
};A catalog listing looks like this:
{
"items": [
{
"productCatalogId": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
"name": "US Consumer Catalog",
"description": "Consumer plans sold through the US web store",
"extendsDefault": true
},
{
"productCatalogId": "8d3e5f70-12ab-4cd6-9e8f-a01b23c45d67",
"name": "US Business Catalog",
"description": "Business plans with pooled data and licenses",
"extendsDefault": false
}
],
"pagination": {
"nextCursor": null
}
}extendsDefault tells you how a catalog is composed. When it is true, the catalog inherits
every offering of the default catalog and adds its own. When it is false, the catalog stands
alone, and it carries only the offerings assigned to it.
Step 2: List product offerings
Get the offerings themselves. The customerType parameter is required. Every other parameter
narrows the result:
types— filter by offering type (SUBSCRIPTION,SUBSCRIPTION_ADDON,LICENSE,EXTERNAL_PRODUCT)categories— filter by product categoryproductCatalogId— only offerings belonging to a specific catalogincludeArchived— includeARCHIVEDofferings (defaultfalse)countries/regions— coverage filters for travel eSIM offerings (see below)
# List consumer subscription offerings in a specific catalog
curl -X GET "{BASE_URL}/product-offerings?customerType=CONSUMER&types=SUBSCRIPTION&productCatalogId=f47ac10b-58cc-4372-a567-0e02b2c3d479&limit=100" \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "X-API-Key: YOUR_API_KEY"
# Fetch the next page using the cursor from the previous response
curl -X GET "{BASE_URL}/product-offerings?customerType=CONSUMER&types=SUBSCRIPTION&limit=100&cursor=NEXT_CURSOR_VALUE" \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "X-API-Key: YOUR_API_KEY"// List all consumer subscription offerings, following pagination
const listAllProductOfferings = async () => {
const offerings = [];
let cursor = null;
do {
const query = new URLSearchParams({
customerType: 'CONSUMER',
limit: '100',
});
query.append('types', 'SUBSCRIPTION');
query.set('productCatalogId', 'f47ac10b-58cc-4372-a567-0e02b2c3d479');
if (cursor) {
query.set('cursor', cursor);
}
const response = await fetch(`{BASE_URL}/product-offerings?${query}`, {
method: 'GET',
headers: {
Authorization: 'Bearer YOUR_ACCESS_TOKEN',
'X-API-Key': 'YOUR_API_KEY',
},
});
const page = await response.json();
offerings.push(...page.items);
cursor = page.pagination.nextCursor;
} while (cursor);
return offerings;
};Each item is a full ProductOffering with its product embedded:
{
"items": [
{
"productOfferingId": "0b54a9c2-7f13-4e8a-b2d6-91c37f5a04e8",
"status": "AVAILABLE",
"name": "Seamless 10GB",
"description": "10GB of high-speed data on nationwide 5G",
"customerType": "CONSUMER",
"product": {
"productId": "4c6a1e83-b25f-4d90-87ce-3f19a0d6b524",
"internalName": "seamless_cell_10gb_us",
"type": "SUBSCRIPTION",
"category": "PRODUCT_CATEGORY_SUBSCRIPTION_CELL",
"networkProviderId": "tmobile-us",
"features": {
"dataMb": 10240,
"includedCallSeconds": 60000,
"includedSms": 1000
}
},
"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
}
},
"group": {
"productOfferingGroupId": "mobile-plans",
"name": "Mobile Plans",
"description": "Cell subscriptions with data, calls, and SMS included",
"category": "PRODUCT_CATEGORY_SUBSCRIPTION_CELL"
},
"imageUrl": "https://cdn.example.com/images/seamless-10gb.png"
}
],
"pagination": {
"nextCursor": null
}
}The product.features object tells you what the service includes. A cellular plan carries
dataMb, includedCallSeconds, and includedSms. A travel eSIM package carries
validityDays, countries, regions, and activationType.
When you show the details of an existing subscription, pass includeArchived=true, or get the
offering by its ID. Nobody can order an ARCHIVED offering any more, but an existing subscription
can still point at one, and the lookup then comes back empty.
To fetch a single offering — for example to render a detail page or re-validate before checkout — use its ID:
curl -X GET "{BASE_URL}/product-offerings/0b54a9c2-7f13-4e8a-b2d6-91c37f5a04e8" \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "X-API-Key: YOUR_API_KEY"const getProductOffering = async (productOfferingId) => {
const response = await fetch(`{BASE_URL}/product-offerings/${productOfferingId}`, {
method: 'GET',
headers: {
Authorization: 'Bearer YOUR_ACCESS_TOKEN',
'X-API-Key': 'YOUR_API_KEY',
},
});
if (!response.ok) {
throw new Error(`Offering lookup failed: ${response.status}`);
}
return response.json();
};Step 3: Understand pricing and billing cycles
Every offering carries one price object. Read it with these fields:
| Field | Meaning |
|---|---|
netPriceMinor | The offering’s configured price for one billing period, with no discount deducted |
currency | The currency code, such as USD |
priceType | ONE_TIME for a single charge, RECURRING for repeated billing |
billingCycle | For recurring prices: the billing period (MONTHLY) and interval (1 = every month, 3 = every three months) |
standardDiscount | An unconditional discount: amountMinor per billing period, and an optional duration |
bindingContract | A commitment to keep the subscription for a fixed duration, and the discount granted in exchange |
customUpfrontPayment | Billing cycles the customer pays for in advance at checkout, and the discount granted for doing so |
currencyOptionsMinor | Per-currency price overrides keyed by ISO currency code, for offerings sold in multiple currencies |
A price is the catalog entry, not a quote. Do not charge a customer from it. Each field
reports the offering exactly as it is configured. netPriceMinor has no discount deducted, not
even the discounts on the same object. The price also knows nothing about the customer that reads
it, so it carries no promotion and no negotiated price list.
The order is the one place that resolves a discount, a promotion, a price list, and the tax. Add
the offering to an order, then read
the order’s pricing. That answer is
what the customer pays.
Every monetary amount is an integer in the minor units of its currency. A minor unit is one
hundredth of the major unit, for every currency that the platform bills in. As a result, 2999
is $29.99 in USD, and 299.00 kr in SEK. Divide by 100 to display an amount.
The decimal netPrice, currencyOptions, discount and discountMinor fields are gone from
the response. An older revision still receives the first two. See Versioning.
Only currency and priceType are always present. Every example in this
documentation shows the same offering with each optional field filled in, so that you see the
whole shape in one place. A real offering carries only the discounts and the currency options
that it is configured with. Read all of them as optional.
The two price types match two selling motions:
RECURRING— A subscription, a license, and a recurring addon. ThebillingCyclegives the cadence.{ "period": "MONTHLY", "interval": 1 }bills every month.ONE_TIME— One charge, such as a travel eSIM package or an external product. There is nobillingCycle.
A recurring price is quoted for one billing period, not for one charge. Bill and total
netPriceMinor × billingCycle.interval, and display the per-period figure. An interval of more
than 1 collects that many periods at once. A netPriceMinor of 2999 with a MONTHLY period and
an interval of 3 charges 8997 every three months, not 2999.
Discounts
An offering can carry up to three discounts, each with its own condition. This is the Seamless 10GB price used throughout this documentation:
{
"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 }
}
}Read that price this way. The plan lists at $29.99 a month. A customer that takes it as sold pays $22.99. The $5.00 standard discount and the $2.00 binding-contract discount come off when the order is priced. A customer that also prepays three billing cycles pays $19.99 per period, which is $59.97 at checkout. From the fourth month the upfront discount stops and the price returns to $22.99.
None of that comes off netPriceMinor, which stays 2999. Each discount has its own
condition, and the order decides which ones apply:
standardDiscount— unconditional. It applies to every purchase of the offering.bindingContract.discount— applies when the subscription is bound fordurationmonths.customUpfrontPayment.discount— applies when the customer paysbillingCyclescycles in advance at checkout. Itsdurationcovers those cycles, and the price then returns to the full amount.
A discount amountMinor is per billing period, like the price itself. It is never a total.
{ "amountMinor": 300 } takes $3.00 off every period, not $3.00 once. On a quarterly price it
comes off all three periods of each invoice.
You can subtract the discounts yourself to show an indicative price before an order exists. That is what the fields are for. But the order price is the number that you charge.
Discounts that expire
A discount can carry a duration, which makes it an introductory offer rather than the standing price:
{
"standardDiscount": {
"amountMinor": 500,
"duration": { "unit": "MONTHS", "value": 3 }
}
}That takes $5.00 off each of the first three months, $15.00 in all. After that the customer pays
the full price. A discount with no duration never stops.
Read duration to find out whether a saving that you advertise has an end date. When it does,
say so: “$24.99/mo for 3 months, then $29.99”.
currencyOptionsMinor is the price of the same offering in the other currencies of the catalog.
This plan is $29.99 in the US and 299.00 kr in Sweden. The key that matches currency repeats
netPriceMinor.
Promotional pricing
Promo codes belong to the order, not to the catalog. Set promoCode when you create or update the order, then read the order back:
# Apply the promo code to the order
curl -X PUT "{BASE_URL}/orders/296af5b6-f3b3-4128-b307-5ddc9190502f" \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "X-API-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "promoCode": "SPRING25" }'
# Read the order back to see what the customer pays
curl "{BASE_URL}/orders/296af5b6-f3b3-4128-b307-5ddc9190502f" \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "X-API-Key: YOUR_API_KEY"const priceOrderWithPromoCode = async (orderId, promoCode) => {
const updateResponse = await fetch(`{BASE_URL}/orders/${orderId}`, {
method: 'PUT',
headers: {
Authorization: 'Bearer YOUR_ACCESS_TOKEN',
'X-API-Key': 'YOUR_API_KEY',
'Content-Type': 'application/json',
},
body: JSON.stringify({ promoCode }),
});
if (!updateResponse.ok) {
const error = await updateResponse.json();
throw new Error(`Failed to apply promo code: ${error.message}`);
}
const response = await fetch(`{BASE_URL}/orders/${orderId}`, {
headers: {
Authorization: 'Bearer YOUR_ACCESS_TOKEN',
'X-API-Key': 'YOUR_API_KEY',
},
});
if (!response.ok) {
const error = await response.json();
throw new Error(`Reading the order failed: ${error.message}`);
}
const { pricing } = await response.json();
const formatted = new Intl.NumberFormat('en-US', {
style: 'currency',
currency: pricing.currency,
}).format(pricing.totalMinor / 100);
console.log(`Total with ${promoCode}: ${formatted}`);
return pricing;
};To examine a code before an order exists, call Get promotion. It tells you whether the code is valid, and what discount it carries.
Step 4: Fetch a customer’s product catalog
To find out what one customer can buy, ask the API. Do not filter the global list yourself. The customer catalog endpoint merges the default catalog with every catalog assigned to that customer, and it returns a result that you can render directly:
# By internal customer UUID
curl -X GET "{BASE_URL}/customers/5b8f3c72-94d1-4a06-8e2b-c1d7f0a63e94/product-catalog" \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "X-API-Key: YOUR_API_KEY"
# By external reference identifier (rid_ prefix)
curl -X GET "{BASE_URL}/customers/rid_crm-customer-12345/product-catalog" \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "X-API-Key: YOUR_API_KEY"const getCustomerCatalog = async (customerId) => {
const response = await fetch(`{BASE_URL}/customers/${customerId}/product-catalog`, {
method: 'GET',
headers: {
Authorization: 'Bearer YOUR_ACCESS_TOKEN',
'X-API-Key': 'YOUR_API_KEY',
},
});
const catalog = await response.json();
// Group offerings by their group for display
const byGroup = new Map();
for (const offering of catalog.productOfferings ?? []) {
const groupId = offering.group?.productOfferingGroupId ?? 'ungrouped';
const bucket = byGroup.get(groupId) ?? [];
bucket.push(offering);
byGroup.set(groupId, bucket);
}
return { groups: catalog.productOfferingGroups ?? [], byGroup };
};The response contains the groups and the offerings side by side:
{
"productOfferingGroups": [
{
"productOfferingGroupId": "mobile-plans",
"name": "Mobile Plans",
"description": "Cell subscriptions with data, calls, and SMS included",
"category": "PRODUCT_CATEGORY_SUBSCRIPTION_CELL"
}
],
"productOfferings": [
{
"productOfferingId": "0b54a9c2-7f13-4e8a-b2d6-91c37f5a04e8",
"status": "AVAILABLE",
"name": "Seamless 10GB",
"customerType": "CONSUMER",
"product": {
"productId": "4c6a1e83-b25f-4d90-87ce-3f19a0d6b524",
"internalName": "seamless_cell_10gb_us",
"type": "SUBSCRIPTION",
"category": "PRODUCT_CATEGORY_SUBSCRIPTION_CELL"
},
"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
}
},
"group": {
"productOfferingGroupId": "mobile-plans",
"name": "Mobile Plans",
"category": "PRODUCT_CATEGORY_SUBSCRIPTION_CELL"
}
},
{
"productOfferingId": "6d2f8e11-3c49-4b7a-a5e0-84b9d1c72f35",
"status": "AVAILABLE",
"name": "Seamless 25GB",
"customerType": "CONSUMER",
"product": {
"productId": "4c6a1e83-b25f-4d90-87ce-3f19a0d6b524",
"internalName": "seamless_cell_25gb_us",
"type": "SUBSCRIPTION",
"category": "PRODUCT_CATEGORY_SUBSCRIPTION_CELL"
},
"price": {
"netPriceMinor": 3999,
"currency": "USD",
"priceType": "RECURRING",
"billingCycle": {
"period": "MONTHLY",
"interval": 1
},
"standardDiscount": { "amountMinor": 500 },
"bindingContract": {
"duration": {
"unit": "MONTHS",
"value": 12
},
"discount": {
"amountMinor": 300
}
},
"customUpfrontPayment": {
"billingCycles": 3,
"discount": {
"amountMinor": 400,
"duration": { "unit": "MONTHS", "value": 3 }
}
},
"currencyOptionsMinor": {
"USD": 3999,
"SEK": 39900
}
},
"group": {
"productOfferingGroupId": "mobile-plans",
"name": "Mobile Plans",
"category": "PRODUCT_CATEGORY_SUBSCRIPTION_CELL"
}
}
]
}Customer identifiers can be internal UUIDs or your own reference identifiers. Reference
identifiers must be prefixed with rid_ (for example rid_crm-customer-12345) so the API can
distinguish them from UUIDs.
Step 5: Use offerings in orders and addons
The productOfferingId is the currency of the rest of the platform. In an order, each line item names the offering it purchases:
# Create an order with a subscription line item for a chosen offering
curl -X POST "{BASE_URL}/orders" \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "X-API-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"customerType": "CONSUMER",
"lineItems": [
{
"type": "SUBSCRIPTION",
"lineItemId": "line-item-1",
"productOfferingId": "0b54a9c2-7f13-4e8a-b2d6-91c37f5a04e8",
"sim": { "esim": true }
}
]
}'const createOrderForOffering = async (productOfferingId) => {
const response = await fetch('{BASE_URL}/orders', {
method: 'POST',
headers: {
Authorization: 'Bearer YOUR_ACCESS_TOKEN',
'X-API-Key': 'YOUR_API_KEY',
'Content-Type': 'application/json',
},
body: JSON.stringify({
customerType: 'CONSUMER',
lineItems: [
{
type: 'SUBSCRIPTION',
lineItemId: 'line-item-1',
productOfferingId,
sim: { esim: true },
},
],
}),
});
return response.json();
};Addon offerings (type: SUBSCRIPTION_ADDON) attach to an existing subscription instead. Pick an addon whose addonCategories includes the subscription’s category, then add it:
# Add a travel eSIM package to an existing travel eSIM subscription
curl -X POST "{BASE_URL}/subscriptions/e7a12b90-45cd-4f38-9a61-08b3d5c2ef47/addons" \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "X-API-Key: YOUR_API_KEY" \
-H "X-Idempotency-Key: addon-e7a12b90-2a91cf64" \
-H "Content-Type: application/json" \
-d '{
"productOfferingId": "2a91cf64-8e05-47d3-b18c-f60a24d9e573"
}'const addAddon = async (subscriptionId, productOfferingId, scheduledAt) => {
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': `addon-${subscriptionId}-${productOfferingId}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
productOfferingId,
// Optional: schedule the add-on for a future date (YYYY-MM-DD)
...(scheduledAt ? { scheduledAt } : {}),
}),
});
if (response.status !== 201) {
throw new Error(`Add-on failed: ${response.status}`);
}
return response.json();
};Step 6: Discover and apply subscription changes
For upgrades and downgrades, never guess which offerings a subscription can move to. The change-options endpoint returns exactly what the subscription can become and when each change can take effect:
curl -X GET "{BASE_URL}/subscriptions/e7a12b90-45cd-4f38-9a61-08b3d5c2ef47/product-offering-options" \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "X-API-Key: YOUR_API_KEY"const getChangeOptions = 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',
},
},
);
const { items } = await response.json();
return items;
};Each option pairs an offering with a change schedule:
{
"items": [
{
"productOffering": {
"productOfferingId": "6d2f8e11-3c49-4b7a-a5e0-84b9d1c72f35",
"name": "Seamless 25GB",
"price": {
"netPriceMinor": 3999,
"currency": "USD",
"priceType": "RECURRING",
"billingCycle": {
"period": "MONTHLY",
"interval": 1
},
"standardDiscount": { "amountMinor": 500 },
"bindingContract": {
"duration": {
"unit": "MONTHS",
"value": 12
},
"discount": {
"amountMinor": 300
}
},
"customUpfrontPayment": {
"billingCycles": 3,
"discount": {
"amountMinor": 400,
"duration": { "unit": "MONTHS", "value": 3 }
}
},
"currencyOptionsMinor": {
"USD": 3999,
"SEK": 39900
}
}
},
"changeSchedule": "INSTANT",
"changeScheduleDate": "2026-07-12"
},
{
"productOffering": {
"productOfferingId": "9f3b6d84-2c71-4a5e-b90d-57e1f8a3c266",
"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"
}
]
}The changeSchedule values are:
| Schedule | Takes effect |
|---|---|
INSTANT | Immediately |
FIRST_OF_NEXT_MONTH | On the first day of the next calendar month |
NEXT_RENEWAL_DAY | On the subscription’s next renewal date |
NEXT_PAYMENT_DAY | At the end of the prepaid period, on the next payment day |
As a rule of thumb, upgrades and lateral moves are immediate while downgrades wait for the next renewal — but always trust changeSchedule and changeScheduleDate over assumptions. To apply a change, pass the chosen offering to the change endpoint:
curl -X PUT "{BASE_URL}/subscriptions/e7a12b90-45cd-4f38-9a61-08b3d5c2ef47/product-offering-change" \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "X-API-Key: YOUR_API_KEY" \
-H "X-Idempotency-Key: change-e7a12b90-20260712" \
-H "Content-Type: application/json" \
-d '{
"productOfferingId": "6d2f8e11-3c49-4b7a-a5e0-84b9d1c72f35"
}'const changeSubscriptionOffering = async (subscriptionId, productOfferingId, earliestDate) => {
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': `change-${subscriptionId}-${productOfferingId}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
productOfferingId,
// Optional: earliest date (YYYY-MM-DD) to perform the change on.
// If the change schedule does not fit this date, the earliest date after it is chosen.
...(earliestDate ? { scheduledAt: earliestDate } : {}),
}),
},
);
const subscription = await response.json();
console.log('Change scheduled for subscription:', subscription.subscriptionId);
return subscription;
};The same options-then-change pattern exists for addons and licenses:
GET /subscriptions/{subscriptionId}/addons/product-offering-options?currentProductOfferingId=...andPUT /subscriptions/{subscriptionId}/addons/product-offering-changefor changing an existing addonGET /licenses/{licenseId}/product-offering-optionsandPUT /licenses/{licenseId}/product-offering-changefor licenses
Filtering travel eSIM offerings by coverage
Travel eSIM packages carry coverage in product.features.countries (ISO 3166-1 alpha-3 codes) and product.features.regions. To build a destination picker, first fetch the full coverage map:
# List all countries and regions covered by travel eSIM offerings
curl -X GET "{BASE_URL}/product-offerings/countries?customerType=CONSUMER" \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "X-API-Key: YOUR_API_KEY"
# Then list offerings that cover the selected destination
curl -X GET "{BASE_URL}/product-offerings?customerType=CONSUMER&types=SUBSCRIPTION_ADDON&categories=PRODUCT_CATEGORY_TRAVEL_ESIM_PACKAGE&countries=MEX" \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "X-API-Key: YOUR_API_KEY"const getTravelCoverage = async () => {
const query = new URLSearchParams({ customerType: 'CONSUMER' });
const response = await fetch(`{BASE_URL}/product-offerings/countries?${query}`, {
method: 'GET',
headers: {
Authorization: 'Bearer YOUR_ACCESS_TOKEN',
'X-API-Key': 'YOUR_API_KEY',
},
});
const { countries, regions } = await response.json();
return { countries, regions };
};
const listPackagesForCountry = async (countryCode) => {
const query = new URLSearchParams({ customerType: 'CONSUMER' });
query.append('types', 'SUBSCRIPTION_ADDON');
query.append('categories', 'PRODUCT_CATEGORY_TRAVEL_ESIM_PACKAGE');
query.append('countries', countryCode);
const response = await fetch(`{BASE_URL}/product-offerings?${query}`, {
method: 'GET',
headers: {
Authorization: 'Bearer YOUR_ACCESS_TOKEN',
'X-API-Key': 'YOUR_API_KEY',
},
});
const { items } = await response.json();
return items;
};The coverage response deduplicates countries across all offerings and lists each region with its constituent countries:
{
"countries": [
{ "code": "USA", "name": "United States" },
{ "code": "CAN", "name": "Canada" },
{ "code": "MEX", "name": "Mexico" }
],
"regions": [{ "region": "NORTH_AMERICA", "countries": ["USA", "CAN", "MEX"] }]
}A matching travel eSIM package offering looks like this — note the one-time price, the coverage features, and addonCategories binding it to travel eSIM subscriptions:
{
"productOfferingId": "2a91cf64-8e05-47d3-b18c-f60a24d9e573",
"status": "AVAILABLE",
"name": "North America 5GB",
"customerType": "CONSUMER",
"addonCategories": ["PRODUCT_CATEGORY_TRAVEL_ESIM"],
"product": {
"productId": "d80e6f21-5a4c-49b7-93d2-6c1e8b0f47a9",
"internalName": "travel_esim_na_5gb",
"type": "SUBSCRIPTION_ADDON",
"category": "PRODUCT_CATEGORY_TRAVEL_ESIM_PACKAGE",
"features": {
"dataMb": 5120,
"validityDays": 30,
"countries": ["USA", "CAN", "MEX"],
"regions": ["NORTH_AMERICA"],
"activationType": "FIRST_USE"
}
},
"price": {
"netPriceMinor": 1900,
"currency": "USD",
"priceType": "ONE_TIME",
"standardDiscount": {
"amountMinor": 200
},
"currencyOptionsMinor": {
"USD": 1900,
"SEK": 18900
}
}
}The country filter matches an offering that lists the country, and an offering that belongs
to a region with that country in it. A filter of MEX thus finds a Mexico-only package and
this North America package.
Best practices
Catalog data handling
- Cache the catalog and the offering data with a short TTL. Do not get them on every page view. An offering changes far less often than it is read.
- Identify an offering in your own systems by its
productOfferingId. For a stable mapping across environments, useproduct.internalNameormetadata. Never match on the displayname. - Price the order before checkout, and charge that number. A catalog price carries no discount, no promotion, no price list, and no tax, so it drifts away from what the customer pays.
Presentation
- Drive your plan picker from
group. Render one section perproductOfferingGroup, and sort the offerings in it byprice.netPriceMinor. - Show what a discounted price becomes when its discount ends.
standardDiscount.durationandbindingContract.durationcarry the end date.netPriceMinoralone does not. - Use
richContenton a detail page anddescriptionon a card. Both are optional, so keep a fallback for each. - Obey
customerType. A consumer and a business see different offerings, and the parameter is required on every list call.
Lifecycle safety
- Take the options endpoints as the authority on an upgrade and a downgrade. A raw catalog listing does not know the network, the billing cycle, or the current offering of the subscription.
- Send an
X-Idempotency-Keyheader on an addon request and on a change request. Every retry of that one request must carry the same key and the same body, and the change then happens once. A new key starts a separate operation, and a key expires after 24 hours. - Expect an
ARCHIVEDoffering on an existing subscription, and handle it in your rendering and in your reporting.
Next steps
With catalog discovery in place, put the offering IDs to work:
Place an order
Turn a chosen product offering into a draft order, price it, and collect payment
Customer self-service
Let customers browse their catalog and change plans from your own UI
Common questions
Q: What is the difference between a product and a product offering? A: A product is the technical definition of a service: its network, its features, and its category. A product offering wraps a product with a price and a presentation. An order and a subscription always point at the offering, not at the product.
Q: Why does the same offering show different prices at different times? A: It does not. The price of an offering is the catalog entry, and it is the same for every caller. It changes only when somebody edits the offering. What differs per customer is what they pay. The order resolves that amount. It reads the discounts on the offering, and the promo code, the price list, and the tax of that customer.
Q: Can I change a subscription to any offering in the catalog?
A: No. Call GET /subscriptions/{subscriptionId}/product-offering-options for the valid
targets. The platform limits a change by category, by network setup, and by billing cycle. The
response also tells you when each change can take effect.
Q: What happens to a subscription when its offering is archived?
A: The subscription keeps running on the archived offering. Archiving stops a new purchase, and
nothing else. Pass includeArchived=true when you need an archived offering in a list response.