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.
| Vertical | List | Detail | Variants / denominations | Availability |
|---|---|---|---|---|
| Vouchers | GET /api/v1/products | GET /api/v1/products/:id | Inline on the product (available_denominations[]) | GET /api/v1/products/:id/availability |
| Topups | GET /api/v1/topups/products | GET /api/v1/topups/products/:id | GET /api/v1/topups/products/:id/variants | — (status returned in the order response) |
| eSIM | GET /api/v1/esim/products | GET /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"| Filter | Use |
|---|---|
country_id (numeric) | Restrict to a country (resolve via /currencies) |
category | Gaming, Shopping, Entertainment, etc. |
currency_id | All products priced in a specific currency |
search | Case-insensitive name match |
sort_by + sort_dir | Ordering (name, id) |
Topups (gaming + mobile)
curl "$HOST/api/v1/topups/products?type=GAMING&search=pubg&limit=25" \
-H "Authorization: Bearer $TOKEN"| Filter | Use |
|---|---|
type | GAMING, MOBILE, or UTILITY |
search | Case-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"| Filter | Use |
|---|---|
country_code (ISO 3166-1 alpha-3) | E.g. JPN, USA, GLO for global products |
search | Case-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 }
]
}| Field | Why it matters |
|---|---|
id | Goes into product_id on order requests |
currency_code | Drives FX decisions |
available_denominations | The only denominations accepted in an order |
delivery_mode | Code with PIN vs URL — UI needs to know |
delivery_time | Instant vs Delayed — sets user expectations |
validity | How long the code is valid after delivery |
Denomination rules:
- Fixed denominations —
min_value == max_value. Must send exactly that amount. - Variable ranges —
min_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:
| Category | Shape | Example |
|---|---|---|
Open-range (Airtime) | min_amount / max_amount set, is_fixed_amount: false | Top 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 SKUs | Fixed denominations, often a small set | 60 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 foundon directGET …/:id. - Return
404on 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).
| Data | TTL | Reason |
|---|---|---|
| Countries | 24 hours | Nearly immutable |
| Currencies | 24 hours | Nearly immutable |
| Categories / subcategories | 12 hours | Editorial changes occasionally |
| Voucher list (a filter combo) | 1 hour | New products added, some delisted |
| Voucher detail (single product) | 15 minutes | Discounts and denominations retuned |
| Topup product list | 1 hour | Similar churn |
| Topup variants | 15 minutes | Operator-side denominations change occasionally |
| eSIM product list | 1 hour | Country catalogues are stable |
| eSIM variants | 1 hour | Plans change rarely |
| Charges (pricing) | 5 minutes | Server already caches this; respect it |
| Voucher availability | Do not cache | Always fresh if it matters |
| Mobile lookup results | Do not cache | Per-MSISDN result, low volume |
| Wallets & transactions | Do not cache | Live 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:
- Poll. Re-fetch the filtered list on the TTL above. Simple, reliable, costs a few reads per hour.
- Subscribe to
product.updatewebhooks. 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:
- Invalidate the cache entry for that product.
- Re-fetch the product (a follow-up
GET …/:idwill return404if it's truly gone). - If gone: surface to the customer, suggest an alternative.
- If returned: retry the order (new
client_referenceor 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_idfor filtering; topup and eSIM expose a three-lettercountry_code(alpha-3) on the product payload — the/topups/productslist doesn't accept a country filter (typediscriminates instead), and the eSIM product list acceptscountry_codedirectly. - For mobile recharges, pair the catalog with
/topups/lookupto 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.updatewebhooks exist today; topup and eSIM catalog updates are polling-only for now. NOT_FOUNDat order time → invalidate the cache, re-fetch, then retry or surface to the customer.