Skip to content

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

EventDescription
balance.addedAn 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"
  }
}
FieldTypeDescription
typestringEvent type. Currently always balance.added
versionstringPayload contract version. Currently always 1
idstringStable event ID in the format mutasi:<code>; use it as the idempotency key
created_atstringTime the credit was recorded, as a UTC ISO 8601 timestamp ending in Z
dataobjectBalance credit details
data.resellerCodestringReseller code whose balance was credited
data.amountnumberAmount credited
data.balancenumber | nullBalance after the credit; may be null if the source ledger balance is unavailable
data.descriptionstringTrimmed 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:

HeaderValue
Content-Typeapplication/json
X-Webhook-Signaturesha256=<hex HMAC-SHA256>
X-Webhook-Eventbalance.added
X-Webhook-IdThe payload id, for example mutasi:155062341
User-AgentIIMMPACT-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-secret

Raw 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=036509838b584a3102ecb0e7263ab875c378117d2bbc6762e5a7f8dc5e99beba

Handler 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.

ResultOutcomeReason
Any 2xx responseSuccessEvent accepted
408 request timeoutRetryTransient timeout
429 too many requestsRetryRate limited
5xx server errorRetryTransient server failure
Network, DNS, or 10-second delivery timeoutRetryNo successful response
Other 4xx responseTerminal; no automatic retryRequest rejected permanently
AttemptApproximate Delay After Previous Failure
InitialImmediate
Retry 11 minute
Retry 22 minutes
Retry 34 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.

IIMMPACT API Documentation