๐Ÿ” CollectUG Webhook Integration

Secure webhook signature verification guide for developers

๐Ÿ“‹ Overview

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.

๐Ÿ”’ Security First: Always verify webhook signatures before processing transaction data. This prevents malicious actors from sending fake notifications to your system.

What You'll Learn

โœ… Proven System: This signature verification system is battle-tested and currently processes thousands of transactions daily with 99.9% reliability.

โš™๏ธ How Signature Verification Works

๐Ÿ” HMAC-SHA256 Security: CollectUG uses industry-standard HMAC-SHA256 cryptographic signatures to ensure webhook authenticity. This is the same security standard used by major payment processors worldwide.
1

Get Your Webhook Secret

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.

โš ๏ธ Keep It Secret: Never share your webhook secret or commit it to version control. Store it securely in environment variables.
2

CollectUG Generates Signature

When sending a webhook, CollectUG creates a signature using exactly these 3 fields in this specific format:

Signature Data (JSON, compact format):
{"transaction_id":"TXN_123456789","amount":"10000","status":"completed"}
HMAC-SHA256 Signature Generation:
signature = HMAC-SHA256(json_string, webhook_secret)
๐ŸŽฏ Critical: Only these 3 fields are used: transaction_id, amount, status. The JSON must be compact (no spaces) and fields must be in alphabetical order.
3

Complete Webhook Payload Sent

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..."
}
๐Ÿ’ก Additional Fields: While the webhook contains many fields, only the 3 core fields are used for signature generation. All other fields are for your business logic.
4

Your Application Verifies

Your webhook endpoint receives the payload and verifies the signature using the exact same process CollectUG used to generate it.

โœ… Security Guarantee: If signatures match, you can be 100% confident the webhook is authentic and from CollectUG.

๐Ÿ’ป Implementation Examples

PHP Implementation

๐Ÿ’ก Most Popular: PHP is the most commonly used language for CollectUG integrations. This implementation is production-tested.
<?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}");
}

?>

Node.js Implementation

๐Ÿš€ Modern: Perfect for Express.js applications and modern JavaScript backends. Includes comprehensive error handling.
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');
});

Python Implementation

๐Ÿ Versatile: Works great with Flask, Django, or FastAPI frameworks. Includes comprehensive logging and error handling.
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)

Java Implementation

โ˜• Enterprise: Perfect for Spring Boot applications and enterprise systems. Includes comprehensive error handling and logging.
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());
        }
    }
}

๐Ÿงช Testing Your Implementation

โœ… Test First: Always test your webhook implementation before going live. CollectUG provides testing tools to help you verify your integration.

1. Create a Test Webhook Endpoint

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')
]);
?>

2. Manual Signature Testing

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();
?>

3. Use Online HMAC Tools for Verification

๐Ÿ’ก Quick Verification: You can use online HMAC-SHA256 generators to verify your signature logic:
  • Search for "HMAC SHA256 generator online"
  • Input: {"amount":"10000","status":"completed","transaction_id":"TXN_123456789"}
  • Secret: Your webhook secret
  • Algorithm: SHA256
  • Compare the result with your code's output

4. Test with Different Transaction Types

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";
}
?>

๐Ÿ”ง Common Issues & Solutions

โŒ Issue: Signature Mismatch

Problem: Your generated signature doesn't match CollectUG's signature.

Root Causes & Solutions:

  • โœ… Field Order: Use alphabetical order: amount, status, transaction_id
  • โœ… JSON Format: Use compact JSON (no spaces): {"amount":"10000","status":"completed","transaction_id":"TXN_123"}
  • โœ… Data Types: Amount must be a string, not a number: "10000" not 10000
  • โœ… Webhook Secret: Verify you're using the correct secret from your dashboard
  • โœ… Encoding: Ensure UTF-8 encoding for all strings
  • โœ… Only 3 Fields: Use ONLY transaction_id, amount, status - ignore all other fields

โš ๏ธ Issue: Webhook Secret Problems

Problem: Even with correct implementation, signatures don't match.

Solutions:

  • โœ… Copy Carefully: Copy webhook secret without extra spaces or line breaks
  • โœ… Don't Confuse: Webhook secret โ‰  API key (they're different)
  • โœ… Environment Variables: Store in COLLECTUG_WEBHOOK_SECRET env var
  • โœ… Special Characters: Some secrets contain special characters - copy exactly
  • โœ… Contact Support: If still failing, contact CollectUG support to regenerate

โ„น๏ธ Issue: Intermittent Failures

Problem: Some webhooks verify successfully, others don't.

Debugging Steps:

  • โœ… Log Everything: Log both received payload and your generated signature
  • โœ… Check Data Types: Ensure amount is always string, never number
  • โœ… Status Values: Check for exact status values: completed, failed, pending
  • โœ… Transaction IDs: Verify transaction ID format is consistent
  • โœ… Character Encoding: Check for hidden characters or encoding issues

โœ… Issue: Testing & Debugging

Best Practices for Troubleshooting:

  • ๐Ÿ” Enable Detailed Logging: Log all webhook requests with timestamps
  • ๐Ÿงช Use Test Data: Test with known good data first
  • ๐Ÿ”„ Compare Step by Step: Compare your signature generation with examples
  • ๐Ÿ“ž Contact Support: Provide logs and code samples when asking for help
  • ๐Ÿ› ๏ธ Use Debug Tools: Use online HMAC generators to verify your logic

Debug Checklist

โœ… 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

๐Ÿ”’ Security Best Practices

1

Always Verify Signatures

Never process webhooks without signature verification. This is your primary defense against malicious requests.

2

Use Timing-Safe Comparison

Use hash_equals() in PHP, crypto.timingSafeEqual() in Node.js, or hmac.compare_digest() in Python to prevent timing attacks.

3

Secure Secret Storage

Store webhook secrets in environment variables or secure configuration files, never in your source code.

4

Implement Idempotency

Handle duplicate webhooks gracefully using transaction IDs. CollectUG may send the same webhook multiple times.

5

Log Everything

Log both successful and failed verification attempts for debugging and security monitoring.

6

Proper HTTP Responses

Return 200 OK for valid webhooks and 401 Unauthorized for invalid signatures.

๐Ÿ“„ Example Webhook Payload

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"
}
โš ๏ธ Critical: Only transaction_id, amount, and status are used for signature generation. All other fields are for your business logic but don't affect the signature.

Field Descriptions

  • transaction_id: CollectUG's unique transaction identifier
  • external_transaction_id: Your system's transaction reference (if provided)
  • amount: Transaction amount in UGX (always as string)
  • status: Transaction status: completed, failed, or pending
  • currency: Always "UGX" for Ugandan Shillings
  • phone_number: Customer's mobile money number
  • transaction_type: Either deposit or withdraw
  • signature: HMAC-SHA256 signature for verification

Signature Generation Example

For 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)