GuidesWalkthroughs

Working with the product catalog

Filtering, denominations, availability, blacklists, and a sensible caching strategy across vouchers, topups, and eSIM

The product catalog is thousands of products across three verticals — vouchers, topups (gaming + mobile), and eSIM — covering 100+ countries. It changes: new products added, discount tiers adjusted, some products occasionally unavailable. This guide covers how to browse each vertical efficiently, which fields matter when about to order, and how to keep a local cache fresh without hammering the API.

The endpoints

Each vertical has its own catalog surface. The shape varies a little — vouchers carry denominations inline on the product, topup and eSIM expose a separate /variants endpoint — but the integration patterns (filter, list, fetch detail, cache) are the same.

VerticalListDetailVariants / denominationsAvailability
VouchersGET /api/v1/productsGET /api/v1/products/:idInline on the product (available_denominations[])GET /api/v1/products/:id/availability
TopupsGET /api/v1/topups/productsGET /api/v1/topups/products/:idGET /api/v1/topups/products/:id/variants— (status returned in the order response)
eSIMGET /api/v1/esim/productsGET /api/v1/esim/products/:id (inlines variants)GET /api/v1/esim/products/:id/variants— (status returned in the order response)

Full per-field shapes: Voucher products, Topup products, eSIM products.

Filtering the list

Filters narrow results server-side — far cheaper than pulling the whole catalog and filtering client-side. Each vertical has slightly different filter knobs.

Vouchers

curl "$HOST/api/v1/products?country_id=1&category=Gaming&limit=25" \
  -H "Authorization: Bearer $TOKEN"
FilterUse
country_id (numeric)Restrict to a country (resolve via /currencies)
categoryGaming, Shopping, Entertainment, etc.
currency_idAll products priced in a specific currency
searchCase-insensitive name match
sort_by + sort_dirOrdering (name, id)

Topups (gaming + mobile)

curl "$HOST/api/v1/topups/products?type=GAMING&search=pubg&limit=25" \
  -H "Authorization: Bearer $TOKEN"
FilterUse
typeGAMING, MOBILE, or UTILITY
searchCase-insensitive name match

The same /topups/products endpoint serves both gaming top-ups and mobile recharges. The type discriminates: GAMING for in-game currency / item packs, MOBILE for mobile carriers, UTILITY for things like prepaid utility recharges. The list endpoint itself does not accept a country filter — each product carries its own country_code (ISO 3166-1 alpha-3) for grouping client-side, and operator-level country discovery from a phone number runs through the lookup endpoint below.

For mobile recharges specifically, pair the catalog with a lookup endpoint:

curl -X POST "$HOST/api/v1/topups/lookup" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"mobile_number": "+918879685100"}'

The lookup returns the matching MOBILE products for an MSISDN — handy when the operator can't be inferred from the country alone. See Mobile recharge flow for the full pattern.

eSIM

curl "$HOST/api/v1/esim/products?country_code=JPN&limit=25" \
  -H "Authorization: Bearer $TOKEN"
FilterUse
country_code (ISO 3166-1 alpha-3)E.g. JPN, USA, GLO for global products
searchCase-insensitive name match

Country values aren't shared across verticals

Vouchers use a numeric country_id on filters. Topups and eSIM use three-letter country_code strings on the product payload (alpha-3, e.g. IND, JPN, USA, GLO for global). Resolve the numeric country_id via GET /api/v1/countries when matching across the three.

Responses are paginated across all three verticals — read X-Total-Count and X-Total-Pages from the response headers to know how many pages to fetch.

Picking a denomination

The three verticals model denominations differently. Pick the right one for the vertical before placing an order.

Vouchers — inline available_denominations[]

