Skip to content

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 ApproachNew Approach
Hardcoded product flows per product typeSingle dynamic form renderer for all products
Scattered validation logicBackend-driven validation rules
Implicit extras requirementsExplicit fulfillment mapping
Multiple endpoints for subproductsUnified /v2/options endpoint
App releases for new productsBackend configuration only
No wholesale pricing infoB2B pricing with costs and margins
Manual sync for catalog updatesWebhook 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.

EndpointStatusDeprecation DateSunset Date
/v2/product-listDeprecatedTBATBA
/v2/subproductsDeprecatedTBATBA
/v2/catalogActive--
/v2/optionsActive--

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 EndpointNew EndpointNotes
GET /v2/product-listGET /v2/catalogReturns products with field configurations
GET /v2/subproducts?product_code=JOMPAYGET /v2/options?product_code=JOMPAY&field_id=billerJomPAY 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_source pointing to /v2/options
  • account_label + keyboard_typefields[].type: "text" + input_mode with label and validation
  • has_subproductfields[].data_source.type: "dynamic"
  • Implicit extras → Explicit fulfillment mapping

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_codecode
  • display_namelabel
  • min/maxmin_amount/max_amount as structured Money objects
  • Page-based pagination → Page-based pagination with meta object

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_codecode
  • denomination/face_valueprice as 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_codecode
  • display_namelabel
  • Cleaner structure, account_number preserved for fulfillment mapping

Pricing Migration (B2B)

The new API includes wholesale pricing information so you can calculate your costs and margins.

Pricing Models

ModelUse CaseYour Cost Calculation
percentage_discountMobile reloads, postpaidface_value × (1 - discount_rate/100)
fixed_discountUtility bills (TNB, water)face_value - discount_amount
fixed_per_itemGamesitem.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 info

After: 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 = 3

For 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.50

B2B 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,
); // Daily

After: 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

EventTriggerPayload
catalog.syncProduct/pricing changeFull catalog (replace entirely)
options.syncOptions/cost changeFull options for product+field

Migration Checklist

  • [ ] Update API client to support both /v2/catalog and /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_on for dynamic fields
  • [ ] Implement cost calculation for each pricing model
  • [ ] Register webhook for real-time sync
  • [ ] Handle catalog.sync and options.sync webhook events
  • [ ] Test all product types with new endpoints
  • [ ] Remove product-specific hardcoded logic
  • [ ] Update error handling for new response formats

IIMMPACT API Documentation