MAA INVEST Pay Docs
API reference

Webhooks

The signed payment.succeeded callback, retries and receiver examples.

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"
}
FieldMeaning
amountThe amount you sent for the order.
amount_paidWhat 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.
receiptM-Pesa receipt number. Unique per payment: use it to ignore repeats.
checkout_idOur checkout reference.
phoneThe payer's number, masked.
sandboxPresent 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', 200

Before 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.
Stuck on an integration? Open a ticket from the Support page in your account and include your invoice_id, the time, and the HTTP status you returned.