{
  "id": 123,
  "name": "Steam Wallet Card",
  "category": "Gaming",
  "country_code": "USA",
  "currency_code": "USD",
  "delivery_mode": "Code with PIN",
  "delivery_time": "Instant",
  "validity": "12 months",
  "available_denominations": [
    { "min_value": 10.00, "max_value": 10.00, "discount": 3.0 },
    { "min_value": 50.00, "max_value": 50.00, "discount": 3.5 },
    { "min_value": 100.00, "max_value": 100.00, "discount": 3.5 }
  ]
}
FieldWhy it matters
idGoes into product_id on order requests
currency_codeDrives FX decisions
available_denominationsThe only denominations accepted in an order
delivery_modeCode with PIN vs URL — UI needs to know
delivery_timeInstant vs Delayed — sets user expectations
validityHow long the code is valid after delivery

Denomination rules:

  • Fixed denominationsmin_value == max_value. Must send exactly that amount.
  • Variable rangesmin_value < max_value. Any amount in [min, max] works.
  • Multiple entries — mix and match. Some products support both fixed ($50, $100) and variable ($10–$50).

Sending an out-of-range denomination returns 400 Bad Request — Denomination not available.

discount is a percentage off the face value (3.5 = 3.5% off), not an absolute amount. Discounts can differ per denomination — bigger denominations often have better margins.

Topups — /variants endpoint

curl "$HOST/api/v1/topups/products/4218/variants" \
  -H "Authorization: Bearer $TOKEN"

Variants carry the SKU-level pricing. They come in three flavours:

CategoryShapeExample
Open-range (Airtime)min_amount / max_amount set, is_fixed_amount: falseTop up any amount between ₹10 and ₹10,000
Fixed denominations (Data, Bundle, Gaming)fixed_amounts: ["10", "20", "50"]1 GB / 28 days for ₹179, 2 GB / 28 days for ₹239
Gaming SKUsFixed denominations, often a small set60 UC for $0.99, 325 UC for $4.99

When placing the order, pass product_id + amount directly — Octopus Cards picks the matching variant. The variants endpoint is purely for rendering ("pick a pack") and for validating amounts client-side.

The category query parameter on /topups/charges and /topups/orders constrains variant selection — useful when a single product offers both Airtime and Data variants and a clear category split is wanted in the UI.

eSIM — /variants endpoint, fixed-denomination only

curl "$HOST/api/v1/esim/products/712/variants" \
  -H "Authorization: Bearer $TOKEN"

eSIM variants are always fixed-denomination — each variant is a specific data + validity combo at a specific price:

[
  { "id": 9001, "amount": 4.50, "data_amount_gb": 1.0, "validity_days": 7, "currency_code": "USD" },
  { "id": 9002, "amount": 14.90, "data_amount_gb": 5.0, "validity_days": 30, "currency_code": "USD" },
  { "id": 9003, "amount": 39.90, "data_amount_gb": 20.0, "validity_days": 30, "currency_code": "USD" }
]

Order placement uses (product_id, amount) to resolve a variant — exactly like topups. Don't compute the amount client-side; echo the variant's amount back. Quantity is always 1.

GET /api/v1/esim/products/:id also inlines the variants, so a single product-detail fetch is sufficient if the UI only needs the variants for the chosen country.

Availability & blacklists

Availability

For certain voucher products (especially country-restricted or limited-inventory ones), check availability before committing:

curl "$HOST/api/v1/products/123/availability" \
  -H "Authorization: Bearer $TOKEN"

Useful for:

  • Removing temporarily-unavailable products from the UI.
  • Switching the customer's selection to a similar in-stock alternative.

Topup and eSIM verticals do not expose a separate availability endpoint — they surface unavailability through the order response (status: FAILED with a clear failure_code) and through the lookup endpoint (topups) returning an empty operator list.

Blacklists

A client account may have specific products blacklisted across any vertical — not visible, not orderable. Blacklisted products:

  • Don't appear in list endpoints at all.
  • Return 404 Product not found on direct GET …/:id.
  • Return 404 on order attempts.

If products are "missing", that's either blacklisting or a country/currency filter mismatch. Check with the account manager to enable something.

