Appearance
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
| Event | Resource | Description |
|---|---|---|
product.created | products | New product added |
product.updated | products | Product details changed |
product.deleted | products | Product deactivated |
option.created | options | New option added |
option.updated | options | Option details changed |
option.deleted | options | Option deactivated |
category.created | categories | New category added |
category.updated | categories | Category details changed |
category.deleted | categories | Category deactivated |
group.created | groups | New group added |
group.updated | groups | Group details changed |
group.deleted | groups | Group 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.
| Field | Type | Description |
|---|---|---|
type | string | Event type (e.g. product.updated, option.created) |
resource | string | Resource kind: products, options, categories, groups |
id | string | Identifier of the affected resource (see below) |
timestamp | string | ISO 8601 UTC timestamp of when the change occurred |
data | object/null | Resource 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
updatedevent carrying the base view. Adeletedevent 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: trueand may still haveis_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.
| Field | Type | Description |
|---|---|---|
product_code | string | Product the option belongs to |
field_id | string | Field the option belongs to |
code | string | Option code |
label | string | Display label |
description | string|null | Optional description |
display_order | number | Sort order |
is_active | boolean | Whether the option is available to you |
denomination | number|null | Face value or quantity in native units. null unless the field's role is pricing |
price | Money|null | Your payable price. Equals items[].price from GET /v2/options |
cost | Money|null | Your cost. Equals items[].cost from /options |
rrp | Money|null | Undiscounted retail price. Equals items[].rrp from /options |
min_amount, max_amount | null | Currently 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
| Resource | ID Format | Example |
|---|---|---|
products | PRODUCT_CODE | CELCOM10 |
options | PRODUCT_CODE:FIELD_ID:OPTION_CODE | CELCOM10:package:10 |
categories | CATEGORY_CODE | MOBILE_PREPAID |
groups | GROUP_CODE | TELCO |
Request Headers
Each webhook request includes these headers:
| Header | Description |
|---|---|
Content-Type | application/json |
X-Webhook-Signature | HMAC-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
3xxresponse 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.
| Attempt | Delay After Failure |
|---|---|
| 1 | Immediate |
| 2 | ~1 minute |
| 3 | ~2 minutes |
| 4 | ~4 minutes |
A delivery is considered failed based on the response (any 2xx is success):
| Response | Retried? | Reason |
|---|---|---|
5xx server error | Yes | Transient failure |
408 request timeout | Yes | Transient failure |
429 too many requests | Yes | Rate limited |
| Connection error / DNS failure | Yes | Network issue |
3xx redirect | No | Not followed |
4xx except 408 and 429 | No | Permanent 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.
