Appearance
Webhook Setup
Configure your webhook in the IIMMPACT Dashboard: Developer > Webhooks, Payment Webhooks section.
- Enter your public HTTPS webhook URL and select the events. Subscribe to all of them unless you have a reason not to; without
refund.requires_action, FPX refunds wait for a claim you never see. - Save the signing secret securely when shown—it is displayed only once.
- Verify incoming notifications and return
2xx, as described below.
URL changes apply to new deliveries and retries immediately and keep your signing secret. Disabling webhooks or an event type drops its pending retries. Resend event resends a notification by its event_id; for anything you never received, read the payment, order or refund.
Rotate the signing secret
The Dashboard offers two modes:
| Mode | Effect |
|---|---|
| 24-hour overlap (default) | The new key becomes current immediately; the previous key signs deliveries too until its displayed expiry. Update receivers during this window. |
| Immediate replacement | Every previous key is revoked immediately. Update receivers in coordination and use this mode if a key is compromised. |
Save the new secret when shown. During overlap, accept either active secret; the verification example below supports both signatures. Remove expired or revoked secrets from your receiver. A second overlap rotation is available after the current overlap ends.
Receive a webhook
IIMMPACT sends an HTTP POST to your HTTPS endpoint when a payment, order, or refund changes status. The JSON body describes the resource at the time of that event.
- Verify the signature using the steps below.
- Check the event fields and save the event to your database or queue before responding.
- Return HTTP
200to confirm receipt, then process the saved event. Any 2xx response is accepted.
- Respond within 10 seconds with
2xx; redirects are not followed. - Retries: up to three retries after the initial attempt, approximately 60 seconds apart, for network errors, timeouts, or HTTP
408,429, and5xx. Other non-2xx responses are not retried automatically. - Avoid duplicates: process each
event_idonly once. Retries and manual resends keep the same event ID and body. - Check current status: events may arrive out of order. Use the resource's GET endpoint when you need its latest state. Webhook delivery does not affect payment or refund processing.
A manual resend sends only the notification; it never repeats a payment, topup or refund.
Signature verification
The signature lets your backend check that the notification came from IIMMPACT and its body was not changed. Use the webhook signing secret, not your API HMAC secret.
| Field | Type | Description |
|---|---|---|
Content-Type | string | application/json. |
IIMMPACT-Signature | string | Contains the delivery timestamp (t) and signature (v1), as shown below. |
http
Content-Type: application/json
IIMMPACT-Signature: t=<Unix-seconds>,v1=<hex-digest>Verification steps
- Read the original body bytes, before JSON parsing. Apply a request-size limit in your server or framework.
- Verify the header timestamp and signature using the function below.
- If verification fails, return
401. Otherwise, parse and validate the event, save it, then return200.
TypeScript example
Works with Bun or Node.js. The function checks the five-minute window and compares HMAC-SHA256 signatures in constant time.
typescript
import { Buffer } from "node:buffer";
import { createHmac, timingSafeEqual } from "node:crypto";
const SIGNATURE_PATTERN = /^t=(\d+),\s*v1=([a-fA-F0-9]{64})(?:,\s*v1=([a-fA-F0-9]{64}))?$/;
const TIMESTAMP_TOLERANCE_SECONDS = 5 * 60;
export function verifyWebhookSignature(
rawBody: Uint8Array,
signature: string | null,
activeWebhookSecrets: readonly string[],
nowSeconds = Math.floor(Date.now() / 1000)
): boolean {
const match = SIGNATURE_PATTERN.exec(signature ?? "");
if (!match) return false;
const timestamp = Number(match[1]);
if (
!Number.isSafeInteger(timestamp) ||
!Number.isFinite(nowSeconds) ||
Math.abs(nowSeconds - timestamp) > TIMESTAMP_TOLERANCE_SECONDS
) {
return false;
}
if (activeWebhookSecrets.length < 1 || activeWebhookSecrets.length > 2) return false;
const received = [match[2], match[3]]
.filter((value): value is string => value !== undefined)
.map((value) => Buffer.from(value, "hex"));
return activeWebhookSecrets.some((secret) => {
const key = Buffer.from(secret, "base64");
if (key.length === 0 || key.toString("base64") !== secret) return false;
const expected = createHmac("sha256", key)
.update(`${match[1]}.`)
.update(rawBody)
.digest();
return received.some((digest) => timingSafeEqual(expected, digest));
});
}Pass the exact body bytes, the IIMMPACT-Signature header, and an array containing your currently active Base64 webhook secrets (normally one). During overlap the header has repeated v1 entries: t=...,v1=...,v1=.... Do not turn the header into a map that discards repeated entries. The pattern checks digest length before comparison. Do not pass JSON.stringify(parsedBody)—even whitespace changes invalidate the signature.
For retried notifications, t is refreshed even if occurred_at is old. Check freshness against t, not occurred_at. During secret rotation, accept either currently active secret; stop accepting a revoked secret immediately.
Event catalog and schemas
Payment events
Each event's data follows the payment response fields, with action omitted. No card input is included.
| Event | Description |
|---|---|
payment.processing | The payment reached the provider and its result is not confirmed yet. Wait before creating another attempt. Not sent if the result is known at creation. |
payment.cancellation_pending | Cancellation was requested, but the result is not confirmed yet. |
payment.successful | Payment collection is confirmed. transaction_refids identifies the winning payment's fulfillment units; track the order for their results. |
payment.failed | Payment failed. The failure object explains the result. |
payment.cancelled | The payment was cancelled. A new payment may be created for the unpaid order. |
payment.expired | The payment window ended without a confirmed collection. A new payment may be created for the unpaid order. |
Order events
Each event's data follows the order response fields, including per-unit items, expires_at, and refunds known at that time.
| Event | Description |
|---|---|
order.completed | Every topup has a final result. Some may have failed; check the transactions. Refunds may still be processing. |
order.expired | Admission cutoff passed with no winning collection and no unresolved attempt. A late payment can still pay and fulfill it. |
Refund events
Each event's data follows the refund response fields. Track these events separately from order completion.
| Event | Description |
|---|---|
refund.pending | A refund exists; customer repayment is not yet confirmed. |
refund.requires_action | The customer must choose a bank account. See customer refund claims. |
refund.requires_review | The refund result is uncertain. IIMMPACT is reviewing it; do not refund the customer independently. |
refund.successful | Customer repayment is confirmed. |
Envelope fields
| Field | Type | Description |
|---|---|---|
event_id | string | Unique notification ID. Use it to avoid processing the same event twice. |
type | string | Event name; suffix matches data.status. |
occurred_at | string | UTC event occurrence time. |
data | object | Payment, order, or refund details at the time of the event. |
Payment example
Example of a confirmed payment:
json
{
"event_id": "01J9Z6S8V0W2X4Y6Z8A0B2C4D6",
"type": "payment.successful",
"occurred_at": "2026-09-14T02:10:42.000Z",
"data": {
"payment_id": "01J9Z6R2B4C6D8E0F2G4H6J8K0",
"order_id": "01J9Z6Q4M8N2P5R7S9T1V3W5X7",
"external_reference": "ORD-00042",
"customer_external_id": "CUS-1042",
"transaction_refids": ["MP-01M4WHY4T2VHJSJJXNFYA99Q0C-1", "MP-01M4WHY4T2VHJSJJXNFYA99Q0C-2"],
"status": "successful",
"currency": "MYR",
"payment_method_code": "CARD",
"customer_fee_percent": "50.00",
"fee": {
"type": "percentage",
"value": "1.70"
},
"totals": {
"items_subtotal": "100.00",
"fee": {
"total": "1.70",
"customer": "0.85",
"merchant": "0.85"
},
"payment_total": "100.85"
},
"created_at": "2026-09-14T02:05:00.000Z",
"updated_at": "2026-09-14T02:10:42.000Z",
"expires_at": "2026-09-14T02:35:00.000Z"
}
}Order example
After all units finish, the full order snapshot includes their recorded results. This successful example has no refunds:
json
{
"event_id": "01J9Z6T1E3F5G7H9J1K3M5N7P9",
"type": "order.completed",
"occurred_at": "2026-09-14T02:10:42.000Z",
"data": {
"order_id": "01J9Z6Q4M8N2P5R7S9T1V3W5X7",
"external_reference": "ORD-00042",
"status": "completed",
"currency": "MYR",
"items_subtotal": "100.00",
"items": [
{
"product": "TNB",
"product_name": "Tenaga Nasional Berhad",
"account": "220012345679",
"amount": "60.00",
"status": "successful",
"refid": "MP-01M4WHY4T2VHJSJJXNFYA99Q0C-1",
"remarks": "Managed payment 01J9Z6Q4M8N2P5R7S9T1V3W5X7",
"status_code": 20,
"sn": "",
"pin": "",
"expiry": "",
"cost": "58.1234",
"note": "",
"voucherlink": "",
"timestamp": "2026-09-14T02:10:42.000Z"
},
{
"product": "TNB",
"product_name": "Tenaga Nasional Berhad",
"account": "220012345678",
"amount": "40.00",
"status": "successful",
"refid": "MP-01M4WHY4T2VHJSJJXNFYA99Q0C-2",
"remarks": "Managed payment 01J9Z6Q4M8N2P5R7S9T1V3W5X7",
"status_code": 20,
"sn": "",
"pin": "",
"expiry": "",
"cost": "39.1250",
"note": "",
"voucherlink": "",
"timestamp": "2026-09-14T02:10:42.000Z"
}
],
"refunds": [],
"metadata": null,
"created_at": "2026-09-14T02:00:00.000Z",
"updated_at": "2026-09-14T02:10:42.000Z",
"expires_at": "2026-09-15T02:00:00.000Z"
}
}order.completed means every unit is terminal, not that every unit succeeded or every refund finished; see Refunds for repayment progress. It contains the recorded per-unit results and refunds known at that time, without items[].unit_price. See the populated order detail example. Retries and replay retain the original event body; later refund changes appear through refund events or a fresh order read.
Refund example
json
{
"event_id": "01J9Z7B4D6F8H0K2M4N6P8R0S2",
"type": "refund.successful",
"occurred_at": "2026-09-24T05:11:49.707Z",
"data": {
"refund_id": "01J9Z7A3C5E7G9J1K3M5N7P9Q1",
"order_id": "01J9Z6Q4M8N2P5R7S9T1V3W5X7",
"payment_id": "01J9Z6R2B4C6D8E0F2G4H6J8K0",
"reason": "failed_fulfillment",
"amount": "10.00",
"currency": "MYR",
"status": "successful",
"created_at": "2026-09-24T05:11:49.321Z",
"updated_at": "2026-09-24T05:11:49.707Z"
}
}Duplicate or delayed notifications
| Situation | What to do |
|---|---|
The same event_id arrives again | If you already saved it, return 2xx without processing it again. Retain these IDs for at least seven days. |
| An older update arrives after a newer one | Keep the newer status. For example, a delayed payment.processing must not replace payment.successful. If unsure, read the current payment status. |
payment.successful arrives after payment.cancelled or payment.expired | The customer paid late. Treat the payment as successful. |
| You need to know whether the customer paid | Call Get Payment Detail. |
| You need the latest topup results or refund status | Call Get Order Detail. |
Notifications can arrive late or be missed. A repeated notification does not repeat a charge, topup, or refund. Your handler should also avoid repeating its own actions, such as sending the same receipt twice. Keep signing secrets, PINs, and voucher links out of logs.
