Delivery and retries#
When a payment succeeds we POST JSON to your notify_url (HTTPS only, redirects are not followed, answer within 20 seconds). Return any 2xx status to acknowledge it. Otherwise we retry with back-off, up to 8 attempts in total: after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours, and then twice more 24 hours apart.
Payload#
{
"event": "payment.succeeded",
"merchant_id": "MRC1A2B3C4D",
"checkout_id": "9f2c1e7a0b…",
"invoice_id": "ORD-1001",
"amount": "1500.50",
"amount_paid": "1501.00",
"currency": "KES",
"receipt": "SGH4XXXXXX",
"phone": "+25471***678",
"paid_at": "2026-10-23T10:15:30+03:00"
}| Field | Meaning |
|---|---|
amount | The amount you sent for the order. |
amount_paid | What M-Pesa actually collected, in whole shillings. It is the same as amount or up to KES 1 more when cents were rounded up. Accept the order when amount_paid ≥ amount. |
receipt | M-Pesa receipt number. Unique per payment: use it to ignore repeats. |
checkout_id | Our checkout reference. |
phone | The payer's number, masked. |
sandbox | Present and true only for sandbox webhooks. |
Verifying the signature#
Headers: X-Maainvest-Timestamp and X-Maainvest-Signature = hex(HMAC-SHA256(timestamp + "." + rawBody, API_SECRET)). Verify against the raw request body before you parse the JSON.
PHP#
<?php
$raw = file_get_contents('php://input');
$ts = $_SERVER['HTTP_X_MAAINVEST_TIMESTAMP'] ?? '';
$sig = strtolower($_SERVER['HTTP_X_MAAINVEST_SIGNATURE'] ?? '');
$ok = ctype_digit($ts) && abs(time() - (int)$ts) <= 600
&& hash_equals(hash_hmac('sha256', $ts . '.' . $raw, $API_SECRET), $sig);
if (!$ok) { http_response_code(403); exit; }
$d = json_decode($raw, true);
if (($d['event'] ?? '') === 'payment.succeeded' && $d['merchant_id'] === $MERCHANT_ID) {
// 1) Already processed this receipt? Then just answer 200 (retries are normal).
// 2) Load order $d['invoice_id']; require (float)$d['amount_paid'] >= (float)$order['total'].
// 3) Mark the order paid and store $d['receipt'].
}
http_response_code(200); echo 'OK';Node.js (Express)#
const express = require('express'), crypto = require('crypto');
const app = express();
// Keep the RAW body: the signature is computed over the exact bytes we sent.
app.post('/maainvest-webhook', express.raw({ type: '*/*' }), (req, res) => {
const ts = req.get('X-Maainvest-Timestamp') || '';
const sig = (req.get('X-Maainvest-Signature') || '').toLowerCase();
const expected = crypto.createHmac('sha256', process.env.MAA_API_SECRET)
.update(ts + '.' + req.body.toString('utf8')).digest('hex');
const fresh = /^\d+$/.test(ts) && Math.abs(Date.now() / 1000 - Number(ts)) <= 600;
const same = sig.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
if (!fresh || !same) return res.sendStatus(403);
const d = JSON.parse(req.body.toString('utf8'));
if (d.event === 'payment.succeeded' && d.merchant_id === process.env.MAA_MERCHANT_ID) {
// skip if d.receipt already stored; check Number(d.amount_paid) >= order total; mark d.invoice_id paid
}
res.status(200).send('OK');
});Python (Flask)#
import hmac, hashlib, time, json
from flask import Flask, request, abort
app = Flask(__name__)
@app.post('/maainvest-webhook')
def webhook():
raw = request.get_data() # raw bytes, before any JSON parsing
ts = request.headers.get('X-Maainvest-Timestamp', '')
sig = request.headers.get('X-Maainvest-Signature', '').lower()
expected = hmac.new(API_SECRET.encode(), ts.encode() + b'.' + raw, hashlib.sha256).hexdigest()
if not ts.isdigit() or abs(time.time() - int(ts)) > 600 or not hmac.compare_digest(sig, expected):
abort(403)
d = json.loads(raw)
if d.get('event') == 'payment.succeeded' and d.get('merchant_id') == MERCHANT_ID:
pass # skip if d['receipt'] already stored; check float(d['amount_paid']) >= order total; mark paid
return 'OK', 200Before you fulfil an order#
Checklist: verify the signature, the
merchant_id, that amount_paid covers the order total, and that you have not already processed this receipt. Do the slow work after you answer 200 so we do not time out and retry.
MAA INVEST Pay Docs