telnesstech

Product Catalog

Discover product catalogs and offerings, understand pricing and billing cycles, and use offerings in orders, add-ons, and subscription changes

Everything a customer can buy through the Connect API — mobile plans, travel eSIMs, add-ons, licenses — is represented as a product offering. This guide shows you how to discover what is available, interpret pricing, resolve the exact set of offerings a specific customer may purchase, and feed offering IDs into orders and subscription changes.

Prerequisites

Before you begin, ensure you have:

  • API Credentials: A valid access token and API key for the Connect API
  • Customer Context: Whether you are selling to CONSUMER or BUSINESS customers
  • Order Basics: Familiarity with placing an order helps for the later steps

Overview

A typical catalog integration follows this flow:

  1. Explore catalogs

    List product catalogs to understand how offerings are segmented.

  2. List offerings

    Fetch product offerings, filtered by type, category, or catalog.

  3. Interpret pricing

    Read prices, billing cycles, and promotional discounts correctly.

  4. Resolve per-customer catalogs

    Fetch the exact offerings and groups available to one customer.

  5. Sell and change

    Use offering IDs in orders, add-ons, 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, add-ons, 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 a single list call gives you the full picture: what the service is (product), what it costs (price), and how to present it (group, name, description, imageUrl).

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.

Add-on offerings additionally carry addonCategories: the subscription categories the add-on can be attached to. For example, a PRODUCT_CATEGORY_TRAVEL_ESIM_PACKAGE add-on 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 "https://apiv2.example.com/api/v2/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(`https://apiv2.example.com/api/v2/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 composes: when true, the catalog inherits every offering from the default catalog and adds its own on top; when false, it stands alone with only its explicitly assigned offerings.

Step 2: List Product Offerings

Fetch the offerings themselves. The customerType parameter is required; everything else narrows the result:

  • types — filter by offering type (SUBSCRIPTION, SUBSCRIPTION_ADDON, LICENSE, EXTERNAL_PRODUCT)
  • categories — filter by product category
  • productCatalogId — only offerings belonging to a specific catalog
  • includeArchived — include ARCHIVED offerings (default false)
  • promoCode — apply promotional pricing to the returned prices
  • countries / regions — coverage filters for travel eSIM offerings (see below)
# List consumer subscription offerings in a specific catalog
curl -X GET "https://apiv2.example.com/api/v2/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 "https://apiv2.example.com/api/v2/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(`https://apiv2.example.com/api/v2/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": {
        "netPrice": 25,
        "currency": "USD",
        "priceType": "RECURRING",
        "billingCycle": {
          "period": "MONTHLY",
          "interval": 1
        }
      },
      "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 — dataMb, includedCallSeconds, and includedSms for cellular plans; validityDays, countries, regions, and activationType for travel eSIM packages.

An ARCHIVED offering can no longer be ordered, but existing subscriptions may still reference it. When rendering details for an existing subscription, pass includeArchived=true (or fetch the offering directly by ID) so lookups do not come 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 "https://apiv2.example.com/api/v2/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(
    `https://apiv2.example.com/api/v2/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
netPrice The final amount after all discounts are applied — the number to display and charge
discount The discount amount off the original price (present when a discount or promo applies)
currency The currency code, e.g. 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)
boundMonths For recurring prices: the number of months the price is contractually bound
currencyOptions Per-currency price overrides keyed by ISO currency code, for offerings sold in multiple currencies

The two price types map to two selling motions:

  • RECURRING — subscriptions, licenses, and recurring add-ons. The billingCycle describes cadence: { "period": "MONTHLY", "interval": 1 } bills every month.
  • ONE_TIME — single charges such as travel eSIM packages or external products. No billingCycle is present.

Promotional pricing is applied server-side: pass a promoCode query parameter to the offering list, single offering, or customer catalog endpoints, and discounted prices come back with discount and a reduced netPrice:

# Fetch an offering with a promo code applied
curl -X GET "https://apiv2.example.com/api/v2/product-offerings/0b54a9c2-7f13-4e8a-b2d6-91c37f5a04e8?promoCode=SPRING25" \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
  -H "X-API-Key: YOUR_API_KEY"
const getDiscountedOffering = async (productOfferingId, promoCode) => {
  const query = new URLSearchParams({ promoCode });

  const response = await fetch(
    `https://apiv2.example.com/api/v2/product-offerings/${productOfferingId}?${query}`,
    {
      method: 'GET',
      headers: {
        Authorization: 'Bearer YOUR_ACCESS_TOKEN',
        'X-API-Key': 'YOUR_API_KEY',
      },
    },
  );

  const offering = await response.json();
  const { netPrice, discount, currency } = offering.price;
  console.log(`Price with ${promoCode}: ${netPrice} ${currency} (discount ${discount ?? 0})`);
  return offering;
};

With SPRING25 applied, the price block reflects the discount:

{
  "price": {
    "discount": 5,
    "netPrice": 20,
    "currency": "USD",
    "priceType": "RECURRING",
    "billingCycle": {
      "period": "MONTHLY",
      "interval": 1
    }
  }
}

Step 4: Fetch a Customer’s Product Catalog

To know what one specific customer may buy, do not filter the global list yourself — ask the API. The customer catalog endpoint merges the default catalog with every catalog assigned to that customer and returns the result ready to render:

# By internal customer UUID
curl -X GET "https://apiv2.example.com/api/v2/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 "https://apiv2.example.com/api/v2/customers/rid_crm-customer-12345/product-catalog" \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
  -H "X-API-Key: YOUR_API_KEY"
