Appearance
Catalog API
The Catalog API returns the complete product catalog with hierarchical structure and field configurations. This endpoint is designed for device caching — fetch once, render dynamically.
Migrating from /v2/product-list?
This endpoint replaces the legacy Product Listing API. See the Migration Guide for endpoint mapping, field changes, and a migration checklist.
Endpoint
http
GET https://api.iimmpact.com/v2/catalogRequest Headers
| Header | Description | Required |
|---|---|---|
X-Api-Key | Your API key | Yes |
X-Timestamp | Current Unix timestamp in seconds | Yes |
X-Nonce | Unique nonce for replay protection | Yes |
X-Signature | HMAC signature in format v1=<base64> | Yes |
See API Key Authentication for canonical-string rules and signature generation.
Query Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
product_code | string | No | Return only one product in products map |
is_active | boolean | No | Filter by catalog active status first, then reseller override status (true/false) |
include_hidden | boolean | No | Include hidden products. Default is false |
Response Headers
This endpoint currently does not guarantee conditional-cache headers (Last-Modified / ETag). Use the response body last_updated field for cache freshness checks.
Response Structure
json
{
"last_updated": "2025-01-07T00:00:00Z",
"tree": {
"groups": [...]
},
"products": {
"D": {...},
"M": {...}
}
}| Field | Type | Description |
|---|---|---|
last_updated | string | ISO 8601 timestamp of last catalog update |
tree | object | Hierarchical structure for UI navigation |
products | object | Flat map of products keyed by product_code |
Tree Structure
The tree provides hierarchical navigation (group → category → products):
json
{
"tree": {
"groups": [
{
"id": "grp_mobile",
"name": "Mobile",
"icon_url": "https://cdn.iimmpact.com/icons/mobile.png",
"categories": [
{
"id": "cat_prepaid",
"name": "Prepaid Reload",
"product_codes": ["D", "M", "C", "U"]
},
{
"id": "cat_postpaid",
"name": "Postpaid Bills",
"product_codes": ["DB", "MB", "CB"]
}
]
},
{
"id": "grp_bills",
"name": "Bills",
"icon_url": "https://cdn.iimmpact.com/icons/bills.png",
"categories": [
{
"id": "cat_utilities",
"name": "Utilities",
"product_codes": ["TNB", "IW", "AKSB"]
}
]
}
]
}
}Why Separate Tree and Products?
tree: Controls UI hierarchy and orderingproducts: Flat map for O(1) lookup and efficient local storage
Product Schema
Each product in the products map contains field configurations and fulfillment mapping:
json
{
"D": {
"code": "D",
"name": "Digi Prepaid",
"note": "Prepaid reload for Digi mobile numbers",
"image_url": "https://dashboard.iimmpact.com/img/D.png",
"processing_time": "instant",
"is_active": true,
"denomination": "5,10,30,50,100",
"fields": [...],
"fulfillment": {...},
"pricing": {
"cost": {
"model": "percentage_discount",
"percentage_rate": 0.985
},
"price_adjustment": null,
"has_loss_risk": false
}
}
}| Field | Type | Description |
|---|---|---|
code | string | Unique product identifier |
name | string | Display name |
note | string | null | Optional extra description for the product |
image_url | string | Product logo URL |
processing_time | string | instant, 24_hours, or 3_days |
is_active | boolean | Effective product status after reseller overlay |
denomination | string | null | Raw denomination source string from backend |
fields | array | Form field definitions |
fulfillment | object | Mapping to payment request |
pricing | object | Tenant pricing metadata (cost, optional price_adjustment, has_loss_risk) |
min_amount | object | null | Optional custom minimum amount override for pricing fields |
max_amount | object | null | Optional custom maximum amount override for pricing fields |
Processing Time Values
The processing_time field indicates how long before the transaction is fulfilled:
| Value | Description | User Message | Examples |
|---|---|---|---|
instant | Processed immediately | "Delivered within seconds" | Mobile reload, games |
24_hours | Processed within 1 business day | "Processing within 24 hours" | PTPTN, some utilities |
3_days | Processed within 3 business days | "Processing within 3 business days" | Insurance, special bills |
User Experience
Display the processing_time to users before checkout so they understand when to expect fulfillment.
Field Schema
Fields are the building blocks of the product form. We use primitive types with optional input_mode hints for UX:
json
{
"id": "phone",
"type": "text",
"input_mode": "tel",
"label": "Phone Number",
"placeholder": "e.g. 0123456789",
"required": true,
"order": 1,
"role": "account",
"validation": {
"pattern": "^01[0-9]{8,9}$",
"message": "Enter valid Malaysian phone number"
}
}Field Properties
| Property | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Unique identifier within product |
type | string | Yes | text, number, select, money |
input_mode | string | No | Keyboard/input hint (see table below) |
label | string | Yes | Display label |
placeholder | string | No | Input placeholder text |
required | boolean | Yes | Whether field is required |
order | number | No | Display order (ascending) |
role | string | No | account, pricing, or none |
validation | object | No | Validation rules |
data_source | object | No | For select fields only |
currency | string | No | For money fields only (default: MYR) |
Field Types
| Type | Description | Use Case |
|---|---|---|
text | Free-form text input | Phone, NRIC, account numbers |
number | Numeric input | Player IDs, quantities |
select | Dropdown from options | Plans, packages, billers |
money | Currency amount with decimals | Payment amounts |
Input Modes
The input_mode property hints which keyboard/input method to use. It does not affect validation — use the validation object for that.
| Input Mode | Keyboard Type | Use Case |
|---|---|---|
text | Default keyboard | General text (default) |
tel | Phone dialpad | Phone numbers |
numeric | Number pad | NRIC, account numbers |
email | Email keyboard | Email addresses |
decimal | Number + decimal | Amounts (for money type) |
Validation Object
json
{
"validation": {
"pattern": "^[0-9]{12}$",
"message": "Enter valid 12-digit NRIC",
"min": 10,
"max": 60000
}
}| Property | Type | Description |
|---|---|---|
pattern | string | Regex pattern for text validation |
message | string | Error message when validation fails |
min | number | Minimum value (for money/number) |
max | number | Maximum value |
Data Source (Select Fields)
json
{
"data_source": {
"type": "dynamic",
"depends_on": ["phone"],
"endpoint": "/options",
"params": {
"product_code": { "static": "HI" },
"field_id": { "static": "plan" },
"account_number": { "from_field": "phone" }
}
}
}| Property | Type | Description |
|---|---|---|
type | string | reference (static) or dynamic (user-specific) |
depends_on | array | Field IDs that must be filled first |
endpoint | string | API endpoint to fetch options |
params | object | Query parameters with static or dynamic values |
Fulfillment Schema
Declares how fields map to the /v2/topup payment request.
The fulfillment block covers account, amount, and extras. The product field is always the product's code. You still need to provide your own refid (idempotency) and optional remarks when calling /v2/topup.
Amount = Price
The amount in the payment request is the selected transaction amount (price.amount from options, or direct money input). Pricing metadata (cost / price_adjustment) is for your backend calculations.
json
{
"fulfillment": {
"account": { "from_field": "phone" },
"amount": { "from_field": "plan", "path": "price.amount" },
"extras": {
"subproduct_code": { "from_field": "plan", "path": "code" },
"ic_number": { "from_field": "nric" },
"ref2": { "from_field": "ref2", "omit_if_empty": true }
}
}
}Field Mapping
| Property | Type | Description |
|---|---|---|
from_field | string | Field ID to get value from |
path | string | Dot notation path into selected item (for select fields) |
omit_if_empty | boolean | Exclude from request if value is empty |
Storing Select Field Values
For select fields, store the full selected item object from /options (not just the code). The path property accesses nested values like price.amount or account_number from this object when building the payment request.
Pricing Schema
B2B Data
Pricing information is for your backend only. Do not expose cost, price_adjustment, or discount rates to end users.
Each product includes a pricing object with three fields:
json
{
"pricing": {
"cost": {
"model": "percentage_discount",
"percentage_rate": 0.98
},
"price_adjustment": {
"type": "fixed",
"value": 0.5,
"currency": "MYR"
},
"has_loss_risk": false
}
}| Field | Type | Description |
|---|---|---|
cost | object | null | Wholesale cost model |
price_adjustment | object | null | Optional reseller adjustment (fixed or percentage) |
has_loss_risk | boolean | true when configured adjustment can produce a loss at some tier |
Cost Models
Your wholesale cost is determined by the cost.model:
| Model | Use Case | Calculation |
|---|---|---|
percentage_discount | Mobile reloads, postpaid bills | cost = price × percentage_rate |
fixed_discount | Utility bills (TNB, water) | cost = price - abs(fixed_amount) (fixed amount can be signed value) |
Percentage Discount
json
{
"cost": {
"model": "percentage_discount",
"percentage_rate": 0.97
}
}Example: Celcom Postpaid (CB) with 3% discount rate
- Price RM 100 → Your cost = RM 100 × 0.97 = RM 97
Fixed Discount
json
{
"cost": {
"model": "fixed_discount",
"fixed_amount": { "amount": "-0.50", "currency": "MYR" }
}
}Example: TNB with RM 0.50 fixed discount
- Price RM 100 → Your cost = RM 100 - abs(-0.50) = RM 99.50
Price Adjustment Models
price_adjustment is optional. If absent, no adjustment is applied.
| Type | Meaning | Calculation |
|---|---|---|
fixed | Add/subtract absolute amount | user_pays = price + value |
percentage | Multiplier rate (0.95 discount, 1.03 markup) | user_pays = price × value |
json
{
"price_adjustment": {
"type": "percentage",
"value": 1.03
}
}Loss Risk Flag
has_loss_risk is computed by evaluating configured cost and adjustment against denomination boundaries. Use it to warn users when their adjustment may undercut cost.
Pricing Calculation Helper
javascript
function calculatePricing(product, price) {
const { cost, price_adjustment: adjustment } = product.pricing;
let yourCost = price;
if (cost?.model === "percentage_discount") {
yourCost = price * cost.percentage_rate;
}
if (cost?.model === "fixed_discount") {
yourCost = price - Math.abs(parseFloat(cost.fixed_amount.amount));
}
let userPays = price;
if (adjustment?.type === "fixed") {
userPays = price + adjustment.value;
}
if (adjustment?.type === "percentage") {
userPays = price * adjustment.value;
}
return {
price,
yourCost,
userPays,
yourMargin: userPays - yourCost,
hasLossRisk: product.pricing.has_loss_risk,
};
}Complete Pricing Example
Face value RM 100, cost.percentage_rate=0.995, price_adjustment.fixed=+0.50:
Price: RM 100.00
Your Cost: RM 99.50
Price Adjustment: RM 0.50
────────────────────────────────
User Pays: RM 100.50
Your Margin: RM 1.00Percentage Values Use Rate Convention
For both cost.percentage_rate and price_adjustment.type=percentage, values are multipliers (for example 0.98, 1.00, 1.03) rather than percentage points.
Option-Level Pricing Data
For options, the API can also return per-item pricing fields in /v2/options responses:
json
{
"items": [
{
"code": "30",
"label": "RM 30",
"price": { "amount": "30.00", "currency": "MYR" },
"cost": { "amount": "29.55", "currency": "MYR" },
"rrp": { "amount": "30.50", "currency": "MYR" }
}
]
}| Field | Type | Description |
|---|---|---|
price | Money | Effective selling price |
cost | Money | Wholesale cost for current reseller |
rrp | Money | Recommended retail price |
Complete Product Examples
Digi Prepaid — Fixed Amounts (Percentage Discount)
Simple flow: Enter phone → Select amount → Checkout
json
{
"code": "D",
"name": "Digi Prepaid",
"note": "Prepaid reload for Digi mobile numbers",
"image_url": "https://dashboard.iimmpact.com/img/D.png",
"processing_time": "instant",
"fields": [
{
"id": "phone",
"type": "text",
"input_mode": "tel",
"label": "Phone Number",
"placeholder": "e.g. 0123456789",
"required": true,
"role": "account",
"validation": {
"pattern": "^01[0-9]{8,9}$",
"message": "Enter valid Malaysian phone number"
}
},
{
"id": "amount",
"type": "select",
"label": "Select Amount",
"required": true,
"role": "pricing",
"data_source": {
"type": "reference",
"endpoint": "/options",
"params": {
"product_code": { "static": "D" },
"field_id": { "static": "amount" }
}
}
}
],
"fulfillment": {
"account": { "from_field": "phone" },
"amount": { "from_field": "amount", "path": "price.amount" }
},
"pricing": {
"cost": {
"model": "percentage_discount",
"percentage_rate": 0.985
},
"price_adjustment": null,
"has_loss_risk": false
}
}Pricing: Face value RM 30 → Your cost = RM 30 × 0.985 = RM 29.55 → Margin = RM 0.45
Resulting Payment Request:
json
{
"refid": "your-unique-refid",
"product": "D",
"account": "0123456789",
"amount": "30.00",
"extras": {}
}Hotlink Internet — Dynamic Plans (Percentage Discount)
Flow: Enter phone → Fetch available plans → Select plan → Checkout
json
{
"code": "HI",
"name": "Hotlink Internet",
"note": "Data add-on plans for Hotlink prepaid",
"image_url": "https://dashboard.iimmpact.com/img/HI.png",
"processing_time": "instant",
"fields": [
{
"id": "phone",
"type": "text",
"input_mode": "tel",
"label": "Phone Number",
"placeholder": "e.g. 0123456789",
"required": true,
"role": "account",
"validation": {
"pattern": "^01[0-9]{8,9}$",
"message": "Enter valid Malaysian phone number"
}
},
{
"id": "plan",
"type": "select",
"label": "Select Plan",
"required": true,
"role": "pricing",
"data_source": {
"type": "dynamic",
"depends_on": ["phone"],
"endpoint": "/options",
"params": {
"product_code": { "static": "HI" },
"field_id": { "static": "plan" },
"account_number": { "from_field": "phone" }
}
}
}
],
"fulfillment": {
"account": { "from_field": "phone" },
"amount": { "from_field": "plan", "path": "price.amount" },
"extras": {
"subproduct_code": { "from_field": "plan", "path": "code" }
}
},
"pricing": {
"cost": {
"model": "percentage_discount",
"percentage_rate": 0.98
},
"price_adjustment": {
"type": "fixed",
"value": 1.0,
"currency": "MYR"
},
"has_loss_risk": false
}
}Resulting Payment Request:
json
{
"refid": "your-unique-refid",
"product": "HI",
"account": "0123456789",
"amount": "40.00",
"extras": {
"subproduct_code": "Unlimited data with hotspot and calls 30-days (3Mbps) H"
}
}Pricing: Price RM 40 → Your cost = RM 40 × 0.98 = RM 39.20 → Base margin = RM 0.80.
With fixed price adjustment +1.00, user pays RM 41.00, increasing margin to RM 1.80.
PTPTN — Account Selection + Flexible Amount (Fixed Discount)
Flow: Enter NRIC → Fetch loan accounts → Select account → Enter amount → Checkout
json
{
"code": "PTPTN",
"name": "PTPTN",
"note": "Repay your PTPTN education loan",
"processing_time": "24_hours",
"fields": [
{
"id": "nric",
"type": "text",
"input_mode": "numeric",
"label": "NRIC Number",
"placeholder": "Enter 12-digit NRIC",
"required": true,
"validation": {
"pattern": "^[0-9]{12}$",
"message": "Enter valid 12-digit NRIC"
}
},
{
"id": "loan_account",
"type": "select",
"label": "Select Account",
"required": true,
"data_source": {
"type": "dynamic",
"depends_on": ["nric"],
"endpoint": "/options",
"params": {
"product_code": { "static": "PTPTN" },
"field_id": { "static": "loan_account" },
"account_number": { "from_field": "nric" }
}
}
},
{
"id": "amount",
"type": "money",
"input_mode": "decimal",
"label": "Payment Amount",
"placeholder": "Enter amount",
"required": true,
"role": "pricing",
"currency": "MYR",
"validation": {
"min": 10,
"max": 60000
}
}
],
"fulfillment": {
"account": { "from_field": "loan_account", "path": "account_number" },
"amount": { "from_field": "amount" },
"extras": {
"ic_number": { "from_field": "nric" },
"subproduct_code": { "from_field": "loan_account", "path": "code" }
}
},
"pricing": {
"cost": {
"model": "fixed_discount",
"fixed_amount": { "amount": "-0.50", "currency": "MYR" }
},
"price_adjustment": {
"type": "percentage",
"value": 1.01
},
"has_loss_risk": false
}
}Resulting Payment Request:
json
{
"refid": "your-unique-refid",
"product": "PTPTN",
"account": "009411230450014",
"amount": "500.00",
"extras": {
"ic_number": "941123045001",
"subproduct_code": "S"
}
}Pricing: Price RM 500 → Your cost = RM 500 - abs(-0.50) = RM 499.50 → Base margin = RM 0.50.
With percentage price adjustment 1.01, user pays RM 505.00, increasing margin to RM 5.50.
JomPAY — Biller Selection (Fixed Discount)
Flow: Select biller → Enter references → Enter amount → Checkout
json
{
"code": "JOMPAY",
"name": "JomPAY",
"image_url": "https://dashboard.iimmpact.com/img/JOMPAY.png",
"processing_time": "instant",
"fields": [
{
"id": "biller",
"type": "select",
"label": "Select Biller",
"required": true,
"data_source": {
"type": "reference",
"endpoint": "/options",
"params": {
"product_code": { "static": "JOMPAY" },
"field_id": { "static": "biller" }
}
}
},
{
"id": "ref1",
"type": "text",
"label": "Reference 1",
"placeholder": "Account/Reference number",
"required": true,
"role": "account"
},
{
"id": "ref2",
"type": "text",
"label": "Reference 2",
"placeholder": "Optional reference",
"required": false
},
{
"id": "nric",
"type": "text",
"input_mode": "numeric",
"label": "NRIC Number",
"placeholder": "Enter 12-digit NRIC",
"required": true,
"validation": {
"pattern": "^[0-9]{12}$",
"message": "Enter valid 12-digit NRIC"
}
},
{
"id": "amount",
"type": "money",
"input_mode": "decimal",
"label": "Payment Amount",
"required": true,
"role": "pricing",
"currency": "MYR",
"validation": {
"min": 1,
"max": 30000
}
}
],
"fulfillment": {
"account": { "from_field": "ref1" },
"amount": { "from_field": "amount" },
"extras": {
"biller_code": { "from_field": "biller", "path": "code" },
"ic_number": { "from_field": "nric" },
"ref2": { "from_field": "ref2", "omit_if_empty": true }
}
},
"pricing": {
"cost": {
"model": "fixed_discount",
"fixed_amount": { "amount": "-0.30", "currency": "MYR" }
},
"price_adjustment": {
"type": "fixed",
"value": 0.5,
"currency": "MYR"
},
"has_loss_risk": false
}
}Pricing: Face value RM 150 + fixed price adjustment RM 0.50 = User pays RM 150.50 → Your cost = RM 150 - abs(-0.30) = RM 149.70 → Margin = RM 0.80
Dynamic Amount Limits
The min/max in the amount field are fallback values. When user selects a biller, use that biller's min_amount and max_amount from the Options API response to validate the payment amount.
PUBG Mobile — Game Packages
Flow: Enter Player ID → Select package → Checkout
json
{
"code": "PUBG",
"name": "PUBG Mobile",
"note": "Purchase UC for PUBG Mobile",
"image_url": "https://dashboard.iimmpact.com/img/PUBG.png",
"processing_time": "instant",
"fields": [
{
"id": "player_id",
"type": "text",
"input_mode": "numeric",
"label": "Player ID",
"placeholder": "Enter your PUBG Player ID",
"required": true,
"role": "account",
"validation": {
"pattern": "^[0-9]{8,12}$",
"message": "Enter valid Player ID"
}
},
{
"id": "package",
"type": "select",
"label": "Select Package",
"required": true,
"role": "pricing",
"data_source": {
"type": "reference",
"endpoint": "/options",
"params": {
"product_code": { "static": "PUBG" },
"field_id": { "static": "package" }
}
}
}
],
"fulfillment": {
"account": { "from_field": "player_id" },
"amount": { "from_field": "package", "path": "price.amount" },
"extras": {
"subproduct_code": { "from_field": "package", "path": "code" }
}
},
"pricing": {
"cost": {
"model": "percentage_discount",
"percentage_rate": 0.945
},
"price_adjustment": null,
"has_loss_risk": false
}
}For game products, per-item price / cost / rrp values are returned in /v2/options and should be used for checkout display:
json
{
"items": [
{
"code": "60",
"label": "60 UC",
"price": { "amount": "5.00", "currency": "MYR" },
"cost": { "amount": "4.50", "currency": "MYR" },
"rrp": { "amount": "5.50", "currency": "MYR" },
"value": { "amount": 60, "unit": "UC" },
"option_type": "package"
},
{
"code": "325",
"label": "325 UC",
"price": { "amount": "23.00", "currency": "MYR" },
"cost": { "amount": "19.50", "currency": "MYR" },
"rrp": { "amount": "25.00", "currency": "MYR" },
"value": { "amount": 325, "unit": "UC" },
"option_type": "package"
}
]
}| Field | Type | Description |
|---|---|---|
price | Money | Base selling price |
cost | Money | Your wholesale cost |
rrp | Money | Official/recommended retail price |
value | object | What user receives (amount + unit) |
option_type | string | Semantic type for UI rendering |
Pricing: User buys 60 UC (RM 5) → Your cost = RM 4.50 → Margin = RM 0.50 (RRP is RM 5.50, showing 9% savings)
Caching Strategy
Recommended: Webhook-Based Sync
Use Catalog Webhooks for real-time catalog updates. Your backend receives push notifications when the catalog changes — no polling required.
IIMMPACT ──webhook──▶ Your Backend ──serve──▶ Mobile App
│
└── Store in DB (always fresh)Why Webhooks?
- Always up-to-date — changes pushed instantly
- No polling overhead — reduces API calls
- Single source of truth — your backend controls distribution to clients
Fallback: Pull-Based Sync
If webhooks are not enabled, pull /v2/catalog on a schedule and compare last_updated with your cached copy:
javascript
async function syncCatalog() {
const response = await fetch("https://api.iimmpact.com/v2/catalog", {
headers: {
"X-Api-Key": apiKey,
"X-Timestamp": timestamp,
"X-Nonce": nonce,
"X-Signature": `v1=${signature}`,
},
});
const latest = await response.json();
const cached = JSON.parse(localStorage.getItem("catalog") ?? "null");
if (cached?.last_updated === latest.last_updated) {
return; // unchanged
}
localStorage.setItem("catalog", JSON.stringify(latest));
}Client-Side Caching
If your mobile app fetches directly from IIMMPACT API:
- Fetch catalog on app launch
- Compare
last_updatedwith cached value - Store catalog in SQLite/local DB for offline access
Error Responses
Validation and application errors typically use this envelope:
json
{
"message": "The given data was invalid.",
"errors": {
"field_name": ["Validation message"]
}
}400 Bad Request
Validation errors (for example invalid query parameters):
json
{
"message": "The given data was invalid.",
"errors": {
"is_active": ["The value 'yes' is not valid."]
}
}401 Unauthorized
Authentication failed (for example missing HMAC headers, expired timestamp window, nonce reuse, or signature mismatch):
json
{
"message": "Unauthorized",
"metadata": {
"status_code": "401"
}
}See API Key Authentication for the complete list of 401 causes.
No-Match Product Filter
If product_code does not match any visible product, the endpoint returns 200 OK with an empty products map while keeping the catalog hierarchy:
json
{
"last_updated": "2025-01-07T00:00:00Z",
"tree": {
"groups": [
{
"id": "grp_mobile",
"name": "Mobile",
"categories": [
{
"id": "cat_prepaid",
"name": "Prepaid Reload",
"product_codes": []
}
]
}
]
},
"products": {}
}Handling Errors
For 401 errors, regenerate timestamp/nonce, recompute the signature, and retry with a fresh request. For transient 5xx errors, retry with backoff.
