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.
Endpoint
http
GET https://api.iimmpact.com/v2/catalogRequest Headers
| Header | Description | Required |
|---|---|---|
Authorization | IdToken (Bearer) | Yes |
Response Headers
| Header | Description |
|---|---|
Last-Modified | Timestamp of last catalog update |
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",
"product_type": "standard",
"image_url": "https://dashboard.iimmpact.com/img/D.png",
"processing_time": "instant",
"fields": [...],
"fulfillment": {...},
"pricing": {
"model": "percentage_discount",
"discount_rate": 1.5
}
}
}| Field | Type | Description |
|---|---|---|
code | string | Unique product identifier |
name | string | Display name |
product_type | string | standard or custom |
image_url | string | Product logo URL |
placeholder_image | string | Fallback image identifier |
processing_time | string | instant, 24_hours, or 3_days |
fields | array | Form field definitions |
fulfillment | object | Mapping to payment request |
pricing | object | Wholesale pricing for tenants (B2B) |
confirmation | object | Pre-checkout confirmation config |
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,
"min_from_field": { "field_id": "biller", "path": "min_amount.amount" },
"max_from_field": { "field_id": "biller", "path": "max_amount.amount" }
}
}| 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 |
min_from_field | object | Dynamic min from another field's selection |
max_from_field | object | Dynamic max from another field's selection |
Data Source (Select Fields)
json
{
"data_source": {
"type": "dynamic",
"depends_on": ["phone"],
"endpoint": "/v2/options",
"params": {
"product_code": { "static": "HI" },
"field_id": { "static": "plan" },
"account_number": { "from_field": "phone" }
},
"cache": {
"scope": "per_input",
"ttl_seconds": 300
},
"search": {
"enabled": true,
"min_chars": 2
}
}
}| 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 |
cache | object | Caching strategy |
search | object | Search/filter configuration |
Cache Scopes
| Scope | Description | Example |
|---|---|---|
global | Same for all users | Denomination amounts |
tenant | Same within tenant | Game packages with tenant pricing |
per_input | Unique per input value | Plans for specific phone number |
Fulfillment Schema
Declares how fields map to the /v2/topup payment request.
The fulfillment block only covers product, account, amount, and extras. You still need to provide your own refid (idempotency) and optional remarks when calling /v2/topup.
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 |
Pricing Schema
B2B Data
Pricing information is for your backend only. Do not expose cost, markup, or discount rates to end users.
Each product includes a pricing object with two components:
json
{
"pricing": {
"cost": {
"model": "percentage_discount",
"percentage_rate": 2.0
},
"markup": {
"model": "fixed",
"fixed_amount": { "amount": "0.50", "currency": "MYR" }
}
}
}| Field | Type | Description |
|---|---|---|
cost | object | Your wholesale cost (what IIMMPACT charges you) |
markup | object | Your configured markup (set via Dashboard) |
Cost Models
Your wholesale cost is determined by the cost.model:
| Model | Use Case | Calculation |
|---|---|---|
percentage_discount | Mobile reloads, postpaid bills | cost = face_value × (1 - rate/100) |
fixed_discount | Utility bills (TNB, water) | cost = face_value - fixed_amount |
fixed_per_item | Games with variable margins | cost = item.cost (from /v2/options) |
Percentage Discount
json
{
"cost": {
"model": "percentage_discount",
"percentage_rate": 3.0
}
}Example: Celcom Postpaid (CB) with 3% discount
- Face value RM 100 → Your cost = RM 100 × (1 - 0.03) = RM 97
Fixed Discount
json
{
"cost": {
"model": "fixed_discount",
"fixed_amount": { "amount": "0.50", "currency": "MYR" }
}
}Example: TNB with RM 0.50 fixed discount
- Face value RM 100 → Your cost = RM 100 - RM 0.50 = RM 99.50
Fixed Per Item
json
{
"cost": {
"model": "fixed_per_item"
}
}For fixed_per_item products, the cost is specified per item in the /v2/options response:
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" }
}
]
}| Field | Type | Description |
|---|---|---|
price | Money | Base selling price |
cost | Money | Your wholesale cost |
rrp | Money | Recommended retail price (optional) |
value | object | What user receives (for games, intl top-up) |
Markup Models
Your markup is configured via the Dashboard and included in the catalog response:
| Model | Use Case | Calculation |
|---|---|---|
none | No markup | user_pays = face_value |
fixed | +RM 0.50 | user_pays = face_value + fixed_amount |
percentage | +1% | user_pays = face_value × (1 + rate/100) |
No Markup
json
{
"markup": {
"model": "none"
}
}Fixed Markup
json
{
"markup": {
"model": "fixed",
"fixed_amount": { "amount": "0.50", "currency": "MYR" }
}
}Percentage Markup
json
{
"markup": {
"model": "percentage",
"percentage_rate": 1.0
}
}Complete Pricing Example
TNB electricity bill payment for RM 100:
Face Value: RM 100.00
Your Cost: RM 99.50 (fixed_discount: -RM 0.50)
Your Markup: RM 0.50 (fixed: +RM 0.50)
────────────────────────────────
User Pays: RM 100.50
Your Margin: RM 1.00 (markup + cost savings)Pricing Calculation Helper
javascript
function calculatePricing(product, faceValue, selectedItem) {
const { cost, markup } = product.pricing;
// Calculate your cost
let yourCost;
switch (cost.model) {
case "percentage_discount":
yourCost = faceValue * (1 - cost.percentage_rate / 100);
break;
case "fixed_discount":
yourCost = faceValue - parseFloat(cost.fixed_amount.amount);
break;
case "fixed_per_item":
yourCost = parseFloat(selectedItem.cost.amount);
break;
default:
yourCost = faceValue;
}
// Calculate user pays (with markup)
let userPays;
switch (markup.model) {
case "fixed":
userPays = faceValue + parseFloat(markup.fixed_amount.amount);
break;
case "percentage":
userPays = faceValue * (1 + markup.percentage_rate / 100);
break;
case "none":
default:
userPays = faceValue;
}
return {
faceValue,
yourCost,
userPays,
yourMargin: userPays - yourCost,
};
}Complete Product Examples
Digi Prepaid — Fixed Amounts (Percentage Discount)
Simple flow: Enter phone → Select amount → Checkout
json
{
"code": "D",
"name": "Digi Prepaid",
"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": "/v2/options",
"params": {
"product_code": { "static": "D" },
"field_id": { "static": "amount" }
},
"cache": { "scope": "global", "ttl_seconds": 86400 }
}
}
],
"fulfillment": {
"account": { "from_field": "phone" },
"amount": { "from_field": "amount", "path": "price.amount" }
},
"pricing": {
"cost": {
"model": "percentage_discount",
"percentage_rate": 1.5
},
"markup": {
"model": "none"
}
}
}Pricing: Face value RM 30 → Your cost = RM 30 × (1 - 0.015) = 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",
"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": "/v2/options",
"params": {
"product_code": { "static": "HI" },
"field_id": { "static": "plan" },
"account_number": { "from_field": "phone" }
},
"cache": { "scope": "per_input", "ttl_seconds": 300 }
}
}
],
"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": 2.0
},
"markup": {
"model": "fixed",
"fixed_amount": { "amount": "1.00", "currency": "MYR" }
}
}
}Resulting Payment Request:
json
{
"refid": "your-unique-refid",
"product": "HI",
"account": "0123456789",
"amount": "41.00",
"extras": {
"subproduct_code": "Unlimited data with hotspot and calls 30-days (3Mbps) H"
}
}Pricing: Face value RM 40 + RM 1 markup = User pays RM 41 → Your cost = RM 40 × (1 - 0.02) = RM 39.20 → Margin = 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",
"placeholder_image": "PTPTN",
"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": "/v2/options",
"params": {
"product_code": { "static": "PTPTN" },
"field_id": { "static": "loan_account" },
"account_number": { "from_field": "nric" }
},
"cache": { "scope": "per_input", "ttl_seconds": 600 }
}
},
{
"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" }
},
"markup": {
"model": "percentage",
"percentage_rate": 1.0
}
}
}Resulting Payment Request:
json
{
"refid": "your-unique-refid",
"product": "PTPTN",
"account": "009411230450014",
"amount": "505.00",
"extras": {
"ic_number": "941123045001",
"subproduct_code": "S"
}
}Pricing: Face value RM 500 + 1% markup = User pays RM 505 → Your cost = RM 500 - RM 0.50 = RM 499.50 → Margin = 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": "/v2/options",
"params": {
"product_code": { "static": "JOMPAY" },
"field_id": { "static": "biller" }
},
"cache": { "scope": "global", "ttl_seconds": 86400 },
"search": { "enabled": true, "min_chars": 2 }
}
},
{
"id": "ref1",
"type": "text",
"label": "Reference 1",
"placeholder": "Account/Reference number",
"required": true,
"role": "account",
"validation": { "max_length": 30 }
},
{
"id": "ref2",
"type": "text",
"label": "Reference 2",
"placeholder": "Optional reference",
"required": false,
"validation": { "max_length": 30 }
},
{
"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,
"min_from_field": { "field_id": "biller", "path": "min_amount.amount" },
"max_from_field": { "field_id": "biller", "path": "max_amount.amount" }
}
}
],
"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" }
},
"markup": {
"model": "fixed",
"fixed_amount": { "amount": "0.50", "currency": "MYR" }
}
}
}Pricing: Face value RM 150 + RM 0.50 markup = User pays RM 150.50 → Your cost = RM 150 - RM 0.30 = RM 149.70 → Margin = RM 0.80
PUBG Mobile — Game Packages (Fixed Per Item)
Flow: Enter Player ID → Select package → Checkout
json
{
"code": "PUBG",
"name": "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": "/v2/options",
"params": {
"product_code": { "static": "PUBG" },
"field_id": { "static": "package" }
},
"cache": { "scope": "global", "ttl_seconds": 86400 }
}
}
],
"fulfillment": {
"account": { "from_field": "player_id" },
"amount": { "from_field": "package", "path": "price.amount" },
"extras": {
"subproduct_code": { "from_field": "package", "path": "code" }
}
},
"pricing": {
"cost": {
"model": "fixed_per_item"
},
"markup": {
"model": "none"
}
}
}For fixed_per_item products, your cost varies per item and is returned in the /v2/options response:
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" }
},
{
"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" }
}
]
}| 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) |
Pricing: User buys 60 UC (RM 5) → Your cost = RM 4.50 → Margin = RM 0.50 (RRP is RM 5.50, showing 9% savings)
Custom Products
Some products cannot be expressed with the standard field model and use product_type: "custom". These require hardcoded flows in your application.
json
{
"code": "EVD",
"name": "Every Pay",
"product_type": "custom",
"image_url": "https://dashboard.iimmpact.com/img/EVD.png"
}Handling Custom Products
javascript
function renderProductForm(product) {
// Check for custom products first
if (product.product_type === "custom") {
switch (product.code) {
case "EVD":
return <EveryPayFlow product={product} />;
// Add more custom product handlers as needed
default:
// Unknown custom product - show unsupported message
return <UnsupportedProduct product={product} />;
}
}
// Standard products use dynamic form
return <DynamicProductForm product={product} />;
}Current Custom Products
| Code | Name | Reason for Custom Flow |
|---|---|---|
EVD | Every Pay | Complex multi-step payment flow |
Future Custom Products
When new custom products are added, your app will need an update to support them. Subscribe to our changelog for advance notice of new custom products.
Caching Strategy
Recommended: Webhook-Based Sync
Use 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: Conditional Requests
For direct client-to-API access or initial bootstrap, use conditional requests:
javascript
async function syncCatalog() {
const headers = { Authorization: `Bearer ${idToken}` };
const lastModified = localStorage.getItem("catalog_last_modified");
if (lastModified) {
headers["If-Modified-Since"] = lastModified;
}
const response = await fetch("https://api.iimmpact.com/v2/catalog", {
headers,
});
if (response.status === 304) {
return; // Not modified, use cached
}
const catalog = await response.json();
localStorage.setItem("catalog", JSON.stringify(catalog));
localStorage.setItem(
"catalog_last_modified",
response.headers.get("Last-Modified"),
);
}Client-Side Caching
If your mobile app fetches directly from IIMMPACT API:
- Fetch catalog on app launch
- Use
If-Modified-Sincefor subsequent requests - Store in SQLite/local DB for offline access
Error Responses
401 Unauthorized
Invalid or expired bearer token:
json
{
"error": "unauthorized",
"message": "Invalid or expired bearer token"
}403 Forbidden
Tenant does not have access to the catalog:
json
{
"error": "forbidden",
"message": "Tenant not authorized for catalog access"
}503 Service Unavailable
Catalog temporarily unavailable:
json
{
"error": "service_unavailable",
"message": "Catalog temporarily unavailable",
"retry_after": 30
}Handling Errors
When receiving a 503 error, use the retry_after value (in seconds) before retrying. For 401 errors, refresh your bearer token and retry.
