Webhook Guide

Complete documentation for integrating PayNexus M-Pesa payments

Base URL
https://paynexus.mcbankske.space/api
Authentication
X-API-Key: your_key_here
API Versions
v1 (Stable) | v2 (Enhanced)
Rate Limits
v1: 60/min | v2: 100/min

PayNexus Webhook Integration Guide



Webhooks are essential for receiving real-time payment notifications from M-Pesa. This guide covers everything you need to know about setting up and managing webhooks.

Overview



When a customer completes an M-Pesa payment, PayNexus sends a webhook notification to your endpoint with the transaction details. This allows you to:

- Update order status in real-time
- Send confirmation emails
- Update inventory
- Trigger business logic
- Handle failed payments

Webhook Flow




Customer Pays → M-Pesa → PayNexus → Your Webhook → Your System


Setting Up Webhooks



1. Create Webhook Endpoint



Create an HTTPS endpoint that can receive POST requests:

php
// webhook.php
header('Content-Type: application/json');

// Get the webhook payload
$payload = file_get_contents('php://input');
$data = json_decode($payload, true);

// Log the webhook for debugging
file_put_contents('webhook_log.txt', date('Y-m-d H:i:s') . " - " . $payload . "\n", FILE_APPEND);

// Process the webhook
if ($data['ResultCode'] == 0) {
// Payment successful
handleSuccessfulPayment($data);
} else {
// Payment failed
handleFailedPayment($data);
}

// Always return success to avoid retries
echo json_encode(['ResultCode' => 0, 'ResultDesc' => 'Callback received']);

function handleSuccessfulPayment($data) {
$transactionId = $data['TransactionID'];
$checkoutRequestId = $data['CheckoutRequestID'];
$amount = $data['Amount'];
$phoneNumber = $data['PhoneNumber'];

// Update your database
// Mark order as paid
// Send confirmation email
// Update customer account

echo "Payment successful: $transactionId for $amount KES\n";
}

function handleFailedPayment($data) {
$checkoutRequestId = $data['CheckoutRequestID'];
$errorCode = $data['ResultCode'];
$errorMessage = $data['ResultDesc'];

// Update your database
// Mark order as failed
// Notify customer

echo "Payment failed: $checkoutRequestId - $errorMessage\n";
}
?>


2. Webhook Security



#### Verify Webhook Signature

php
function verifyWebhookSignature($payload, $signature, $secret) {
$expectedSignature = hash_hmac('sha256', $payload, $secret);
return hash_equals($expectedSignature, $signature);
}

// In your webhook handler
$signature = $_SERVER['HTTP_X_PAYNEXUS_SIGNATURE'] ?? '';
$secret = 'your_webhook_secret_here';

if (!verifyWebhookSignature($payload, $signature, $secret)) {
http_response_code(401);
echo json_encode(['error' => 'Unauthorized']);
exit;
}
?>


#### IP Whitelisting (Optional)

php
$allowed_ips = ['52.52.52.52', '52.53.53.53']; // PayNexus webhook IPs
$client_ip = $_SERVER['REMOTE_ADDR'];

if (!in_array($client_ip, $allowed_ips)) {
http_response_code(403);
echo json_encode(['error' => 'Forbidden']);
exit;
}
?>


Webhook Payload Structure



Successful Payment



json
{
"ResultCode": 0,
"ResultDesc": "Success. Request accepted for processing",
"CheckoutRequestID": "ws_CO_27012026101718139798808796",
"MerchantRequestID": "e569-40f5-bc0a-03c08bc2061a3446498",
"Amount": "100",
"MpesaReceiptNumber": "OEI2AK4Q16",
"TransactionDate": "20260127101718",
"PhoneNumber": "254798808796"
}


Failed Payment



json
{
"ResultCode": 1,
"ResultDesc": "Insufficient funds",
"CheckoutRequestID": "ws_CO_27012026101718139798808796",
"MerchantRequestID": "e569-40f5-bc0a-03c08bc2061a3446498",
"Amount": "100",
"PhoneNumber": "254798808796"
}


Result Codes Reference



| Code | Description | Action |
|------|-------------|--------|
| 0 | Success | Mark order as paid |
| 1 | Insufficient funds | Notify customer, retry option |
| 1032 | Account closed | Block customer account |
| 1037 | Account blocked | Contact customer support |
| 2001 | Invalid amount | Show validation error |
| 2002 | Invalid phone number | Prompt for correct number |
| 2003 | Invalid transaction | Log error, contact support |
| 2004 | Invalid security credential | Check API configuration |
| 2005 | Invalid receiver party | Verify payment account |
| 2006 | Invalid initiator | Check merchant setup |
| 2007 | Invalid short code | Verify Paybill/Till number |

Webhook Examples by Language



Node.js/Express



javascript
const express = require('express');
const crypto = require('crypto');
const bodyParser = require('body-parser');

const app = express();
app.use(bodyParser.json({ verify: verifyWebhook }));

