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. 🚀