const getCustomerCatalog = async (customerId, promoCode) => {
  const query = new URLSearchParams();
  if (promoCode) {
    query.set('promoCode', promoCode);
  }

  const response = await fetch(
    `https://apiv2.example.com/api/v2/customers/${customerId}/product-catalog?${query}`,
    {
      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": {
        "netPrice": 25,
        "currency": "USD",
        "priceType": "RECURRING",
        "billingCycle": { "period": "MONTHLY", "interval": 1 }
      },
      "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": {
        "netPrice": 40,
        "currency": "USD",
        "priceType": "RECURRING",
        "billingCycle": { "period": "MONTHLY", "interval": 1 }
      },
      "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 Add-Ons

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 "https://apiv2.example.com/api/v2/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('https://apiv2.example.com/api/v2/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();
};

Add-on offerings (type: SUBSCRIPTION_ADDON) attach to an existing subscription instead. Pick an add-on whose addonCategories includes the subscription’s category, then add it:

# Add a travel eSIM package to an existing travel eSIM subscription
curl -X POST "https://apiv2.example.com/api/v2/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(
    `https://apiv2.example.com/api/v2/subscriptions/${subscriptionId}/addons`,
    {
      method: 'POST',
      headers: {
        Authorization: 'Bearer YOUR_ACCESS_TOKEN',
        'X-API-Key': 'YOUR_API_KEY',
        'X-Idempotency-Key': `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 "https://apiv2.example.com/api/v2/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(
    `https://apiv2.example.com/api/v2/subscriptions/${subscriptionId}/product-offering-options`,
    {
      method: 'GET',
      headers: {
        Authorization: 'Bearer YOUR_ACCESS_TOKEN',
        'X-API-Key': 'YOUR_API_KEY',
      },
    },
  );

  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": {
          "netPrice": 40,
          "currency": "USD",
          "priceType": "RECURRING",
          "billingCycle": { "period": "MONTHLY", "interval": 1 }
        }
      },
      "changeSchedule": "INSTANT",
      "changeScheduleDate": "2026-07-12"
    },
    {
      "productOffering": {
        "productOfferingId": "9f3b6d84-2c71-4a5e-b90d-57e1f8a3c266",
        "name": "Seamless 5GB",
        "price": {
          "netPrice": 15,
          "currency": "USD",
          "priceType": "RECURRING",
          "billingCycle": { "period": "MONTHLY", "interval": 1 }
        }
      },
      "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 "https://apiv2.example.com/api/v2/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(
    `https://apiv2.example.com/api/v2/subscriptions/${subscriptionId}/product-offering-change`,
    {
      method: 'PUT',
      headers: {
        Authorization: 'Bearer YOUR_ACCESS_TOKEN',
        'X-API-Key': 'YOUR_API_KEY',
        'X-Idempotency-Key': `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 add-ons and licenses:

  • GET /subscriptions/{subscriptionId}/addons/product-offering-options?currentProductOfferingId=... and PUT /subscriptions/{subscriptionId}/addons/product-offering-change for changing an existing add-on
  • GET /licenses/{licenseId}/product-offering-options and PUT /licenses/{licenseId}/product-offering-change for 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 "https://apiv2.example.com/api/v2/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 "https://apiv2.example.com/api/v2/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(
    `https://apiv2.example.com/api/v2/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(`https://apiv2.example.com/api/v2/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": {
    "netPrice": 19,
    "currency": "USD",
    "priceType": "ONE_TIME"
  }
}

The country filter matches offerings that list the country explicitly or belong to a region that includes it, so filtering by MEX finds both a Mexico-only package and this North America package.

Best Practices

Catalog Data Handling

  • Cache catalog and offering data with a short TTL rather than fetching on every page view; offerings change far less often than they are read
  • Identify offerings in your own systems by productOfferingId, and use product.internalName or metadata for stable cross-environment mapping — never match on display name
  • Re-fetch the offering (or the customer catalog) with the active promoCode right before checkout so displayed and charged prices cannot drift

Presentation

  • Drive plan pickers from group — render one section per productOfferingGroup and sort offerings within it by price.netPrice
  • Use richContent for detail pages and description for cards; both are optional, so fall back gracefully
  • Respect customerType: consumer and business customers see different offerings, and the parameter is required on list calls

Lifecycle Safety

  • Treat the options endpoints as the source of truth for upgrades and downgrades; a raw catalog listing does not know the subscription’s network, billing cycle, or current offering
  • Send an X-Idempotency-Key header on add-on and change requests so retries never double-apply
  • Expect ARCHIVED offerings on existing subscriptions and handle them in rendering and reporting

Next Steps

With catalog discovery in place, put the offering IDs to work:

Common Questions

Q: What is the difference between a product and a product offering? A: A product is the technical service definition (network, features, category); a product offering wraps a product with a price and presentation. Orders and subscriptions always reference the offering, not the product.

Q: Why does the same offering show different prices at different times? A: Prices reflect the request context. A promoCode parameter applies promotional discounts (discount and a reduced netPrice), and currencyOptions may override the price per currency.

Q: Can I change a subscription to any offering in the catalog? A: No. Use GET /subscriptions/{subscriptionId}/product-offering-options to see the valid targets — the platform constrains changes by category, network setup, and billing cycle, and tells you when each change can take effect.

Q: What happens to subscriptions when their offering is archived? A: They keep running on the archived offering. Archiving only prevents new purchases; use includeArchived=true when you need archived offerings in list responses.