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
{
"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
}environmentistestorlive, and is signed. A test event must never release real value. It is absent only on rows from before environments were recorded.statusissuccessfulorfailed.failedincludes a provider refusal and a pay-in that timed out after 10 minutes, which Query payin status reports asTIMED_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.typeisINWARDfor pay-ins andOUTWARDfor payouts. Payouts can also carrybeneficiaryName,merchantNameandmerchant-name.feeandvatare always0.0at present, andgatewayReferenceis 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.
- Take every field in the body except
sign, including any you do not recognise and any empty string. - Sort by key ascending and join as
key=value&key=value, with no URL encoding. - Compute
urlEncode(base64(HMAC_SHA256(secretKey, canonical)))with your API secret key, and compare it withsignas 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:
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)
}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
// 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:
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¤cy=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%3DYour 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