Appearance
Balance Webhook Events
Balance webhooks send one signed event per eligible balance credit. For Dashboard setup and use cases, see the Balance Webhooks Guide.
Event Types
| Event | Description |
|---|---|
balance.added | An eligible positive credit was recorded in your IIMMPACT balance |
balance.added is currently the only event type. The type and version fields are reserved for contract evolution; accept the documented values and do not infer other event types.
Payload Format
json
{
"type": "balance.added",
"version": "1",
"id": "mutasi:155062341",
"created_at": "2026-09-07T12:20:00.000Z",
"data": {
"resellerCode": "3000001",
"amount": 1000,
"balance": 12500.45,
"description": "MBB TRF 1234"
}
}| Field | Type | Description |
|---|---|---|
type | string | Event type. Currently always balance.added |
version | string | Payload contract version. Currently always 1 |
id | string | Stable event ID in the format mutasi:<code>; use it as the idempotency key |
created_at | string | Time the credit was recorded, as a UTC ISO 8601 timestamp ending in Z |
data | object | Balance credit details |
data.resellerCode | string | Reseller code whose balance was credited |
data.amount | number | Amount credited |
data.balance | number | null | Balance after the credit; may be null if the source ledger balance is unavailable |
data.description | string | Trimmed ledger description; may be an empty string |
Amounts are JSON numbers rounded to two decimal places. JSON does not preserve insignificant trailing zeros, so 1000.00 is sent as 1000.
Request Headers
Each request includes these headers:
| Header | Value |
|---|---|
Content-Type | application/json |
X-Webhook-Signature | sha256=<hex HMAC-SHA256> |
X-Webhook-Event | balance.added |
X-Webhook-Id | The payload id, for example mutasi:155062341 |
User-Agent | IIMMPACT-Balance-Webhook/1.0 |
Signature Verification
The signature is the lowercase hexadecimal HMAC-SHA256 digest of the exact request body, using your webhook secret as the UTF-8 key. The header format is sha256=<hex_digest>.
Verify the Raw Request Bytes
Verify X-Webhook-Signature against the raw request bytes exactly as received, not parsed and re-serialized JSON. Re-serialization can change whitespace, escaping, or key order and invalidate the signature.
Node.js
ts
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; }
}PHP
php
<?php
function verifyWebhookSignature(string $rawBody, ?string $signature, string $secret): bool
{
if ($signature === null) {
return false;
}
$providedSignature = str_starts_with($signature, 'sha256=')
? substr($signature, 7)
: $signature;
$expectedSignature = hash_hmac('sha256', $rawBody, $secret);
return hash_equals($expectedSignature, $providedSignature);
}Test Vector
Use this fixed vector to test your implementation. The body is one compact line with no trailing newline.
Secret
text
docs-test-secretRaw body
json
{"type":"balance.added","version":"1","id":"mutasi:155062341","created_at":"2026-09-07T12:20:00.000Z","data":{"resellerCode":"3000001","amount":1000,"balance":12500.45,"description":"MBB TRF 1234"}}Expected signature
text
sha256=036509838b584a3102ecb0e7263ab875c378117d2bbc6762e5a7f8dc5e99bebaHandler Example
Verify and persist the event ID before acknowledging it. Queue slow business logic so the endpoint can return 2xx quickly.
ts
app.post("/iimmpact/balance-webhook", async (req, res) => {
const rawBody = req.rawBody;
const signature = req.get("X-Webhook-Signature");
if (!verifyWebhookSignature(rawBody, signature, WEBHOOK_SECRET)) {
return res.status(401).send("Invalid signature");
}
const event = JSON.parse(rawBody);
if (event.type !== "balance.added" || event.version !== "1") {
return res.status(400).send("Unsupported event");
}
// insertIfAbsent must enforce a unique constraint on event.id.
const accepted = await db.webhookEvents.insertIfAbsent({
id: event.id,
balanceAfterCredit: event.data.balance,
payload: event,
});
if (accepted) {
await queue.add("release-waiting-orders", {
eventId: event.id,
availableBalance: event.data.balance,
});
}
return res.status(202).send("Accepted");
});Retry Policy
IIMMPACT waits up to 10 seconds for each request. A retryable failure receives three retries with exponential backoff starting at approximately 60 seconds.
| Result | Outcome | Reason |
|---|---|---|
Any 2xx response | Success | Event accepted |
408 request timeout | Retry | Transient timeout |
429 too many requests | Retry | Rate limited |
5xx server error | Retry | Transient server failure |
| Network, DNS, or 10-second delivery timeout | Retry | No successful response |
Other 4xx response | Terminal; no automatic retry | Request rejected permanently |
| Attempt | Approximate Delay After Previous Failure |
|---|---|
| Initial | Immediate |
| Retry 1 | 1 minute |
| Retry 2 | 2 minutes |
| Retry 3 | 4 minutes |
If a retryable failure exhausts those retries, the delivery enters recovery. IIMMPACT can re-cycle it up to 10 times, waiting approximately 1, 2, 4, 8, 16, then at most 30 minutes between cycles. Each cycle uses the same three-retry policy, so we keep trying for up to several hours. Non-retryable 4xx failures do not enter this recovery cycle.
Failed deliveries can also be replayed from Developer › Webhooks › Delivery Logs › Balance, up to three manual replays per event. The same limit applies through the delivery logs API.
Idempotency
Balance webhook delivery is at least once. The payload id is stable across automatic retries, recovery cycles, and manual replays, and it matches X-Webhook-Id. Enforce a unique constraint on this value and return 2xx for duplicates already accepted. Do not use delivery time or request count as the event identity.
