Appearance
Migration Guide
This guide helps you migrate from the existing /v2/product-list and /v2/subproducts endpoints to the new Dynamic Product Catalog API.
Why Migrate?
| Current Approach | New Approach |
|---|---|
| Hardcoded product flows per product type | Single dynamic form renderer for all products |
| Scattered validation logic | Backend-driven validation rules |
Implicit extras requirements | Explicit fulfillment mapping |
| Multiple endpoints for subproducts | Unified /v2/options endpoint |
| App releases for new products | Backend configuration only |
| No wholesale pricing info | B2B pricing with costs and margins |
| Manual sync for catalog updates | Webhook notifications for real-time sync |
API Versioning & Deprecation
Both the old endpoints (/v2/product-list, /v2/subproducts) and new endpoints (/v2/catalog, /v2/options) are currently supported.
| Endpoint | Status | Deprecation Date | Sunset Date |
|---|---|---|---|
/v2/product-list | Deprecated | TBA | TBA |
/v2/subproducts | Deprecated | TBA | TBA |
/v2/catalog | Active | - | - |
/v2/options | Active | - | - |
Migration Timeline
We recommend migrating to the new Catalog API as soon as possible. Deprecation notices will be announced at least 6 months before the old endpoints are sunset. Subscribe to our changelog for updates.
Endpoint Mapping
| Old Endpoint | New Endpoint | Notes |
|---|---|---|
GET /v2/product-list | GET /v2/catalog | Returns products with field configurations |
GET /v2/subproducts?product_code=JOMPAY | GET /v2/options?product_code=JOMPAY&field_id=biller | JomPAY billers |
GET /v2/subproducts?product_code=HI&account_number=... | GET /v2/options?product_code=HI&field_id=plan&account_number=... | Mobile data plans |
GET /v2/subproducts?product_code=PTPTN&account_number=... | GET /v2/options?product_code=PTPTN&field_id=loan_account&account_number=... | PTPTN accounts |
Response Comparison
Product List → Catalog
Before: /v2/product-list
json
{
"data": [
{
"status": "Active",
"has_subproduct": false,
"product_code": "D",
"product_name": "Digi Prepaid",
"product_remarks": "Please allow a 10-second waiting period...",
"account_label": "Enter Mobile Number",
"keyboard_type": "Numeric",
"denomination": "5,10,30,50,100",
"common_denominations": "5,10,30,50,100",
"denomination_data_type": "Integer",
"denomination_currency": "MYR",
"product_group": "Mobile",
"category": "Mobile Prepaid",
"image_url": "https://dashboard.iimmpact.com/img/D.png",
"account_type": "phone_number",
"is_refundable": false
}
]
}After: /v2/catalog
json
{
"last_updated": "2025-01-07T00:00:00Z",
"tree": {
"groups": [
{
"id": "grp_mobile",
"name": "Mobile",
"categories": [
{
"id": "cat_prepaid",
"name": "Prepaid Reload",
"product_codes": ["D", "M", "C"]
}
]
}
]
},
"products": {
"D": {
"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": "Mobile 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" }
}
}
}
],
"fulfillment": {
"account": { "from_field": "phone" },
"amount": { "from_field": "amount", "path": "price.amount" }
}
}
}
}Key Changes:
denomination: "5,10,30,50,100"→fields[].data_sourcepointing to/v2/optionsaccount_label+keyboard_type→fields[].type: "text"+input_modewithlabelandvalidationhas_subproduct→fields[].data_source.type: "dynamic"- Implicit extras → Explicit
fulfillmentmapping
JomPAY Subproducts → Options
Before: /v2/subproducts?product_code=JOMPAY&limit=3
json
{
"message": "success",
"data": [
{
"subproduct_code": "818625",
"display_name": "TADIKA DIDIKAN SOLEH PLT.",
"denomination": null,
"face_value": null,
"validity": null,
"description": null,
"account_number": null,
"additional_description": [],
"min": "1.00",
"max": "30000.00"
}
],
"links": { "next": "/subproducts?page=2&limit=3" },
"meta": { "total": 21287 }
}After: /v2/options?product_code=JOMPAY&field_id=biller&limit=3
json
{
"product_code": "JOMPAY",
"field_id": "biller",
"items": [
{
"code": "818625",
"label": "TADIKA DIDIKAN SOLEH PLT.",
"min_amount": { "amount": "1.00", "currency": "MYR" },
"max_amount": { "amount": "30000.00", "currency": "MYR" }
}
],
"meta": {
"current_page": 1,
"last_page": 7096,
"per_page": 3,
"total": 21287
}
}Key Changes:
subproduct_code→codedisplay_name→labelmin/max→min_amount/max_amountas structured Money objects- Page-based pagination → Page-based pagination with
metaobject
Mobile Data Subproducts → Options
Before: /v2/subproducts?product_code=HI&account_number=0178855286
json
{
"message": "success",
"data": [
{
"subproduct_code": "Everything Unlimited with Unlimited Weekly Pass RM12",
"display_name": "Hotlink Internet",
"denomination": 12,
"face_value": 12,
"validity": "7 day",
"description": "Everything Unlimited with Unlimited Weekly Pass RM12",
"account_number": null,
"additional_description": null,
"min": null,
"max": null
}
]
}After: /v2/options?product_code=HI&field_id=plan&account_number=0178855286
json
{
"product_code": "HI",
"field_id": "plan",
"items": [
{
"code": "Everything Unlimited with Unlimited Weekly Pass RM12",
"label": "Unlimited Weekly Pass",
"description": "Everything Unlimited with Unlimited Weekly Pass RM12",
"price": { "amount": "12.00", "currency": "MYR" },
"validity": "7 days"
}
],
"meta": {
"current_page": 1,
"last_page": 1,
"per_page": 10,
"total": 1
}
}Key Changes:
subproduct_code→codedenomination/face_value→priceas structured Money object- Cleaner structure without null fields
PTPTN Subproducts → Options
Before: /v2/subproducts?product_code=PTPTN&account_number=941123045001
json
{
"message": "success",
"data": [
{
"subproduct_code": "S",
"display_name": "KELVIN LEE WEI SERN",
"denomination": null,
"face_value": null,
"validity": null,
"description": "SSPN Prime",
"account_number": "009411230450014",
"additional_description": null,
"min": null,
"max": null
}
]
}After: /v2/options?product_code=PTPTN&field_id=loan_account&account_number=941123045001
json
{
"product_code": "PTPTN",
"field_id": "loan_account",
"items": [
{
"code": "S",
"label": "KELVIN LEE WEI SERN",
"description": "SSPN Prime",
"account_number": "009411230450014"
}
],
"meta": {
"current_page": 1,
"last_page": 1,
"per_page": 10,
"total": 1
}
}Key Changes:
subproduct_code→codedisplay_name→label- Cleaner structure,
account_numberpreserved for fulfillment mapping
Pricing Migration (B2B)
The new API includes wholesale pricing information so you can calculate your costs and margins.
Pricing Models
| Model | Use Case | Your Cost Calculation |
|---|---|---|
percentage_discount | Mobile reloads, postpaid | face_value × (1 - discount_rate/100) |
fixed_discount | Utility bills (TNB, water) | face_value - discount_amount |
fixed_per_item | Games | item.cost (varies per denomination) |
Before: No Pricing Info
javascript
// Old: No way to know your cost from the API
const product = productList.find((p) => p.product_code === "CB");
// product has no cost/margin infoAfter: Pricing in Catalog
javascript
// New: Pricing model included in catalog
const product = catalog.products["CB"];
// {
// "code": "CB",
// "pricing": {
// "model": "percentage_discount",
// "discount_rate": 3.0
// }
// }
// Calculate your cost
const faceValue = 100;
const yourCost = faceValue * (1 - product.pricing.discount_rate / 100);
// yourCost = 97, your margin = 3For Games (Fixed Per Item)
javascript
// Games have variable margins per denomination
const options = await fetchOptions("PUBG", "package");
// {
// "items": [
// { "code": "60", "price": { "amount": "5.00" }, "cost": { "amount": "4.50" } },
// { "code": "325", "price": { "amount": "23.00" }, "cost": { "amount": "19.50" } }
// ]
// }
// Your margin varies: 60 UC = RM 0.50, 325 UC = RM 3.50B2B Data
The cost, discount_rate, and discount_amount fields are for your backend only. Do not expose these to end users.
Code Migration
Before: Hardcoded Product Flow
javascript
// Old approach: Product-specific logic
function buildPaymentRequest(productCode, formData) {
const request = {
product: productCode,
account: formData.account,
amount: formData.amount,
extras: {},
};
// Scattered switch statements
switch (productCode) {
case "JOMPAY":
request.extras.biller_code = formData.billerCode;
request.extras.ic_number = formData.icNumber;
if (formData.ref2) {
request.extras.ref2 = formData.ref2;
}
break;
case "PTPTN":
request.account = formData.selectedAccount.account_number;
request.extras.ic_number = formData.icNumber;
request.extras.subproduct_code = formData.selectedAccount.subproduct_code;
break;
case "HI":
request.extras.subproduct_code = formData.selectedPlan.subproduct_code;
break;
// ... more cases for each product
}
return request;
}After: Generic Fulfillment Builder
javascript
// New approach: Data-driven fulfillment
function buildPaymentRequest(product, fieldValues) {
const { fulfillment } = product;
return {
product: product.code,
account: resolveMapping(fulfillment.account, fieldValues),
amount: resolveMapping(fulfillment.amount, fieldValues),
extras: buildExtras(fulfillment.extras, fieldValues),
};
}
function resolveMapping(mapping, fieldValues) {
const fieldValue = fieldValues[mapping.from_field];
if (mapping.path && typeof fieldValue === "object") {
return getNestedValue(fieldValue, mapping.path);
}
return fieldValue;
}
function buildExtras(extrasMapping, fieldValues) {
if (!extrasMapping) return {};
const extras = {};
for (const [key, mapping] of Object.entries(extrasMapping)) {
const value = resolveMapping(mapping, fieldValues);
if (mapping.omit_if_empty && !value) {
continue;
}
extras[key] = value;
}
return extras;
}
function getNestedValue(obj, path) {
return path.split(".").reduce((current, key) => current?.[key], obj);
}Field Rendering
Before: Product-Specific Views
javascript
// Old: Different views per product
function renderProductForm(product) {
switch (product.product_code) {
case "D":
case "M":
case "C":
return <PhoneInputWithAmounts product={product} />;
case "HI":
case "DI":
return <PhoneInputWithDynamicPlans product={product} />;
case "PTPTN":
return <NRICWithAccountSelection product={product} />;
case "JOMPAY":
return <BillerSearchWithRefs product={product} />;
default:
return <GenericForm product={product} />;
}
}After: Dynamic Form Builder
javascript
// New: Single form builder for all products
function DynamicProductForm({ product }) {
const [fieldValues, setFieldValues] = useState({});
const sortedFields = [...product.fields].sort(
(a, b) => (a.order || 0) - (b.order || 0),
);
return (
<form>
{sortedFields.map((field) => (
<DynamicField
key={field.id}
field={field}
value={fieldValues[field.id]}
allValues={fieldValues}
onChange={(value) =>
setFieldValues((prev) => ({
...prev,
[field.id]: value,
}))
}
/>
))}
</form>
);
}
function DynamicField({ field, value, allValues, onChange }) {
switch (field.type) {
case "phone":
return <PhoneInput field={field} value={value} onChange={onChange} />;
case "nric":
return <NRICInput field={field} value={value} onChange={onChange} />;
case "text":
case "number":
return <TextInput field={field} value={value} onChange={onChange} />;
case "money":
return (
<MoneyInput
field={field}
value={value}
onChange={onChange}
allValues={allValues}
/>
);
case "select":
return (
<SelectField
field={field}
value={value}
onChange={onChange}
allValues={allValues}
/>
);
default:
return null;
}
}Validation Migration
Before: Scattered Validation
javascript
// Old: Validation logic everywhere
function validatePhone(value, productCode) {
const pattern = /^01[0-9]{8,9}$/;
if (!pattern.test(value)) {
return "Enter valid Malaysian phone number";
}
return null;
}
function validateAmount(value, productCode, selectedBiller) {
const amount = parseFloat(value);
if (productCode === "JOMPAY" && selectedBiller) {
const min = parseFloat(selectedBiller.min);
const max = parseFloat(selectedBiller.max);
if (min !== -1 && amount < min) {
return `Minimum amount is RM ${min}`;
}
if (max !== -1 && amount > max) {
return `Maximum amount is RM ${max}`;
}
}
// More product-specific logic...
}After: Configuration-Driven Validation
javascript
// New: Generic validation from config
function validateField(field, value, allValues) {
const { validation } = field;
if (!validation) return null;
// Pattern validation
if (validation.pattern) {
const regex = new RegExp(validation.pattern);
if (!regex.test(value)) {
return validation.message || "Invalid format";
}
}
// Numeric validation
if (field.type === "money" || field.type === "number") {
const numValue = parseFloat(value);
// Static limits
let min = validation.min;
let max = validation.max;
// Dynamic limits from another field
if (validation.min_from_field) {
const sourceField = allValues[validation.min_from_field.field_id];
if (sourceField) {
min = getNestedValue(sourceField, validation.min_from_field.path);
}
}
if (validation.max_from_field) {
const sourceField = allValues[validation.max_from_field.field_id];
if (sourceField) {
max = getNestedValue(sourceField, validation.max_from_field.path);
}
}
if (min !== undefined && numValue < parseFloat(min)) {
return `Minimum amount is RM ${min}`;
}
if (max !== undefined && numValue > parseFloat(max)) {
return `Maximum amount is RM ${max}`;
}
}
return null;
}Backward Compatibility
During migration, you can use both APIs in parallel:
javascript
async function getProductConfig(productCode) {
// Try new catalog first
try {
const catalog = await fetchCatalog();
if (catalog.products[productCode]) {
return { type: "catalog", data: catalog.products[productCode] };
}
} catch (e) {
console.warn("Catalog not available, falling back to product-list");
}
// Fall back to old product-list
const productList = await fetchProductList();
const product = productList.data.find((p) => p.product_code === productCode);
return { type: "legacy", data: product };
}Webhook Migration
The new API supports webhooks for real-time catalog and options sync. Instead of polling for changes, register a webhook to receive updates automatically.
Before: Polling
javascript
// Old: Poll periodically for changes
setInterval(
async () => {
const productList = await fetchProductList();
await syncToDatabase(productList);
},
24 * 60 * 60 * 1000,
); // DailyAfter: Webhook
javascript
// New: Register webhook once
await fetch("https://api.iimmpact.com/v2/webhooks", {
method: "POST",
headers: { Authorization: `Bearer ${idToken}` },
body: JSON.stringify({
url: "https://your-api.com/iimmpact/webhook",
secret: "whsec_your_secret",
}),
});
// Handle incoming webhooks
app.post("/iimmpact/webhook", async (req, res) => {
const { event, data } = req.body;
switch (event) {
case "catalog.sync":
// Full catalog replacement
await db.catalog.replaceOne({ _id: "catalog" }, data, { upsert: true });
break;
case "options.sync":
// Full options replacement for product+field
await db.options.replaceOne(
{ product_code: data.product_code, field_id: data.field_id },
data,
{ upsert: true },
);
break;
}
res.status(200).send("OK");
});Webhook Events
| Event | Trigger | Payload |
|---|---|---|
catalog.sync | Product/pricing change | Full catalog (replace entirely) |
options.sync | Options/cost change | Full options for product+field |
Migration Checklist
- [ ] Update API client to support both
/v2/catalogand/v2/options - [ ] Create generic field components for each
type - [ ] Implement fulfillment mapping builder
- [ ] Migrate validation logic to use field configurations
- [ ] Update caching strategy for catalog (Last-Modified / If-Modified-Since)
- [ ] Handle
data_source.depends_onfor dynamic fields - [ ] Implement cost calculation for each pricing model
- [ ] Register webhook for real-time sync
- [ ] Handle
catalog.syncandoptions.syncwebhook events - [ ] Test all product types with new endpoints
- [ ] Remove product-specific hardcoded logic
- [ ] Update error handling for new response formats