function verifyWebhook(req, res, buf) {
const signature = req.get('X-PayNexus-Signature');
const secret = process.env.WEBHOOK_SECRET;

if (signature) {
const expectedSignature = crypto
.createHmac('sha256', secret)
.update(buf)
.digest('hex');

if (signature !== expectedSignature) {
throw new Error('Invalid signature');
}
}
}

app.post('/webhook/mpesa', (req, res) => {
const payload = req.body;

if (payload.ResultCode === 0) {
// Success
handleSuccess(payload);
} else {
// Failure
handleFailure(payload);
}

res.json({ ResultCode: 0, ResultDesc: 'Callback received' });
});

function handleSuccess(data) {
console.log(
Payment successful: ${data.TransactionID});
// Update database, send emails, etc.
}

function handleFailure(data) {
console.log(
Payment failed: ${data.CheckoutRequestID} - ${data.ResultDesc});
// Update database, notify customer, etc.
}

app.listen(3000, () => console.log('Webhook server running'));


Python/Flask



python
from flask import Flask, request, jsonify
import hmac
import hashlib

app = Flask(__name__)

@app.route('/webhook/mpesa', methods=['POST'])
def mpesa_webhook():
# Verify signature
signature = request.headers.get('X-PayNexus-Signature')
secret = 'your_webhook_secret'

if signature:
expected_signature = hmac.new(
secret.encode(),
request.data,
hashlib.sha256
).hexdigest()

if not hmac.compare_digest(signature, expected_signature):
return jsonify({'error': 'Unauthorized'}), 401

payload = request.get_json()

if payload.get('ResultCode') == 0:
handle_success(payload)
else:
handle_failure(payload)

return jsonify({'ResultCode': 0, 'ResultDesc': 'Callback received'})

def handle_success(data):
print(f"Payment successful: {data.get('TransactionID')}")
# Update database, send emails, etc.

def handle_failure(data):
print(f"Payment failed: {data.get('CheckoutRequestID')} - {data.get('ResultDesc')}")
# Update database, notify customer, etc.

if __name__ == '__main__':
app.run(host='0.0.0.0', port=5000)


Django



python

views.py


from django.http import JsonResponse
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods
import hmac
import hashlib
import json

@csrf_exempt
@require_http_methods(["POST"])
def mpesa_webhook(request):
# Verify signature
signature = request.META.get('HTTP_X_PAYNEXUS_SIGNATURE')
secret = 'your_webhook_secret'

if signature:
expected_signature = hmac.new(
secret.encode(),
request.body,
hashlib.sha256
).hexdigest()

if not hmac.compare_digest(signature, expected_signature):
return JsonResponse({'error': 'Unauthorized'}, status=401)

try:
payload = json.loads(request.body)
except json.JSONDecodeError:
return JsonResponse({'error': 'Invalid JSON'}, status=400)

if payload.get('ResultCode') == 0:
handle_success(payload)
else:
handle_failure(payload)

return JsonResponse({'ResultCode': 0, 'ResultDesc': 'Callback received'})

def handle_success(data):
# Update database, send emails, etc.
pass

def handle_failure(data):
# Update database, notify customer, etc.
pass


Database Integration



Example Database Schema



sql
CREATE TABLE payments (
id INT AUTO_INCREMENT PRIMARY KEY,
checkout_request_id VARCHAR(100) UNIQUE,
merchant_request_id VARCHAR(100),
transaction_id VARCHAR(100),
amount DECIMAL(10,2),
phone_number VARCHAR(20),
status ENUM('pending', 'completed', 'failed') DEFAULT 'pending',
result_code INT,
result_desc TEXT,
mpesa_receipt_number VARCHAR(50),
transaction_date DATETIME,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);

CREATE TABLE webhook_logs (
id INT AUTO_INCREMENT PRIMARY KEY,
payload JSON,
processed BOOLEAN DEFAULT FALSE,
error_message TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);


PHP Database Integration



php
// Database connection
$pdo = new PDO('mysql:host=localhost;dbname=your_db', 'username', 'password');

