Secure webhook signature verification guide for developers
CollectUG sends webhook notifications to your application when transaction statuses change. Each webhook includes a cryptographic signature that you must verify to ensure authenticity and security.
Login to your CollectUG merchant dashboard and navigate to Settings โ API Integration to find your unique webhook secret. This secret is automatically generated and is unique to your account.
When sending a webhook, CollectUG creates a signature using exactly these 3 fields in this specific format:
{"transaction_id":"TXN_123456789","amount":"10000","status":"completed"}
signature = HMAC-SHA256(json_string, webhook_secret)
transaction_id, amount, status. The JSON must be compact (no spaces) and fields must be in alphabetical order.
CollectUG sends the complete webhook payload including the signature and additional transaction details:
{
"transaction_id": "TXN_123456789",
"external_transaction_id": "EXT_123",
"amount": "10000",
"status": "completed",
"currency": "UGX",
"phone_number": "256701234567",
"transaction_type": "deposit",
"signature": "a1b2c3d4e5f6..."
}
Your webhook endpoint receives the payload and verifies the signature using the exact same process CollectUG used to generate it.
<?php
function verifyCollectUGWebhook($payload, $webhookSecret) {
// Extract signature from payload
$receivedSignature = $payload['signature'] ?? '';
if (empty($receivedSignature)) {
return false;
}
// Create signature data (EXACTLY these 3 fields in alphabetical order)
$signatureData = [
'amount' => $payload['amount'],
'status' => $payload['status'],
'transaction_id' => $payload['transaction_id']
];
// Convert to JSON string (compact format, no spaces)
$jsonString = json_encode($signatureData, JSON_UNESCAPED_SLASHES);
// Generate expected signature using HMAC-SHA256
$expectedSignature = hash_hmac('sha256', $jsonString, $webhookSecret);
// Compare signatures securely (timing-safe comparison)
return hash_equals($expectedSignature, $receivedSignature);
}
// Complete webhook endpoint example
$webhookSecret = getenv('COLLECTUG_WEBHOOK_SECRET'); // Store in environment variable
// Get the raw POST data
$rawPayload = file_get_contents('php://input');
$payload = json_decode($rawPayload, true);
// Validate required fields
if (!isset($payload['transaction_id'], $payload['amount'], $payload['status'])) {
http_response_code(400);
echo json_encode(['status' => 'error', 'message' => 'Missing required fields']);
exit;
}
// Verify the webhook signature
if (verifyCollectUGWebhook($payload, $webhookSecret)) {
// โ
Webhook is authentic - process the transaction
$transactionId = $payload['transaction_id'];
$status = $payload['status'];
$amount = $payload['amount'];
$phoneNumber = $payload['phone_number'];
$transactionType = $payload['transaction_type'];
// Log the webhook for debugging
error_log("CollectUG webhook verified: {$transactionId} - {$status}");
// Your business logic here
switch ($status) {
case 'completed':
// Handle successful transaction
updateTransactionStatus($transactionId, 'completed');
sendConfirmationNotification($phoneNumber, $amount);
// For deposits, credit user account
if ($transactionType === 'deposit') {
creditUserAccount($phoneNumber, $amount);
}
break;
case 'failed':
// Handle failed transaction
updateTransactionStatus($transactionId, 'failed');
sendFailureNotification($phoneNumber, $amount);
break;
case 'pending':
// Handle pending transaction
updateTransactionStatus($transactionId, 'pending');
break;
default:
error_log("Unknown transaction status: {$status}");
}
// Always respond with 200 OK for valid webhooks
http_response_code(200);
echo json_encode(['status' => 'success', 'message' => 'Webhook processed']);
} else {
// โ Invalid signature - reject the webhook
error_log('CollectUG webhook signature verification failed: ' . json_encode($payload));
http_response_code(401);
echo json_encode(['status' => 'error', 'message' => 'Invalid signature']);
}
function updateTransactionStatus($transactionId, $status) {
// Your database update logic here
// Example: UPDATE transactions SET status = ? WHERE transaction_id = ?
try {
$pdo = new PDO('mysql:host=localhost;dbname=your_db', $username, $password);
$stmt = $pdo->prepare("UPDATE transactions SET status = ?, updated_at = NOW() WHERE transaction_id = ?");
$stmt->execute([$status, $transactionId]);
error_log("Transaction {$transactionId} updated to {$status}");
} catch (PDOException $e) {
error_log("Database error: " . $e->getMessage());
}
}
function creditUserAccount($phoneNumber, $amount) {
// Credit user account for successful deposits
try {
$pdo = new PDO('mysql:host=localhost;dbname=your_db', $username, $password);
$stmt = $pdo->prepare("UPDATE users SET balance = balance + ? WHERE phone_number = ?");
$stmt->execute([$amount, $phoneNumber]);
error_log("Credited {$amount} to account {$phoneNumber}");
} catch (PDOException $e) {
error_log("Balance update error: " . $e->getMessage());
}
}
function sendConfirmationNotification($phoneNumber, $amount) {
// Send SMS/email confirmation to customer
$message = "Payment of UGX " . number_format($amount) . " received successfully. Thank you!";
// Your SMS/email sending logic here
error_log("Confirmation sent to {$phoneNumber}: {$message}");
}
function sendFailureNotification($phoneNumber, $amount) {
// Send failure notification to customer
$message = "Payment of UGX " . number_format($amount) . " failed. Please try again or contact support.";
// Your SMS/email sending logic here
error_log("Failure notification sent to {$phoneNumber}: {$message}");
}
?>
const crypto = require('crypto');
const express = require('express');
function verifyCollectUGWebhook(payload, webhookSecret) {
// Extract signature from payload
const receivedSignature = payload.signature;
if (!receivedSignature) {
return false;
}
// Create signature data (EXACTLY these 3 fields in alphabetical order)
const signatureData = {
amount: payload.amount,
status: payload.status,
transaction_id: payload.transaction_id
};
// Convert to JSON string (compact format, no spaces)
const jsonString = JSON.stringify(signatureData);
// Generate expected signature using HMAC-SHA256
const expectedSignature = crypto
.createHmac('sha256', webhookSecret)
.update(jsonString)
.digest('hex');
// Compare signatures securely (timing-safe comparison)
return crypto.timingSafeEqual(
Buffer.from(receivedSignature),
Buffer.from(expectedSignature)
);
}
// Express.js webhook endpoint
const app = express();
app.use(express.json());
// Middleware for logging all webhook requests
app.use('/webhook/collectug', (req, res, next) => {
console.log('CollectUG webhook received:', {
timestamp: new Date().toISOString(),
ip: req.ip,
headers: req.headers,
body: req.body
});
next();
});
app.post('/webhook/collectug', async (req, res) => {
const webhookSecret = process.env.COLLECTUG_WEBHOOK_SECRET;
const payload = req.body;
// Validate required fields
if (!payload.transaction_id || !payload.amount || !payload.status) {
console.error('CollectUG webhook missing required fields:', payload);
return res.status(400).json({
status: 'error',
message: 'Missing required fields'
});
}
if (verifyCollectUGWebhook(payload, webhookSecret)) {
// โ
Webhook is authentic - process the transaction
const { transaction_id, status, amount, phone_number, transaction_type } = payload;
console.log(`CollectUG webhook verified: ${transaction_id} - ${status}`);
try {
// Your business logic here
switch (status) {
case 'completed':
// Handle successful transaction
await updateTransactionStatus(transaction_id, 'completed');
await sendConfirmationNotification(phone_number, amount);
// For deposits, credit user account
if (transaction_type === 'deposit') {
await creditUserAccount(phone_number, amount);
}
break;
case 'failed':
// Handle failed transaction
await updateTransactionStatus(transaction_id, 'failed');
await sendFailureNotification(phone_number, amount);
break;
case 'pending':
// Handle pending transaction
await updateTransactionStatus(transaction_id, 'pending');
break;
default:
console.warn(`Unknown transaction status: ${status}`);
}
res.status(200).json({
status: 'success',
message: 'Webhook processed successfully'
});
} catch (error) {
console.error('Error processing webhook:', error);
res.status(500).json({
status: 'error',
message: 'Internal processing error'
});
}
} else {
// โ Invalid signature - reject the webhook
console.error('CollectUG webhook signature verification failed:', payload);
res.status(401).json({
status: 'error',
message: 'Invalid signature'
});
}
});
async function updateTransactionStatus(transactionId, status) {
// Your database update logic here
// Example using a database client:
try {
// await db.query('UPDATE transactions SET status = ?, updated_at = NOW() WHERE transaction_id = ?', [status, transactionId]);
console.log(`Transaction ${transactionId} updated to ${status}`);
} catch (error) {
console.error('Database update error:', error);
throw error;
}
}
async function creditUserAccount(phoneNumber, amount) {
// Credit user account for successful deposits
try {
// await db.query('UPDATE users SET balance = balance + ? WHERE phone_number = ?', [amount, phoneNumber]);
console.log(`Credited ${amount} to account ${phoneNumber}`);
} catch (error) {
console.error('Balance update error:', error);
throw error;
}
}
async function sendConfirmationNotification(phoneNumber, amount) {
// Send SMS/email confirmation to customer
const message = `Payment of UGX ${amount.toLocaleString()} received successfully. Thank you!`;
// Your SMS/email sending logic here
console.log(`Confirmation sent to ${phoneNumber}: ${message}`);
}
async function sendFailureNotification(phoneNumber, amount) {
// Send failure notification to customer
const message = `Payment of UGX ${amount.toLocaleString()} failed. Please try again or contact support.`;
// Your SMS/email sending logic here
console.log(`Failure notification sent to ${phoneNumber}: ${message}`);
}
// Error handling middleware
app.use((error, req, res, next) => {
console.error('Unhandled error:', error);
res.status(500).json({ status: 'error', message: 'Internal server error' });
});
app.listen(3000, () => {
console.log('CollectUG webhook server running on port 3000');
});
import hmac
import hashlib
import json
import os
import logging
from flask import Flask, request, jsonify
# Configure logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
def verify_collectug_webhook(payload, webhook_secret):
"""Verify CollectUG webhook signature using HMAC-SHA256"""
# Extract signature from payload
received_signature = payload.get('signature', '')
if not received_signature:
return False
# Create signature data (EXACTLY these 3 fields in alphabetical order)
signature_data = {
'amount': payload['amount'],
'status': payload['status'],
'transaction_id': payload['transaction_id']
}
# Convert to JSON string (compact format, no spaces)
json_string = json.dumps(signature_data, separators=(',', ':'))
# Generate expected signature using HMAC-SHA256
expected_signature = hmac.new(
webhook_secret.encode('utf-8'),
json_string.encode('utf-8'),
hashlib.sha256
).hexdigest()
# Compare signatures securely (timing-safe comparison)
return hmac.compare_digest(received_signature, expected_signature)
# Flask webhook endpoint
app = Flask(__name__)
@app.before_request
def log_request_info():
"""Log all incoming webhook requests"""
if request.endpoint == 'collectug_webhook':
logger.info(f'CollectUG webhook received from {request.remote_addr}')
logger.info(f'Headers: {dict(request.headers)}')
logger.info(f'Body: {request.get_json()}')
@app.route('/webhook/collectug', methods=['POST'])
def collectug_webhook():
webhook_secret = os.getenv('COLLECTUG_WEBHOOK_SECRET')
if not webhook_secret:
logger.error('COLLECTUG_WEBHOOK_SECRET not configured')
return jsonify({'status': 'error', 'message': 'Webhook secret not configured'}), 500
payload = request.get_json()
# Validate required fields
required_fields = ['transaction_id', 'amount', 'status']
if not all(field in payload for field in required_fields):
logger.error(f'Missing required fields in webhook: {payload}')
return jsonify({'status': 'error', 'message': 'Missing required fields'}), 400
if verify_collectug_webhook(payload, webhook_secret):
# โ
Webhook is authentic - process the transaction
transaction_id = payload['transaction_id']
status = payload['status']
amount = payload['amount']
phone_number = payload.get('phone_number')
transaction_type = payload.get('transaction_type')
logger.info(f'CollectUG webhook verified: {transaction_id} - {status}')
try:
# Your business logic here
if status == 'completed':
# Handle successful transaction
update_transaction_status(transaction_id, 'completed')
send_confirmation_notification(phone_number, amount)
# For deposits, credit user account
if transaction_type == 'deposit':
credit_user_account(phone_number, amount)
elif status == 'failed':
# Handle failed transaction
update_transaction_status(transaction_id, 'failed')
send_failure_notification(phone_number, amount)
elif status == 'pending':
# Handle pending transaction
update_transaction_status(transaction_id, 'pending')
else:
logger.warning(f'Unknown transaction status: {status}')
return jsonify({
'status': 'success',
'message': 'Webhook processed successfully'
}), 200
except Exception as e:
logger.error(f'Error processing webhook: {str(e)}')
return jsonify({
'status': 'error',
'message': 'Internal processing error'
}), 500
else:
# โ Invalid signature - reject the webhook
logger.error(f'CollectUG webhook signature verification failed: {payload}')
return jsonify({
'status': 'error',
'message': 'Invalid signature'
}), 401
def update_transaction_status(transaction_id, status):
"""Update transaction status in your database"""
try:
# Your database update logic here
# Example: cursor.execute("UPDATE transactions SET status = %s, updated_at = NOW() WHERE transaction_id = %s", (status, transaction_id))
logger.info(f'Transaction {transaction_id} updated to {status}')
except Exception as e:
logger.error(f'Database update error: {str(e)}')
raise
def credit_user_account(phone_number, amount):
"""Credit user account for successful deposits"""
try:
# Your database update logic here
# Example: cursor.execute("UPDATE users SET balance = balance + %s WHERE phone_number = %s", (amount, phone_number))
logger.info(f'Credited {amount} to account {phone_number}')
except Exception as e:
logger.error(f'Balance update error: {str(e)}')
raise
def send_confirmation_notification(phone_number, amount):
"""Send confirmation SMS/email to customer"""
try:
message = f"Payment of UGX {amount:,} received successfully. Thank you!"
# Your SMS/email sending logic here
logger.info(f'Confirmation sent to {phone_number}: {message}')
except Exception as e:
logger.error(f'Notification error: {str(e)}')
def send_failure_notification(phone_number, amount):
"""Send failure notification to customer"""
try:
message = f"Payment of UGX {amount:,} failed. Please try again or contact support."
# Your SMS/email sending logic here
logger.info(f'Failure notification sent to {phone_number}: {message}')
except Exception as e:
logger.error(f'Notification error: {str(e)}')
@app.errorhandler(Exception)
def handle_exception(e):
"""Global error handler"""
logger.error(f'Unhandled exception: {str(e)}')
return jsonify({'status': 'error', 'message': 'Internal server error'}), 500
if __name__ == '__main__':
app.run(debug=True, port=5000)
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.security.MessageDigest;
import java.util.HashMap;
import java.util.Map;
import java.util.logging.Logger;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.web.bind.annotation.*;
import org.springframework.http.ResponseEntity;
@RestController
public class CollectUGWebhookController {
private static final Logger logger = Logger.getLogger(CollectUGWebhookController.class.getName());
private final String webhookSecret = System.getenv("COLLECTUG_WEBHOOK_SECRET");
private final ObjectMapper objectMapper = new ObjectMapper();
@PostMapping("/webhook/collectug")
public ResponseEntity<Map<String, String>> handleWebhook(@RequestBody Map<String, Object> payload) {
logger.info("CollectUG webhook received: " + payload);
// Validate required fields
if (!payload.containsKey("transaction_id") ||
!payload.containsKey("amount") ||
!payload.containsKey("status")) {
logger.warning("Missing required fields in webhook: " + payload);
Map<String, String> response = new HashMap<>();
response.put("status", "error");
response.put("message", "Missing required fields");
return ResponseEntity.badRequest().body(response);
}
if (verifyCollectUGWebhook(payload, webhookSecret)) {
// โ
Webhook is authentic - process the transaction
String transactionId = (String) payload.get("transaction_id");
String status = (String) payload.get("status");
String amount = (String) payload.get("amount");
String phoneNumber = (String) payload.get("phone_number");
String transactionType = (String) payload.get("transaction_type");
logger.info("CollectUG webhook verified: " + transactionId + " - " + status);
try {
// Your business logic here
switch (status) {
case "completed":
// Handle successful transaction
updateTransactionStatus(transactionId, "completed");
sendConfirmationNotification(phoneNumber, amount);
// For deposits, credit user account
if ("deposit".equals(transactionType)) {
creditUserAccount(phoneNumber, amount);
}
break;
case "failed":
// Handle failed transaction
updateTransactionStatus(transactionId, "failed");
sendFailureNotification(phoneNumber, amount);
break;
case "pending":
// Handle pending transaction
updateTransactionStatus(transactionId, "pending");
break;
default:
logger.warning("Unknown transaction status: " + status);
}
Map<String, String> response = new HashMap<>();
response.put("status", "success");
response.put("message", "Webhook processed successfully");
return ResponseEntity.ok(response);
} catch (Exception e) {
logger.severe("Error processing webhook: " + e.getMessage());
Map<String, String> response = new HashMap<>();
response.put("status", "error");
response.put("message", "Internal processing error");
return ResponseEntity.status(500).body(response);
}
} else {
// โ Invalid signature - reject the webhook
logger.severe("CollectUG webhook signature verification failed: " + payload);
Map<String, String> response = new HashMap<>();
response.put("status", "error");
response.put("message", "Invalid signature");
return ResponseEntity.status(401).body(response);
}
}
public boolean verifyCollectUGWebhook(Map<String, Object> payload, String webhookSecret) {
try {
// Extract signature from payload
String receivedSignature = (String) payload.get("signature");
if (receivedSignature == null || receivedSignature.isEmpty()) {
return false;
}
// Create signature data (EXACTLY these 3 fields in alphabetical order)
Map<String, Object> signatureData = new HashMap<>();
signatureData.put("amount", payload.get("amount"));
signatureData.put("status", payload.get("status"));
signatureData.put("transaction_id", payload.get("transaction_id"));
// Convert to JSON string (compact format, no spaces)
String jsonString = objectMapper.writeValueAsString(signatureData);
// Generate expected signature using HMAC-SHA256
String expectedSignature = generateHmacSha256(jsonString, webhookSecret);
// Compare signatures securely (timing-safe comparison)
return MessageDigest.isEqual(
receivedSignature.getBytes(),
expectedSignature.getBytes()
);
} catch (Exception e) {
logger.severe("Error verifying webhook signature: " + e.getMessage());
return false;
}
}
private String generateHmacSha256(String data, String key) throws Exception {
Mac mac = Mac.getInstance("HmacSHA256");
SecretKeySpec secretKeySpec = new SecretKeySpec(key.getBytes(), "HmacSHA256");
mac.init(secretKeySpec);
byte[] hash = mac.doFinal(data.getBytes());
StringBuilder hexString = new StringBuilder();
for (byte b : hash) {
String hex = Integer.toHexString(0xff & b);
if (hex.length() == 1) {
hexString.append('0');
}
hexString.append(hex);
}
return hexString.toString();
}
private void updateTransactionStatus(String transactionId, String status) {
// Your database update logic here
try {
// Example: jdbcTemplate.update("UPDATE transactions SET status = ?, updated_at = NOW() WHERE transaction_id = ?", status, transactionId);
logger.info("Transaction " + transactionId + " updated to " + status);
} catch (Exception e) {
logger.severe("Database update error: " + e.getMessage());
throw new RuntimeException(e);
}
}
private void creditUserAccount(String phoneNumber, String amount) {
// Credit user account for successful deposits
try {
// Example: jdbcTemplate.update("UPDATE users SET balance = balance + ? WHERE phone_number = ?", Double.parseDouble(amount), phoneNumber);
logger.info("Credited " + amount + " to account " + phoneNumber);
} catch (Exception e) {
logger.severe("Balance update error: " + e.getMessage());
throw new RuntimeException(e);
}
}
private void sendConfirmationNotification(String phoneNumber, String amount) {
// Send SMS/email confirmation to customer
try {
String message = "Payment of UGX " + String.format("%,.0f", Double.parseDouble(amount)) + " received successfully. Thank you!";
// Your SMS/email sending logic here
logger.info("Confirmation sent to " + phoneNumber + ": " + message);
} catch (Exception e) {
logger.warning("Notification error: " + e.getMessage());
}
}
private void sendFailureNotification(String phoneNumber, String amount) {
// Send failure notification to customer
try {
String message = "Payment of UGX " + String.format("%,.0f", Double.parseDouble(amount)) + " failed. Please try again or contact support.";
// Your SMS/email sending logic here
logger.info("Failure notification sent to " + phoneNumber + ": " + message);
} catch (Exception e) {
logger.warning("Notification error: " + e.getMessage());
}
}
}
Start with a simple endpoint that logs all received data for debugging:
<?php
// test-webhook.php - Simple webhook logger for debugging
$payload = json_decode(file_get_contents('php://input'), true);
$headers = getallheaders();
// Create detailed log entry
$logData = [
'timestamp' => date('Y-m-d H:i:s'),
'ip_address' => $_SERVER['REMOTE_ADDR'] ?? 'unknown',
'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? 'unknown',
'headers' => $headers,
'payload' => $payload,
'raw_body' => file_get_contents('php://input'),
'method' => $_SERVER['REQUEST_METHOD'] ?? 'unknown'
];
// Log to file with rotation
$logFile = 'webhook-debug-' . date('Y-m-d') . '.log';
file_put_contents($logFile,
json_encode($logData, JSON_PRETTY_PRINT) . "\n" . str_repeat('-', 80) . "\n\n",
FILE_APPEND | LOCK_EX
);
// Also log to system log
error_log("CollectUG webhook received: " . json_encode($payload));
// Always respond with success for testing
http_response_code(200);
echo json_encode([
'status' => 'success',
'message' => 'Webhook received and logged',
'timestamp' => date('c')
]);
?>
Test your signature generation with known values to ensure your implementation is correct:
<?php
// manual-signature-test.php - Test signature generation
function testSignatureGeneration() {
// Test data (use exact format CollectUG uses)
$testData = [
'amount' => '10000', // Always string, not number
'status' => 'completed', // Exact status values
'transaction_id' => 'TXN_123456789'
];
$webhookSecret = 'your_webhook_secret_here';
// Generate signature exactly as CollectUG does
$jsonString = json_encode($testData, JSON_UNESCAPED_SLASHES);
$signature = hash_hmac('sha256', $jsonString, $webhookSecret);
echo "=== CollectUG Signature Test ===\n";
echo "Test Data: " . json_encode($testData) . "\n";
echo "JSON String: " . $jsonString . "\n";
echo "Webhook Secret: " . $webhookSecret . "\n";
echo "Generated Signature: " . $signature . "\n\n";
// Test verification
$testPayload = [
'transaction_id' => 'TXN_123456789',
'external_transaction_id' => 'EXT_123',
'amount' => '10000',
'status' => 'completed',
'currency' => 'UGX',
'phone_number' => '256701234567',
'transaction_type' => 'deposit',
'signature' => $signature
];
$isValid = verifyCollectUGWebhook($testPayload, $webhookSecret);
echo "Signature Verification: " . ($isValid ? 'โ
VALID' : 'โ INVALID') . "\n";
return $isValid;
}
// Include your verification function here
function verifyCollectUGWebhook($payload, $webhookSecret) {
$receivedSignature = $payload['signature'] ?? '';
if (empty($receivedSignature)) return false;
$signatureData = [
'amount' => $payload['amount'],
'status' => $payload['status'],
'transaction_id' => $payload['transaction_id']
];
$jsonString = json_encode($signatureData, JSON_UNESCAPED_SLASHES);
$expectedSignature = hash_hmac('sha256', $jsonString, $webhookSecret);
return hash_equals($expectedSignature, $receivedSignature);
}
// Run the test
testSignatureGeneration();
?>
{"amount":"10000","status":"completed","transaction_id":"TXN_123456789"}Test your webhook with different transaction statuses and types:
<?php
// comprehensive-webhook-test.php
$testCases = [
[
'transaction_id' => 'TXN_DEPOSIT_001',
'amount' => '5000',
'status' => 'completed',
'transaction_type' => 'deposit',
'phone_number' => '256701234567'
],
[
'transaction_id' => 'TXN_WITHDRAW_001',
'amount' => '2000',
'status' => 'failed',
'transaction_type' => 'withdraw',
'phone_number' => '256701234567'
],
[
'transaction_id' => 'TXN_DEPOSIT_002',
'amount' => '15000',
'status' => 'pending',
'transaction_type' => 'deposit',
'phone_number' => '256709876543'
]
];
$webhookSecret = 'your_webhook_secret_here';
foreach ($testCases as $index => $testCase) {
echo "=== Test Case " . ($index + 1) . " ===\n";
// Generate signature
$signatureData = [
'amount' => $testCase['amount'],
'status' => $testCase['status'],
'transaction_id' => $testCase['transaction_id']
];
$signature = hash_hmac('sha256', json_encode($signatureData), $webhookSecret);
$testCase['signature'] = $signature;
// Test verification
$isValid = verifyCollectUGWebhook($testCase, $webhookSecret);
echo "Transaction: {$testCase['transaction_id']}\n";
echo "Status: {$testCase['status']}\n";
echo "Amount: {$testCase['amount']}\n";
echo "Verification: " . ($isValid ? 'โ
PASSED' : 'โ FAILED') . "\n\n";
}
?>
Problem: Your generated signature doesn't match CollectUG's signature.
Root Causes & Solutions:
amount, status, transaction_id{"amount":"10000","status":"completed","transaction_id":"TXN_123"}"10000" not 10000transaction_id, amount, status - ignore all other fieldsProblem: Even with correct implementation, signatures don't match.
Solutions:
COLLECTUG_WEBHOOK_SECRET env varProblem: Some webhooks verify successfully, others don't.
Debugging Steps:
amount is always string, never numbercompleted, failed, pendingBest Practices for Troubleshooting:
โ Webhook secret is correct and properly stored โ Using exactly 3 fields: amount, status, transaction_id โ Fields are in alphabetical order โ JSON is compact format (no spaces) โ Amount is string, not number โ Using HMAC-SHA256 algorithm โ Using timing-safe comparison (hash_equals, etc.) โ Returning proper HTTP status codes (200/401) โ Logging webhook requests for debugging โ Testing with known good data
Never process webhooks without signature verification. This is your primary defense against malicious requests.
Use hash_equals() in PHP, crypto.timingSafeEqual() in Node.js, or hmac.compare_digest() in Python to prevent timing attacks.
Store webhook secrets in environment variables or secure configuration files, never in your source code.
Handle duplicate webhooks gracefully using transaction IDs. CollectUG may send the same webhook multiple times.
Log both successful and failed verification attempts for debugging and security monitoring.
Return 200 OK for valid webhooks and 401 Unauthorized for invalid signatures.
Here's what a typical CollectUG webhook payload looks like when sent to your endpoint:
{
"transaction_id": "TXN_019bda60-44d2-7262-841d-1b99bf30105d",
"external_transaction_id": "EXT_1234567890",
"amount": "10000",
"status": "completed",
"currency": "UGX",
"phone_number": "256701234567",
"transaction_type": "deposit",
"signature": "a1b2c3d4e5f6789abcdef1234567890abcdef1234567890abcdef1234567890"
}
transaction_id, amount, and status are used for signature generation. All other fields are for your business logic but don't affect the signature.
completed, failed, or pendingdeposit or withdrawFor the payload above, the signature is generated from:
// Only these 3 fields in alphabetical order:
{
"amount": "10000",
"status": "completed",
"transaction_id": "TXN_019bda60-44d2-7262-841d-1b99bf30105d"
}
// Compact JSON (no spaces):
{"amount":"10000","status":"completed","transaction_id":"TXN_019bda60-44d2-7262-841d-1b99bf30105d"}
// HMAC-SHA256 with your webhook secret:
signature = HMAC-SHA256(json_string, webhook_secret)