Webhooks
Receive real-time notifications when purchases or withdrawals succeed or fail.
Real-Time Webhook Notifications
Webhooks allow your server to receive real-time POST requests whenever a purchase transaction succeeds or fails, or a wallet withdrawal is settled. This eliminates the need to poll the API for transaction status.
Setting Up
Create Endpoint
Go to Dashboard → Settings → Dev Console and scroll to the Webhook Endpoints section.
Click Add Endpoint and enter:
| Field | Description |
|---|---|
| URL | Your HTTPS endpoint that will receive POST requests (e.g., https://api.yourserver.com/kda-webhook) |
| Categories | Subscribe to Purchase, Withdrawal, or both. Each category delivers both success and failure payloads |
A signing secret is auto-generated for each endpoint. Copy it — you'll need it to verify incoming payloads.
Verify Signatures
Every webhook request includes an X-Webhook-Signature header containing the HMAC-SHA256 of the exact raw request body, computed with your signing secret. Verify the header against the raw body string before parsing the JSON:
import { createHmac, timingSafeEqual } from "crypto";
// Express setup (capture raw body before parsing):
// app.use(express.json({ verify: (req, _res, buf) => { req.rawBody = buf; } }));
/**
* Verifies an HMAC-SHA256 webhook signature.
*
* @param {import('http').IncomingMessage & { rawBody?: Buffer }} req
* @param {string} secret - The shared webhook secret.
* @returns {boolean}
*/
function verifyWebhook(req, secret) {
const signature = req.headers["x-webhook-signature"];
const rawBody = req.rawBody; // raw bytes, NOT req.body
if (typeof signature !== "string" || !Buffer.isBuffer(rawBody)) {
return false;
}
const expected = createHmac("sha256", secret).update(rawBody).digest("hex");
const expectedBuf = Buffer.from(expected, "hex");
const signatureBuf = Buffer.from(signature, "hex");
// timingSafeEqual throws on length mismatch, so guard first.
// Length itself isn't secret, so this comparison need not be constant-time.
if (expectedBuf.length !== signatureBuf.length) {
return false;
}
if (!timingSafeEqual(expectedBuf, signatureBuf)) {
return false;
}
// Parse ONLY after the signature is verified.
let payload;
try {
payload = JSON.parse(rawBody);
} catch {
return false;
}
// Reject replays: reject both stale and future-dated timestamps.
const webhookTime = new Date(payload.timestamp).getTime();
const skew = Date.now() - webhookTime;
if (Number.isNaN(webhookTime) || Math.abs(skew) > 5 * 60 * 1000) {
return false;
}
return true;
}
export { verifyWebhook };
import hashlib
import hmac
import json
import time
from typing import Optional
def verify_webhook(raw_body: bytes, signature: str, secret: str,
max_skew_seconds: int = 300) -> Optional[dict]:
"""
Verifies an HMAC-SHA256 webhook signature and returns the parsed
payload if valid, or None if verification fails.
raw_body: the exact raw request bytes (NOT a parsed/re-serialized object)
signature: the hex-encoded signature from the request header
secret: the shared webhook secret
max_skew_seconds: allowed clock skew in either direction (default 5 min)
"""
if not isinstance(raw_body, (bytes, bytearray)) or not isinstance(signature, str):
return None
expected = hmac.new(secret.encode("utf-8"), raw_body, hashlib.sha256).hexdigest()
try:
expected_bytes = bytes.fromhex(expected)
signature_bytes = bytes.fromhex(signature)
except ValueError:
# signature wasn't valid hex
return None
if len(expected_bytes) != len(signature_bytes):
return None
if not hmac.compare_digest(expected_bytes, signature_bytes):
return None
# Parse ONLY after the signature is verified
try:
payload = json.loads(raw_body)
except (json.JSONDecodeError, UnicodeDecodeError):
return None
if not isinstance(payload, dict):
return None
webhook_time = payload.get("timestamp")
if not isinstance(webhook_time, (int, float)):
return None
# Reject both stale and future-dated timestamps
skew = time.time() - webhook_time
if abs(skew) > max_skew_seconds:
return None
return payload
# Example usage:
# raw_body = request.get_data() # Flask; use request.body for Django
# signature = request.headers.get("X-Webhook-Signature", "")
# payload = verify_webhook(raw_body, signature, secret)
# if payload is None:
# abort(401)
<?php
/**
* Verifies an HMAC-SHA256 webhook signature and returns the parsed
* payload if valid, or null if verification fails.
*
* @param string $rawBody The exact raw request body (file_get_contents('php://input'))
* @param string|null $signature The hex-encoded signature from the request header
* @param string $secret The shared webhook secret
* @param int $maxSkewSeconds Allowed clock skew in either direction (default 5 minutes)
* @return array|null
*/
function verifyWebhook(string $rawBody, ?string $signature, string $secret, int $maxSkewSeconds = 300): ?array
{
if ($signature === null || $signature === '') {
return null;
}
$expected = hash_hmac('sha256', $rawBody, $secret);
$expectedBytes = hex2bin($expected);
$signatureBytes = @hex2bin($signature); // '@' suppresses warning on invalid hex
if ($expectedBytes === false || $signatureBytes === false) {
return null;
}
if (strlen($expectedBytes) !== strlen($signatureBytes)) {
return null;
}
if (!hash_equals($expectedBytes, $signatureBytes)) {
return null;
}
// Parse ONLY after the signature is verified
$payload = json_decode($rawBody, true);
if (json_last_error() !== JSON_ERROR_NONE || !is_array($payload)) {
return null;
}
if (!isset($payload['timestamp'])) {
return null;
}
// Adjust to (int) $payload['timestamp'] if your timestamp is a Unix integer
// rather than an ISO 8601 / parseable date string.
$webhookTime = strtotime((string) $payload['timestamp']);
if ($webhookTime === false) {
return null;
}
// Reject both stale and future-dated timestamps
$skew = time() - $webhookTime;
if (abs($skew) > $maxSkewSeconds) {
return null;
}
return $payload;
}
// Example usage:
// $rawBody = file_get_contents('php://input');
// $signature = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? null;
// $payload = verifyWebhook($rawBody, $signature, $secret);
// if ($payload === null) {
// http_response_code(401);
// exit;
// }
Verify the signature before parsing the body — this prevents both re-serialization mismatches (key order, number formatting, escaping) and DoS attacks that parse untrusted payloads before authenticating them.
Hash the raw request body exactly as received — never parse, re-serialize, or modify it before verifying. The signature lives in the X-Webhook-Signature header, not in the JSON body, so there is nothing to strip from the payload.
You can test your endpoint from the Developer Settings page. Test buttons are scoped to the categories the endpoint is subscribed to, and let you simulate success or failure payloads for each.
How Subscriptions Work
Subscriptions are category-based: an endpoint subscribed to Purchase receives both PURCHASE_SUCCESS and PURCHASE_FAILED payloads, and an endpoint subscribed to Withdrawal receives both WITHDRAWAL_SUCCESS and WITHDRAWAL_FAILED payloads. The payload's event field tells you which one arrived.
Payload Format
{
"event": "PURCHASE_SUCCESS",
"timestamp": "2026-06-22T19:30:00.000Z",
"data": {
"transactionId": "KDS-DATA-...",
"type": "DATA",
"amount": 500,
"status": "SUCCESS",
"providerRef": "KDS-REF-001",
"identifier": "08031234567"
}
}{
"event": "PURCHASE_FAILED",
"timestamp": "2026-06-22T19:30:05.000Z",
"data": {
"transactionId": "cm7def456...",
"type": "AIRTIME",
"amount": 200,
"status": "FAILED",
"errorMessage": "Insufficient provider balance",
"identifier": "08031234567"
}
}Fires when a withdrawal requested via POST /api/profile/withdraw is settled by the bank. It is the terminal status for the PENDING response you received at request time — subscribe to it to avoid polling.
{
"event": "WITHDRAWAL_SUCCESS",
"timestamp": "2026-06-22T19:45:00.000Z",
"data": {
"transactionId": "WITHDRAW-3F2K9XC1WQ-REQ",
"type": "WITHDRAWAL",
"amount": 5000,
"fee": 50,
"total": 5050,
"status": "SUCCESS",
"bank": {
"bankCode": "035",
"bankName": "Wema Bank",
"accountNumber": "0123456789",
"accountName": "John Doe"
},
"narration": "Withdrawal to Wema Bank",
"reference": "BANK|...",
"disbursementRef": "BANK|...",
"completedAt": "2026-06-22T19:45:00.000Z"
}
}Sandbox (test) keys never trigger this event. Test withdrawals are simulated and never reach a bank, so no real settlement webhook is fired. Use the Test button in Developer Settings to verify your handler.
Exactly-once delivery: a withdrawal settles once, and the event is dispatched once — even if the settlement provider re-posts its confirmation. If a withdrawal is reversed/failed, the debit is refunded and WITHDRAWAL_FAILED is dispatched instead of WITHDRAWAL_SUCCESS.
Fires when a withdrawal requested via POST /api/profile/withdraw is rejected or reversed by the bank. The debited amount + fee is refunded to the wallet, and this event is the terminal status — subscribe to it to detect failures without polling.
{
"event": "WITHDRAWAL_FAILED",
"timestamp": "2026-06-22T19:45:05.000Z",
"data": {
"transactionId": "WITHDRAW-3F2K9XC1WQ-REQ",
"type": "WITHDRAWAL",
"amount": 5000,
"fee": 50,
"total": 5050,
"status": "FAILED",
"bank": {
"bankCode": "035",
"bankName": "Wema Bank",
"accountNumber": "0123456789",
"accountName": "John Doe"
},
"narration": "Withdrawal to Wema Bank",
"reference": "BANK|...",
"disbursementRef": "BANK|...",
"errorMessage": "Funds have been refunded due to failure",
"failedAt": "2026-06-22T19:45:05.000Z"
}
}Sandbox (test) keys never trigger this event. Test withdrawals are simulated and never reach a bank, so no real settlement webhook is fired. Use the Test button in Developer Settings to verify your handler.
Exactly-once delivery: a withdrawal fails once, and the event is dispatched once — even if the settlement provider re-posts its confirmation. The wallet refund happens before the event is dispatched, so your handler can safely reconcile on transactionId.
Receiving a Webhook
Your endpoint must:
Respond Quickly
Respond with 200 OK within 10 seconds. KDA Turbo will time out and log a warning if your server is slow.
Verify the Signature
Verify the X-Webhook-Signature header against the raw request body using the secret copied at creation time — and only parse the JSON after verification passes.
Prevent Replay Attacks
Reject payloads whose timestamp is older than 5 minutes. The verification examples above enforce this check.
If your endpoint responds with a non-200 status or times out, KDA Turbo logs the failure but does not currently retry. A retry queue is planned for a future release.
Managing Endpoints
From the Developer Settings page you can:
| Action | Description |
|---|---|
| Toggle active/inactive | Temporarily disable without deleting |
| Test | Send a test payload — success/failure buttons are shown for each subscribed category (e.g. Purchase → Success/Failure) |
| Rotate secret | Generate a new signing secret — update your server immediately after |
| Delete | Permanently remove the endpoint |
Security
- HTTPS is required — webhook URLs must start with
https://. - Signing secrets are per-endpoint and independent of your API keys. Rotating a webhook secret does not affect your API access.
- API keys are never included in webhook payloads.