function updatePaymentStatus($data) {
global $pdo;

$checkoutRequestId = $data['CheckoutRequestID'];
$status = $data['ResultCode'] == 0 ? 'completed' : 'failed';

$stmt = $pdo->prepare("
UPDATE payments
SET status = ?,
transaction_id = ?,
result_code = ?,
result_desc = ?,
mpesa_receipt_number = ?,
transaction_date = ?,
updated_at = NOW()
WHERE checkout_request_id = ?
");

$stmt->execute([
$status,
$data['TransactionID'] ?? null,
$data['ResultCode'],
$data['ResultDesc'],
$data['MpesaReceiptNumber'] ?? null,
$data['TransactionDate'] ?? null,
$checkoutRequestId
]);

// Log webhook
logWebhook($data);
}

function logWebhook($data) {
global $pdo;

$stmt = $pdo->prepare("
INSERT INTO webhook_logs (payload, processed)
VALUES (?, ?)
");

$stmt->execute([json_encode($data), true]);
}
?>


Best Practices



1. Always Return Success



Always return {"ResultCode": 0, "ResultDesc": "Callback received"} to prevent M-Pesa from retrying the webhook.

2. Idempotency



Handle duplicate webhooks gracefully:

php
function handleWebhook($data) {
$checkoutRequestId = $data['CheckoutRequestID'];

// Check if already processed
if (isAlreadyProcessed($checkoutRequestId)) {
return; // Skip duplicate
}

// Process the webhook
processPayment($data);
}

function isAlreadyProcessed($checkoutRequestId) {
global $pdo;

$stmt = $pdo->prepare("
SELECT COUNT(*) FROM payments
WHERE checkout_request_id = ? AND status != 'pending'
");

$stmt->execute([$checkoutRequestId]);
return $stmt->fetchColumn() > 0;
}
?>


3. Error Handling



php
function handleWebhook($data) {
try {
// Validate payload
if (!isset($data['CheckoutRequestID'])) {
throw new Exception('Missing CheckoutRequestID');
}

// Process payment
updatePaymentStatus($data);

// Send confirmation
if ($data['ResultCode'] == 0) {
sendConfirmationEmail($data);
}

} catch (Exception $e) {
// Log error but still return success
error_log('Webhook error: ' . $e->getMessage());
logWebhookError($data, $e->getMessage());
}
}
?>


4. Logging



php
function logWebhook($data, $success = true, $error = null) {
$logEntry = [
'timestamp' => date('Y-m-d H:i:s'),
'checkout_request_id' => $data['CheckoutRequestID'] ?? null,
'result_code' => $data['ResultCode'] ?? null,
'success' => $success,
'error' => $error,
'payload' => $data
];

file_put_contents('webhook_log.json', json_encode($logEntry) . "\n", FILE_APPEND);
}
?>


Testing Webhooks



Local Testing with Ngrok



bash

Install ngrok


npm install -g ngrok

Start your local server


php -S localhost:8000

Expose it to the internet


ngrok http 8000

Use the ngrok URL for webhook testing


https://abc123.ngrok.io/webhook/mpesa




Webhook Testing Tools



1. Webhook.site - Public webhook testing URL
2. RequestBin - Inspect HTTP requests
3. Ngrok - Expose local server to internet

Simulating Webhooks



bash

Test webhook manually


curl -X POST https://your-domain.com/webhook/mpesa \
-H "Content-Type: application/json" \
-H "X-PayNexus-Signature: your_signature" \
-d '{
"ResultCode": 0,
"ResultDesc": "Success. Request accepted for processing",
"CheckoutRequestID": "ws_CO_TEST123",
"MerchantRequestID": "TEST123",
"Amount": "100",
"MpesaReceiptNumber": "TEST123",
"TransactionDate": "20260127101718",
"PhoneNumber": "254798808796"
}'


Troubleshooting



Common Issues



1. Webhook not received
- Check URL is accessible
- Verify HTTPS is working
- Check firewall settings

2. Invalid signature
- Verify webhook secret
- Check signature calculation
- Ensure raw payload is used

3. Duplicate webhooks
- Implement idempotency
- Check for existing transactions

4. Timeout errors
- Optimize webhook processing
- Use background jobs
- Set appropriate timeout

Debugging Tips



php
// Add debugging to your webhook
file_put_contents('debug.log', date('Y-m-d H:i:s') . " - Webhook received\n", FILE_APPEND);
file_put_contents('debug.log', date('Y-m-d H:i:s') . " - Headers: " . json_encode(getallheaders()) . "\n", FILE_APPEND);
file_put_contents('debug.log', date('Y-m-d H:i:s') . " - Payload: " . file_get_contents('php://input') . "\n", FILE_APPEND);
?>


Production Deployment



Checklist



- [ ] HTTPS endpoint
- [ ] Webhook signature verification
- [ ] Idempotency handling
- [ ] Error logging
- [ ] Monitoring and alerts
- [ ] Backup webhook endpoint
- [ ] Load balancing
- [ ] Rate limiting

Monitoring



php
// Monitor webhook health
function monitorWebhookHealth() {
$recent = getRecentWebhooks(5); // Last 5 minutes
$failures = count(array_filter($recent, fn($w) => !$w['success']));

if ($failures > 3) {
// Send alert
sendAlert('High webhook failure rate');
}
}

function getRecentWebhooks($minutes) {
global $pdo;

$stmt = $pdo->prepare("
SELECT * FROM webhook_logs
WHERE created_at > DATE_SUB(NOW(), INTERVAL ? MINUTE)
");

$stmt->execute([$minutes]);
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
?>


Support



For webhook-related issues:

1. Check your webhook logs
2. Verify endpoint accessibility
3. Test with webhook testing tools
4. Contact support with:
- Webhook payload
- Error logs
- Timestamp of issue
- CheckoutRequestID

That's it! Your webhook integration is now robust and production-ready. 🚀