Caching strategy

Catalog data is long-lived — products don't change every minute. Cache aggressively, with shorter TTLs for things that move (discounts, denominations) and longer ones for true reference data (countries, currencies).

DataTTLReason
Countries24 hoursNearly immutable
Currencies24 hoursNearly immutable
Categories / subcategories12 hoursEditorial changes occasionally
Voucher list (a filter combo)1 hourNew products added, some delisted
Voucher detail (single product)15 minutesDiscounts and denominations retuned
Topup product list1 hourSimilar churn
Topup variants15 minutesOperator-side denominations change occasionally
eSIM product list1 hourCountry catalogues are stable
eSIM variants1 hourPlans change rarely
Charges (pricing)5 minutesServer already caches this; respect it
Voucher availabilityDo not cacheAlways fresh if it matters
Mobile lookup resultsDo not cachePer-MSISDN result, low volume
Wallets & transactionsDo not cacheLive state

Stale-while-revalidate works well across all three: serve the cached value immediately, kick off a background refresh if past TTL.

Pre-warm the caches at startup

For a multi-country catalog spanning all three verticals, loading every page is expensive. Pre-warm just the active countries + categories per vertical at boot, lazy-load the rest.

Keeping the catalog fresh

Two channels for updates:

  1. Poll. Re-fetch the filtered list on the TTL above. Simple, reliable, costs a few reads per hour.
  2. Subscribe to product.update webhooks. Fires when any product visible to the account is added, changed, or delisted. React by invalidating that product's cache entry. See Voucher Webhooks. Topup and eSIM product webhooks are not yet shipped — polling is the recommended path for those two verticals today.

For most integrations, polling is enough. Adopt webhooks once the catalog size or change rate makes polling wasteful.

Handling "product not found" at order time

Even with a fresh cache, a product can become unavailable between the cache read and the order:

{
  "error": { "name": "NotFoundError", "code": "NOT_FOUND", "message": "Product not found" }
}

Same recovery pattern across all three verticals:

  1. Invalidate the cache entry for that product.
  2. Re-fetch the product (a follow-up GET …/:id will return 404 if it's truly gone).
  3. If gone: surface to the customer, suggest an alternative.
  4. If returned: retry the order (new client_reference or the same if retry logic expects it).

Don't blindly retry without invalidating — that just loops.

Searching for a specific product

All three verticals support a search query parameter that matches against the product name, case-insensitive:

curl "$HOST/api/v1/products?search=steam&country_id=1" \
  -H "Authorization: Bearer $TOKEN"

curl "$HOST/api/v1/topups/products?type=GAMING&search=pubg" \
  -H "Authorization: Bearer $TOKEN"

curl "$HOST/api/v1/esim/products?search=japan" \
  -H "Authorization: Bearer $TOKEN"

Combine search with country_id (vouchers) or country_code (eSIM), or with type for topups, to narrow results — many popular brands have regional variants.

If a specific product seen earlier is needed, use its ID rather than searching by name — names can occasionally be edited.

Summary

  • Three vertical-specific catalog surfaces; same filter-list-detail-cache integration pattern across all three.
  • Vouchers carry denominations inline on the product; topups and eSIM expose them via /variants.
  • Country fields: voucher uses a numeric country_id for filtering; topup and eSIM expose a three-letter country_code (alpha-3) on the product payload — the /topups/products list doesn't accept a country filter (type discriminates instead), and the eSIM product list accepts country_code directly.
  • For mobile recharges, pair the catalog with /topups/lookup to resolve an MSISDN to operator products.
  • Cache reference data (countries / currencies / categories) for hours; product data for tens of minutes; pricing for minutes; availability and live state never.
  • Voucher product.update webhooks exist today; topup and eSIM catalog updates are polling-only for now.
  • NOT_FOUND at order time → invalidate the cache, re-fetch, then retry or surface to the customer.

On this page