Skip to Content
Pesa VoucherDeveloper Documentation

Webhooks

Receive real-time, signed notifications for deposits, withdrawals, and float status.


Webhooks & Callbacks

The Direct Payments API pushes real-time transaction updates to your server via HTTPS POST webhooks.
You will receive a callback the moment an STK Push or B2C transaction reaches its final status.

Requirements for Your Webhook Endpoint

RequirementDetails
HTTPS onlyMust have a valid SSL certificate (Let’s Encrypt is fine)
Publicly accessibleMust be reachable from the internet
Accepts POSTJSON payload in request body
Responds quicklyReturn 200 OK within 10 seconds (process asynchronously)
IdempotentSafely handle duplicate callbacks (same payment_id)

Security Recommendations

  1. Whitelist our callback IPs (mandatory for production)
  2. Verify the incoming IP is in the allowed list
  3. (Optional) Verify signature header when we roll it out
  4. Never trust the payload without verifying the source IP

Callback IP Addresses (Whitelist These in Your Firewall)

Production

216.219.95.54
196.201.214.206
196.201.214.207

Sandbox

216.219.95.54

Common Fields in All Callbacks

FieldDescription
payment_idUnique ID you received when initiating the request
statusFinal status (Success, Failed, Cancelled, Timeout)
timestampISO timestamp of the callback

STK Push Webhook (Customer-to-Business)

{
"payment_id": "550e8400-e29b-41d4-a716-446655440000",
"merchant_request_id": "MERCHANT-20251120-001",
"checkout_request_id": "ws_CO_20112025143022",
"status": "Success",
"result_code": "0",
"result_description": "The service request is processed successfully.",
"initial_amount": 1250.00,
"actual_amount": 1250.00,
"mpesa_receipt_number": "SKL9P2M4XQ",
"transaction_date": "20251120143245",
"phone_number": "254708374149",
"payer_phone": "254708374149",
"payer_name": "JOHN DOE",
"account_reference": "INV-2025-0891",
"metadata": {
"customer_id": "CUST-8871",
"plan": "premium-annual"
},
"timestamp": "2025-11-20 14:32:50"
}

Important STK Push Statuses

StatusAction Required
SuccessCredit user, fulfill order, send confirmation
FailedShow error message, allow retry
CancelledUser cancelled – prompt to retry
TimeoutNo response – prompt to retry

B2C Webhook (Business-to-Customer)

{
"payment_id": "550e8400-e29b-41d4-a716-446655440001",
"conversation_id": "AG_20251120_000123456789",
"originator_conversation_id": "OC_20251120_987654321",
"transaction_type": "b2c",
"status": "Success",
"result_code": "0",
"result_description": "The service request is processed successfully.",
"transaction_id": "RKJ3M9P2XQ",
"amount": 2500.00,
"recipient_phone": "254708374149",
"recipient_name": "Jane Doe",
"command_id": "BusinessPayment",
"charges": {
"charges_paid": 125.00,
"utility_balance": 987500.00,
"working_balance": 862375.00
},
"timestamp": "2025-11-20 14:30:50"
}

Idempotency – Handle Duplicates Gracefully

Always store payment_id and check before processing:

// Pseudo-code
if (webhookAlreadyProcessed(payment_id)) {
return res.status(200).json({ message: "Already processed" });
}
processTransaction(data);
markAsProcessed(payment_id);
res.status(200).json({ message: "Received" });

Example Webhook Handlers

Node.js / Express

const express = require('express');
const app = express();
app.use(express.json());
const ALLOWED_IPS = [
'196.201.214.200', '196.201.214.206', /* ... all IPs */
];
app.post('/webhook/payments', (req, res) => {
const ip = req.ip || req.connection.remoteAddress;
if (!ALLOWED_IPS.includes(ip)) {
return res.status(403).send('Forbidden');
}
const payload = req.body;
// Log for debugging
console.log('Webhook received:', payload);
// Fire-and-forget processing
processWebhookAsync(payload);
// Immediate acknowledgment
res.status(200).json({ status: 'received' });
});
app.listen(3000);

PHP (Laravel or plain)

<?php
// routes/webhook.php
$allowedIps = ['196.201.214.200', '196.201.214.206', /* ... */];
$ip = $_SERVER['REMOTE_ADDR'];
if (!in_array($ip, $allowedIps)) {
http_response_code(403);
exit('Forbidden');
}
$payload = file_get_contents('php://input');
$data = json_decode($payload, true);
// Log
file_put_contents(storage_path('logs/webhooks.log'), now() . ' ' . $payload . PHP_EOL, FILE_APPEND);
// Prevent duplicates
if (Webhook::where('payment_id', $data['payment_id'])->exists()) {
http_response_code(200);
echo json_encode(['status' => 'already_processed']);
exit;
}
// Process asynchronously (queue, job, etc.)
ProcessPaymentCallback::dispatch($data);
http_response_code(200);
echo json_encode(['status' => 'queued']);

You’re now fully equipped to receive and securely process real-time payment notifications!

Previous: STK PushB2C Deposits • Next: Testing & Go-Live Checklist