Skip to content

Catalog Webhook Events ​

Catalog webhooks deliver real-time notifications when your product catalog changes. Each HTTP POST request to your registered endpoint contains a single event signed with HMAC-SHA256.

For setup instructions and configuration, see the Catalog Webhooks Guide.

Event Types ​

EventResourceDescription
product.createdproductsNew product added
product.updatedproductsProduct details changed
product.deletedproductsProduct deactivated
option.createdoptionsNew option added
option.updatedoptionsOption details changed
option.deletedoptionsOption deactivated
category.createdcategoriesNew category added
category.updatedcategoriesCategory details changed
category.deletedcategoriesCategory deactivated
group.createdgroupsNew group added
group.updatedgroupsGroup details changed
group.deletedgroupsGroup deactivated

Payload Format ​

Every request carries one event. data is always a single object, or null for *.deleted events.

json
{
  "type": "product.updated",
  "resource": "products",
  "id": "CELCOM10",
  "timestamp": "2026-09-16T10:30:00.000Z",
  "data": {
    "product_code": "CELCOM10",
    "product_category_code": "MOBILE_PREPAID",
    "name": "Celcom Prepaid",
    "display_name": "Celcom Prepaid Reload",
    "note": null,
    "image_url": "https://dashboard.iimmpact.com/img/CELCOM10.png",
    "processing_time": "instant",
    "display_order": 1,
    "fields": [],
    "fulfillment": null,
    "is_active": true,
    "is_hidden": false,
    "options_source_type": "static",
    "denomination_currency": null,
    "denomination_unit_price": 1,
    "price_adjustment_type": null,
    "price_adjustment_value": null,
    "reseller_code": null,
    "unit_price": "0.00",
    "currency": "MYR",
    "discount": { "type": "percentage", "value": "0" },
    "min_amount": null,
    "max_amount": null
  }
}

The product example above is illustrative, not a captured response.

FieldTypeDescription
typestringEvent type (e.g. product.updated, option.created)
resourcestringResource kind: products, options, categories, groups
idstringIdentifier of the affected resource (see below)
timestampstringISO 8601 UTC timestamp of when the change occurred
dataobject/nullResource state, or null for delete events

Snapshot semantics ​

For every event except *.deleted, data is the complete effective state of the resource for your account: the base catalog merged with your own customisations. Replace your stored copy with it. A null field means "no value", so clear any value you stored before; do not keep the old one.

  • Removing one of your customisations sends an updated event carrying the base view. A deleted event is sent only when the resource no longer exists for you.
  • Products your account is not eligible to purchase arrive with is_active: false.
  • Availability and visibility are separate. Hidden products carry is_hidden: true and may still have is_active: true. Apply your own listing policy.

Option data ​

Option id is always PRODUCT_CODE:FIELD_ID:OPTION_CODE (the option code may itself contain :). The shorter PRODUCT_CODE:FIELD_ID form is not sent.

FieldTypeDescription
product_codestringProduct the option belongs to
field_idstringField the option belongs to
codestringOption code
labelstringDisplay label
descriptionstring|nullOptional description
display_ordernumberSort order
is_activebooleanWhether the option is available to you
denominationnumber|nullFace value or quantity in native units. null unless the field's role is pricing
priceMoney|nullYour payable price. Equals items[].price from GET /v2/options
costMoney|nullYour cost. Equals items[].cost from /options
rrpMoney|nullUndiscounted retail price. Equals items[].rrp from /options
min_amount, max_amountnullCurrently always null. Fetch GET /v2/options for authoritative per-biller amount limits

Money is { "amount": "<decimal string>", "currency": "MYR" }. For fields whose role is not pricing, denomination, price, cost and rrp are all null. For pricing fields, denomination, price, cost and rrp match what /options returns for your account when called with X-API-Version: 2026-09-16 (see Versioning).

Pricing field (illustrative example):

json
{
  "type": "option.updated",
  "resource": "options",
  "id": "EXAMPLE_TOPUP:package:10",
  "timestamp": "2026-09-16T10:30:00.000Z",
  "data": {
    "product_code": "EXAMPLE_TOPUP",
    "field_id": "package",
    "code": "10",
    "label": "RM10",
    "description": null,
    "display_order": 1,
    "is_active": true,
    "denomination": 10,
    "price": { "amount": "9.80", "currency": "MYR" },
    "cost": { "amount": "9.50", "currency": "MYR" },
    "rrp": { "amount": "10.00", "currency": "MYR" },
    "min_amount": null,
    "max_amount": null
  }
}

Reference field (illustrative example):

json
{
  "type": "option.created",
  "resource": "options",
  "id": "EXAMPLE_BILL:biller:ACME",
  "timestamp": "2026-09-16T10:30:00.000Z",
  "data": {
    "product_code": "EXAMPLE_BILL",
    "field_id": "biller",
    "code": "ACME",
    "label": "Acme Utilities",
    "description": null,
    "display_order": 3,
    "is_active": true,
    "denomination": null,
    "price": null,
    "cost": null,
    "rrp": null,
    "min_amount": null,
    "max_amount": null
  }
}

