Guides

Webhooks

Klustar POSTs signed JSON to the one URL saved in your dashboard when a pay-in or dashboard payout reaches a final status. Respond with any 2xx.

Delivery

Save your https URL in Profile & Settings → API Keys before your first pay-in. That is the only destination: the notifyUrl request field never receives a webhook. There is one URL per App ID, for both test and live, so check environment on every event.

  • Pay-ins: up to 5 attempts, 2 minutes apart, so delivery stops after about 8 minutes and nothing resends it. With no URL saved, the attempts still run and the event is lost: saving a URL later does not deliver it. Use Query payin status to recover a result.
  • Dashboard payouts: up to 10 attempts with growing delays, over about 4 hours. With no URL saved, nothing is sent. None are sent on production at the moment: no payout gateway is configured, so dashboard payouts stop before they reach a result.

Every webhook comes from Klustar's fixed IP address 98.85.73.36, for both Sandbox and Live. If your firewall only accepts known addresses, allow this one.

Payload

Payin webhook
{
  "amount": 2000.0,
  "completeTime": 1793440865000,
  "currency": "NGN",
  "environment": "test",
  "fee": 0.0,
  "gatewayReference": "",
  "merchantId": "00000000-0000-4000-8000-000000000001",
  "reference": "ORDER-8f14e45f-ceea-467d-a5b3-0c1f7b2d9e11",
  "sign": "...",
  "status": "successful",
  "type": "INWARD",
  "vat": 0.0
}
  • environment is test or live, and is signed. A test event must never release real value. It is absent only on rows from before environments were recorded.
  • status is successful or failed. failed includes a provider refusal and a pay-in that timed out after 10 minutes, which Query payin status reports as TIMED_OUT. Do not tell a customer a timed-out payment definitely failed without checking with support: a payment made after the limit is not picked up.
  • type is INWARD for pay-ins and OUTWARD for payouts. Payouts can also carry beneficiaryName, merchantName and merchant-name.
  • fee and vat are always 0.0 at present, and gatewayReference is empty on Korapay pay-ins.

Verify the sign field

sign is on every webhook. It is not the request signature: it is Base64, not hex, and signs only the fields.

  1. Take every field in the body except sign, including any you do not recognise and any empty string.
  2. Sort by key ascending and join as key=value&key=value, with no URL encoding.
  3. Compute urlEncode(base64(HMAC_SHA256(secretKey, canonical))) with your API secret key, and compare it with sign as received.

Use number text from the raw body. Numbers are rendered the way Java prints a double: 2000 arrives and signs as 2000.0, and from 10,000,000 upward 12345678.90 arrives and signs as 1.23456789E7. A verifier that parses JSON into a number and prints it again gets 2000 or 12345678.9 and fails. So compare amounts as numbers, and verify from the raw text, as these do:

Node
import crypto from 'crypto'

// Pass the raw request body, before any JSON parsing: number text such as 2000.0 or 1.23456789E7
// is signed as sent, and JSON.parse would turn it into 2000 or 12345678.9.
export function verifyKlustarWebhook(rawBody, secretKey) {
  const withTextNumbers = rawBody.replace(/"(?:[^"\\]|\\.)*"|-?\d[\d.eE+-]*/g, (token) =>
    token[0] === '"' ? token : `"${token}"`
  )
  let fields
  try {
    fields = JSON.parse(withTextNumbers)
  } catch {
    return false
  }
  if (fields === null || typeof fields !== 'object' || Array.isArray(fields)) return false
  const canonical = Object.keys(fields)
    .filter((key) => key !== 'sign')
    .sort()
    .map((key) => `${key}=${fields[key]}`)
    .join('&')
  const digest = crypto.createHmac('sha256', secretKey).update(canonical).digest('base64')
  const expected = Buffer.from(encodeURIComponent(digest))
  const received = Buffer.from(String(fields.sign))
  return expected.length === received.length && crypto.timingSafeEqual(expected, received)
}
Python
import base64, hashlib, hmac, json
from urllib.parse import quote

# Pass the raw request body. parse_int and parse_float keep number text such as 2000.0 or
# 1.23456789E7 exactly as it was signed.
def verify_klustar_webhook(raw_body, secret_key):
    try:
        fields = json.loads(raw_body, parse_int=str, parse_float=str)
    except ValueError:
        return False
    if not isinstance(fields, dict):
        return False
    canonical = "&".join(f"{k}={fields[k]}" for k in sorted(fields) if k != "sign")
    digest = hmac.new(secret_key.encode(), canonical.encode(), hashlib.sha256).digest()
    expected = quote(base64.b64encode(digest).decode(), safe="")
    return hmac.compare_digest(expected.encode(), str(fields.get("sign", "")).encode())
PHP
<?php
// Pass the raw request body (file_get_contents('php://input')), before json_decode: number text
// such as 2000.0 or 1.23456789E7 is signed as sent, and json_decode would change it.
function verifyKlustarWebhook(string $rawBody, string $secretKey): bool {
    $withTextNumbers = preg_replace_callback(
        '/"(?:[^"\\\\]|\\\\.)*"|-?\d[\d.eE+-]*/',
        fn($m) => $m[0][0] === '"' ? $m[0] : '"' . $m[0] . '"',
        $rawBody
    );
    $fields = json_decode($withTextNumbers, true);
    if (!is_array($fields)) {
        return false;
    }
    $sign = (string) ($fields['sign'] ?? '');
    unset($fields['sign']);
    ksort($fields, SORT_STRING);
    $parts = [];
    foreach ($fields as $key => $value) {
        $parts[] = "{$key}={$value}";
    }
    $digest = hash_hmac('sha256', implode('&', $parts), $secretKey, true);
    return hash_equals(rawurlencode(base64_encode($digest)), $sign);
}

These dummy values must verify:

Self-check
secretKey = FAKEsecretKEY000000000000000000000000000000
rawBody   = {"amount":2000.0,"completeTime":1793440865000,"currency":"NGN","environment":"test","fee":0.0,"gatewayReference":"","merchantId":"00000000-0000-4000-8000-000000000001","reference":"ORDER-self-check-0001","sign":"FfflA81wLLonAHBe%2Fii1YpdnBIpzluvLgGif2SfdxVU%3D","status":"successful","type":"INWARD","vat":0.0}

canonical = amount=2000.0&completeTime=1793440865000&currency=NGN&environment=test&fee=0.0&gatewayReference=&merchantId=00000000-0000-4000-8000-000000000001&reference=ORDER-self-check-0001&status=successful&type=INWARD&vat=0.0
sign      = FfflA81wLLonAHBe%2Fii1YpdnBIpzluvLgGif2SfdxVU%3D

Your handler

  • Respond quickly with any 2xx
  • Treat deliveries as at-least-once — make handlers idempotent on reference
  • Verify the sign field before updating orders
  • Check environment and amount before releasing value