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

Denomination Mapping

This table shows how old denomination formats map to the new Catalog API:

Old API FieldOld ValueNew Field TypeNew Location
denomination"5,10,30,50,100"select/v2/options returns available amounts
denomination"1-30000"moneyvalidation.min/max in catalog
denomination_data_typeIntegermoneyinput_mode: "numeric"
denomination_data_typeDecimalmoneyinput_mode: "decimal"
has_subproduct: trueselectdata_source.type: "dynamic"

Example conversions:

Old: denomination: "50,100,150,200"
New: select field → call GET /v2/options?product_code=X&field_id=amount

Old: denomination: "1-30000"
New: money field with validation: { min: 1, max: 30000 }

Old: denomination_data_type: "Integer"
New: money field with input_mode: "numeric"

Old: denomination_data_type: "Decimal"
New: money field with input_mode: "decimal"

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": 100,
    "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": 100,
    "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, postpaidprice × percentage_rate
fixed_discountUtility bills (TNB, water)price - abs(fixed_amount)

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": {
//     "cost": { "model": "percentage_discount", "percentage_rate": 0.97 },
//     "price_adjustment": { "type": "fixed", "value": 0.50, "currency": "MYR" },
//     "has_loss_risk": false
//   }
// }

// Calculate your cost
const faceValue = 100;
const yourCost = faceValue * product.pricing.cost.percentage_rate;
// yourCost = 97, your margin = 3

For Games (Option-Level Cost)

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 and pricing fields are for your backend only. Do not expose wholesale pricing 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 "text":
    case "number":
      // Use input_mode for keyboard hints (tel, numeric, etc.)
      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);
    const { min, max } = validation;

    if (min !== undefined && numValue < min) {
      return `Minimum amount is RM ${min}`;
    }
    if (max !== undefined && numValue > 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 updates. Instead of polling for changes, register a webhook to receive single-resource events 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/reseller/catalog/webhook", {
  method: "POST",
  headers: {
    "X-Api-Key": apiKey,
    "X-Timestamp": timestamp,
    "X-Nonce": nonce,
    "X-Signature": `v1=${signature}`,
  },
  body: JSON.stringify({
    webhook_url: "https://your-api.com/iimmpact/webhook",
  }),
});

// Handle incoming webhooks
app.post("/iimmpact/webhook", async (req, res) => {
  const signature = req.headers["x-webhook-signature"]; // sha256=<hex_digest>
  if (!verifySignature(req.rawBody, signature, WEBHOOK_SECRET)) {
    return res.status(401).send("Invalid signature");
  }

  const { type, resource, id, data } = req.body;

  if (type === "product.updated") {
    await db.products.upsert({ id, ...data });
  }

  res.status(200).send("OK");
});

Webhook Events

Event PrefixTriggerPayload
product.*Product changesSingle product payload
option.*Option changesSingle option payload
category.*Category changesSingle category payload
group.*Group changesSingle group payload

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 using last_updated comparison
  • [ ] Handle data_source.depends_on for dynamic fields
  • [ ] Implement cost calculation for each pricing model
  • [ ] Register webhook for real-time catalog sync
  • [ ] Handle resource webhook events (product.*, option.*, category.*, group.*)
  • [ ] Test all product types with new endpoints
  • [ ] Remove product-specific hardcoded logic
  • [ ] Update error handling for new response formats

IIMMPACT API Documentation