Category and group data ​

These carry your merged view, the same one GET /v2/catalog returns.

json
{
  "type": "category.updated",
  "resource": "categories",
  "id": "MOBILE_PREPAID",
  "timestamp": "2026-09-16T10:30:00.000Z",
  "data": {
    "product_category_code": "MOBILE_PREPAID",
    "product_group_code": "TELCO",
    "name": "Mobile Prepaid",
    "icon_url": null,
    "display_order": 1,
    "is_active": true
  }
}
json
{
  "type": "group.updated",
  "resource": "groups",
  "id": "TELCO",
  "timestamp": "2026-09-16T10:30:00.000Z",
  "data": {
    "product_group_code": "TELCO",
    "name": "Telco",
    "icon_url": null,
    "display_order": 1,
    "is_active": true
  }
}

Compatibility

Webhook consumers must ignore unknown fields. New optional fields can be added to resource payloads without creating a new event type.

Delete Events

For *.deleted events, the data field is null. Use the id field to remove the resource from your local store.

ID Formats

ResourceID FormatExample
productsPRODUCT_CODECELCOM10
optionsPRODUCT_CODE:FIELD_ID:OPTION_CODECELCOM10:package:10
categoriesCATEGORY_CODEMOBILE_PREPAID
groupsGROUP_CODETELCO

Request Headers ​

Each webhook request includes these headers:

HeaderDescription
Content-Typeapplication/json
X-Webhook-SignatureHMAC-SHA256 signature: sha256=<hex>

Signature Verification ​

All webhook requests include an X-Webhook-Signature header. Always verify the signature before processing the event to confirm it originated from IIMMPACT and was not tampered with.

The header format is sha256=<hex_digest>, computed as HMAC-SHA256 of the raw request body using your webhook secret.

typescript
import crypto from "crypto";

function verifyWebhookSignature(
  rawBody: string,
  signature: string | null,
  secret: string,
): boolean {
  if (!signature) return false;

  const providedSig = signature.startsWith("sha256=")
    ? signature.slice(7)
    : signature;

  const expectedSig = crypto
    .createHmac("sha256", secret)
    .update(rawBody)
    .digest("hex");

  try {
    return crypto.timingSafeEqual(
      Buffer.from(providedSig, "hex"),
      Buffer.from(expectedSig, "hex"),
    );
  } catch {
    return false;
  }
}

Use Raw Body

Verify the signature against the raw request body bytes, not a parsed-and-re-serialized JSON object. Re-serializing can change key order or whitespace, causing a mismatch.

Handler Example ​

A complete webhook handler should verify the signature, process the event idempotently, and return 200 OK. Treat each data as a full replacement of your stored copy.

typescript
app.post("/iimmpact/catalog-webhook", async (req, res) => {
  const signature = req.headers["x-webhook-signature"];
  // req.rawBody must be the unparsed request bytes.
  if (!verifyWebhookSignature(req.rawBody, signature, WEBHOOK_SECRET)) {
    return res.status(401).send("Invalid signature");
  }

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

  switch (type) {
    case "product.created":
    case "product.updated":
      await db.products.replace(id, data);
      break;

    case "product.deleted":
      await db.products.deactivate(id);
      break;

    case "option.created":
    case "option.updated":
      // data is one complete option; null fields mean "no value".
      // Storage schemas must accept denomination and ignore other unknown fields.
      await db.options.replace(id, data);
      break;

    case "option.deleted":
      await db.options.deactivate(id);
      break;

    // Handle category.* and group.* similarly.
  }

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

Respond Quickly

Return 2xx as soon as your backend accepts the event. If processing is slow, queue the work and process it asynchronously.

Delivery Requirements ​

  • The webhook URL must use https.
  • URLs that resolve to private, loopback or link-local addresses are rejected and not retried.
  • Redirects are not followed; a 3xx response counts as a failed delivery.
  • Each request times out after 30 seconds.

Ordering and reconciliation

Events are not guaranteed to arrive in order, and an event can be delivered more than once. Make handlers idempotent, and periodically refresh GET /v2/catalog and the relevant GET /v2/options pages to reconcile your copy (refresh account-dependent options for the corresponding account).

Retry Policy ​

Failed deliveries are retried with exponential backoff. After all retry attempts are exhausted, the event is dropped.

AttemptDelay After Failure
1Immediate
2~1 minute
3~2 minutes
4~4 minutes

A delivery is considered failed based on the response (any 2xx is success):

ResponseRetried?Reason
5xx server errorYesTransient failure
408 request timeoutYesTransient failure
429 too many requestsYesRate limited
Connection error / DNS failureYesNetwork issue
3xx redirectNoNot followed
4xx except 408 and 429NoPermanent failure

Permanent Failures

4xx responses, except 408 and 429, are treated as permanent failures and are not retried. Return 4xx only for genuinely invalid requests, such as bad signatures.

IIMMPACT API Documentation