How to integrate MPESA and Kenyan banks payment collections with Zoho books

How to integrate MPESA and Kenyan banks with Zoho books

Supported channels

Tuma Payment Gateway offers a Robust API that automatically syncs all cashless payments made through mobile money services with Zoho Books. This works with M-PESA paybills, Buy Goods till numbers or any bank account in Kenya, that is:

  • M-PESA Paybill number / Buy Goods Till Number
  • Equity Bank
  • Kenya Coomercial Bank(KCB)
  • Cooperative Bank of Kenya
  • Diamond Trust Bank
  • NCBA
  • Loop
  • Family Bank
  • Stanbic Bank
  • I&M bank
  • KWFT
  • Faulu Bank
  • Access Bank
  • Standard Chartered Bank
  • ABSA
  • SBM Bank Kenya
  • National Bank
  • Sidian Bank etc

Steps

1) Sign up on Tuma, create a business profile and generate API keys: refer to this link

The onboarding procedure is the same regardless of your bank.

2) Login to Zoho API Console https://api-console.zoho.com/add

Click add client, select “self client“

Zoho MPESA API integration

3) Add access scopes and generate code.

Add access scopes: ZohoBooks.fullaccess.READ,ZohoBooks.fullaccess.CREATE,ZohoBooks.fullaccess.UPDATE,ZohoBooks.fullaccess.DELETE

MPESA integration with Zoho and banks

NOTE: Copy the generated Grant Token. Immediately exchange it for a Refresh Token using this one-time CURL command (Grant tokens expire in 1-10 minutes)

Request

curl -X POST "https://accounts.zoho.com/oauth/v2/token" \
-d "code={YOUR_GRANT_TOKEN}" \
-d "client_id={YOUR_CLIENT_ID}" \
-d "client_secret={YOUR_CLIENT_SECRET}" \
-d "grant_type=authorization_code"

Response

{"access_token":"1050.5a2f3ff9cy745teu6538hwnb44b.hfyeuisshf54ac0b35911de220e1c7cc","refresh_token":"1300.0i972b4adf5464748u3f41.f8i4ye6wg546gehab5","scope":"ZohoBooks.fullaccess.CREATE ZohoBooks.fullaccess.READ ZohoBooks.fullaccess.UPDATE","api_domain":"https://www.zohoapis.com","token_type":"Bearer","expires_in":3600}

Save the refresh_token from the response.

Important

  • Access Tokens expire in 1 hour: You should ideally cache the access token in a file or database and only request a new one when it expires to stay within API limits.
  • Datacenters: If your Zoho account is on the EU server, change the URLs to accounts.zoho.eu and zohoapis.eu.
  • Headers: Zoho specifically requires the Zoho-oauthtoken prefix in the Authorization header.

4) Go to your Zoho Books, add bank account and enable sales receipt module

Zoho books automation with mpesa and bank payments

Enable Sales receipts and save

Zoho Sales sync with MPESA and Bank payments

5) Create a walk-in customer in Sales > Customers

Zoho integration with MPESA and bank payments in Kenya

6) Initiate payment prompt from Tuma API

Authentication

Endpoint : https://api.tuma.co.ke/auth/token

Method: POST

Content-Type: application/json

Payload

{
  "email": "[email protected]",
  "api_key": "your_company_tuma_api_key"
}

Response

{
  "success": true,
  "message": "Authentication successful",
  "data": {
    "token": "eyJhbGciOiJIUzI1NiIsInRCJ9.eyJzaG9wX2lkIjoiNzg3ZTI0NGQtOTRhOC00ODgxLWJjNzctNDc2ZGZkNjA3ZGJlIiwidXNlcl9pZCI6ImQyYTY5NWVkLTg1MWUtNGZiYy1iOTUiIsImVtYWlsIjoic2hhZHJhY2subWF0YXRhQGljbG91ZC5jb20iLCJpcHJzX2FjY2VzcyI6ZCI6MTc2MzU0MjY2NywiZXhwIjoxNzYzNjI5MDY3fQ.3FHTZ9ENc_C-C8-AvQEXroIeNYD5Ncv5eEhB4WpEKaY",
    "shop": {
      "id": "787e244d-94a8-4881-bc77-476dfd607dbe",
      "name": "Your Company Name",
      "email": "[email protected]"
    }
  }
}

Initiating Payment Request

With the access token you can now initiate payment request via STK push prompt for any mobile payment network.

POST https://api.tuma.co.ke/payment/stk-push
Authorization: Bearer your-jwt-token
Content-Type: application/json

{
  "amount": 100.00,
  "phone": "254712345678",
  "callback_url": "https://your-app.com/callback",
  "description": "Payment for order #123"
}

Response:
{
    "success": true,
    "message": "Payment request sent successfully. Complete payment on your phone.",
    "data": {
        "merchant_request_id": "2dfd-472d-9bbd-df490884098d625543",
        "checkout_request_id": "ws_CO_04032026165157497729598795",
        "customer_message": "Success. Request accepted for processing"
    }
}

Callback handling and posting to Zoho Books

Your callback URL provided in STK PUSH request will receive payment status updates as shown below.

POST https://your-app.com/callback
Content-Type: application/json

//Response model for success, result_code=0, else it's a fail

{
  "status": "completed",
  "merchant_request_id": "a5ea-442f-a424-f94158490a468325",
  "checkout_request_id": "ws_CO_23022026142735114729500095",
  "result_code": 0,
  "result_desc": "The service request is processed successfully.",
  "timestamp": "2026-02-23 14:27:46",
  "mpesa_receipt_number": "UBNGT7QNYB",
  "amount": 10
}

//fail response model e.g
{
  "status": "failed",
  "merchant_request_id": "0e96-4a74-ab9d-ae3b6a9c0e933677",
  "checkout_request_id": "ws_CO_28022026175239080729590095",
  "result_code": 2001,
  "result_desc": "The initiator information is invalid.",
  "timestamp": "2026-02-28 17:52:51",
  "failure_reason": "Invalid M-Pesa PIN entered"
}

With payment details, you can now post the transactions in Zoho. Check this examples callback example

PHP

<?php
/**
 * MPESA → Zoho Books Webhook Example
 * Only creates receipt when payment is successful (result_code = 0)
 * Dummy credentials – replace with real ones in production
 */

// --- CONFIG (dummy values for documentation) ---
$ZOHO_CLIENT_ID     = '1000.XXXXXXXXXXXXYYYYYYYYZZZZZZZZZZ';
$ZOHO_CLIENT_SECRET = 'zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz';
$ZOHO_REFRESH_TOKEN = '1000.aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb';
$ZOHO_ORG_ID        = '123456789';
$ZOHO_DC            = 'com'; // or 'in', 'eu', etc.

// Walk-in customer & cash account IDs (replace with your real ones)
$CUSTOMER_ID    = '1234567890000001234';
$DEPOSIT_TO_ID  = '1234567890000005678';

// --- 1. Read MPESA callback ---
$raw = file_get_contents('php://input');
$data = json_decode($raw, true);

if (json_last_error() !== JSON_ERROR_NONE) {
    http_response_code(400);
    exit;
}

// Handle both flat and nested Daraja formats
$callback = $data['Body']['stkCallback'] ?? $data;

if (!isset($callback['result_code']) || $callback['result_code'] != 0) {
    // Acknowledge but do nothing
    http_response_code(200);
    echo json_encode(['status' => 'ignored']);
    exit;
}

// Extract useful fields
$receipt    = $callback['mpesa_receipt_number']   ?? 'UNKNOWN';
$amount     = floatval($callback['amount']        ?? 0);
$checkoutId = $callback['checkout_request_id']    ?? 'N/A';

if ($amount <= 0) {
    http_response_code(200);
    exit;
}

// --- 2. Get Zoho access token ---
$tokenUrl = "https://accounts.zoho.{$ZOHO_DC}/oauth/v2/token";
$params = http_build_query([
    'refresh_token' => $ZOHO_REFRESH_TOKEN,
    'client_id'     => $ZOHO_CLIENT_ID,
    'client_secret' => $ZOHO_CLIENT_SECRET,
    'grant_type'    => 'refresh_token'
]);

$ch = curl_init($tokenUrl);
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_POST           => true,
    CURLOPT_POSTFIELDS     => $params,
]);
$tokenResp = json_decode(curl_exec($ch), true);
curl_close($ch);

$accessToken = $tokenResp['access_token'] ?? null;
if (!$accessToken) {
    http_response_code(500);
    exit;
}

// --- 3. Create sales receipt ---
$apiUrl = "https://www.zohoapis.{$ZOHO_DC}/books/v3/salesreceipts?organization_id={$ZOHO_ORG_ID}";

$payload = [
    'customer_id'           => $CUSTOMER_ID,
    'date'                  => date('Y-m-d'),
    'payment_mode'          => 'M-Pesa',
    'deposit_to_account_id' => $DEPOSIT_TO_ID,
    'reference_number'      => "MPESA REF: {$receipt}",
    'line_items'            => [
        [
            'name'     => 'POS / Online Sale',
            'rate'     => $amount,
            'quantity' => 1,
        ]
    ],
    'notes'                 => "M-Pesa Checkout: {$checkoutId}\nReceipt: {$receipt}"
];

$headers = [
    "Authorization: Zoho-oauthtoken {$accessToken}",
    'Content-Type: application/json',
];

$ch = curl_init($apiUrl);
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_POST           => true,
    CURLOPT_POSTFIELDS     => json_encode($payload),
    CURLOPT_HTTPHEADER     => $headers,
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);

// Always respond 200 to MPESA
http_response_code(200);

if ($httpCode === 201) {
    echo json_encode(['status' => 'success']);
} else {
    echo json_encode([
        'status' => 'zoho_error',
        'code'   => $httpCode,
        // 'details' => $response   ← uncomment only in dev
    ]);
}
?>

Java

import org.springframework.http.*;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.client.RestTemplate;
import java.time.LocalDate;
import java.util.*;

@RestController
@RequestMapping("/webhook")
public class MpesaZohoWebhook {

    // Dummy config – replace in production
    private static final String ZOHO_CLIENT_ID     = "1000.XXXXXXXXXXXXYYYYYYYYZZZZZZZZZZ";
    private static final String ZOHO_CLIENT_SECRET = "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz";
    private static final String ZOHO_REFRESH_TOKEN = "1000.aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
    private static final String ZOHO_ORG_ID        = "123456789";
    private static final String ZOHO_DC            = "com";
    private static final String CUSTOMER_ID        = "1234567890000001234";
    private static final String DEPOSIT_TO_ID      = "1234567890000005678";

    private final RestTemplate rest = new RestTemplate();

    private String getAccessToken() {
        String url = "https://accounts.zoho." + ZOHO_DC + "/oauth/v2/token";

        MultiValueMap<String, String> body = new LinkedMultiValueMap<>();
        body.add("refresh_token", ZOHO_REFRESH_TOKEN);
        body.add("client_id", ZOHO_CLIENT_ID);
        body.add("client_secret", ZOHO_CLIENT_SECRET);
        body.add("grant_type", "refresh_token");

        HttpHeaders headers = new HttpHeaders();
        headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);

        HttpEntity<MultiValueMap<String, String>> req = new HttpEntity<>(body, headers);
        Map<String, Object> resp = rest.postForObject(url, req, Map.class);

        return (String) resp.get("access_token");
    }

    @PostMapping("/mpesa-zoho")
    public ResponseEntity<Map<String, Object>> handleMpesaCallback(@RequestBody Map<String, Object> payload) {
        // Flatten nested structure if present
        @SuppressWarnings("unchecked")
        Map<String, Object> callback = (Map<String, Object>) ((Map<String, Object>) payload.getOrDefault("Body", payload)).getOrDefault("stkCallback", payload);

        Object resultCode = callback.get("result_code");
        if (resultCode == null || !resultCode.equals(0) && !resultCode.equals("0")) {
            return ResponseEntity.ok(Map.of("status", "ignored"));
        }

        String receipt = (String) callback.getOrDefault("mpesa_receipt_number", "UNKNOWN");
        Number amountNum = (Number) callback.get("amount");
        double amount = amountNum != null ? amountNum.doubleValue() : 0;
        String checkoutId = (String) callback.getOrDefault("checkout_request_id", "N/A");

        if (amount <= 0) {
            return ResponseEntity.ok(Map.of("status", "invalid amount"));
        }

        String token = getAccessToken();
        if (token == null) {
            return ResponseEntity.ok(Map.of("status", "auth failed"));
        }

        String url = "https://www.zohoapis." + ZOHO_DC + "/books/v3/salesreceipts?organization_id=" + ZOHO_ORG_ID;

        Map<String, Object> lineItem = new HashMap<>();
        lineItem.put("name", "POS / Online Sale");
        lineItem.put("rate", amount);
        lineItem.put("quantity", 1);

        Map<String, Object> receiptPayload = new HashMap<>();
        receiptPayload.put("customer_id", CUSTOMER_ID);
        receiptPayload.put("date", LocalDate.now().toString());
        receiptPayload.put("payment_mode", "M-Pesa");
        receiptPayload.put("deposit_to_account_id", DEPOSIT_TO_ID);
        receiptPayload.put("reference_number", "MPESA REF: " + receipt);
        receiptPayload.put("line_items", List.of(lineItem));
        receiptPayload.put("notes", "M-Pesa Checkout: " + checkoutId + "\nReceipt: " + receipt);

        HttpHeaders headers = new HttpHeaders();
        headers.set("Authorization", "Zoho-oauthtoken " + token);
        headers.setContentType(MediaType.APPLICATION_JSON);

        HttpEntity<Map<String, Object>> entity = new HttpEntity<>(receiptPayload, headers);

        try {
            ResponseEntity<String> response = rest.postForEntity(url, entity, String.class);
            if (response.getStatusCode() == HttpStatus.CREATED) {
                return ResponseEntity.ok(Map.of("status", "success"));
            } else {
                return ResponseEntity.ok(Map.of(
                    "status", "zoho_failed",
                    "code", response.getStatusCodeValue()
                ));
            }
        } catch (Exception e) {
            return ResponseEntity.ok(Map.of("status", "error", "message", e.getMessage()));
        }
    }
}

Python

from flask import Flask, request, jsonify
import requests
from datetime import date

app = Flask(__name__)

# Dummy config – replace in production
ZOHO = {
    'client_id': '1000.XXXXXXXXXXXXYYYYYYYYZZZZZZZZZZ',
    'client_secret': 'zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz',
    'refresh_token': '1000.aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
    'org_id': '123456789',
    'dc': 'com',
    'customer_id': '1234567890000001234',
    'deposit_to_id': '1234567890000005678'
}

def get_zoho_token():
    url = f"https://accounts.zoho.{ZOHO['dc']}/oauth/v2/token"
    data = {
        'refresh_token': ZOHO['refresh_token'],
        'client_id': ZOHO['client_id'],
        'client_secret': ZOHO['client_secret'],
        'grant_type': 'refresh_token'
    }
    r = requests.post(url, data=data)
    return r.json().get('access_token')

@app.route('/webhook/mpesa-zoho', methods=['POST'])
def mpesa_webhook():
    data = request.get_json(force=True, silent=True)
    if not data:
        return jsonify({'status': 'bad request'}), 400

    # Handle nested or flat structure
    callback = data.get('Body', {}).get('stkCallback', data)

    if callback.get('result_code') != 0:
        return jsonify({'status': 'ignored'}), 200

    receipt = callback.get('mpesa_receipt_number', 'UNKNOWN')
    amount = float(callback.get('amount', 0))
    checkout_id = callback.get('checkout_request_id', 'N/A')

    if amount <= 0:
        return jsonify({'status': 'invalid amount'}), 200

    token = get_zoho_token()
    if not token:
        return jsonify({'status': 'auth failed'}), 200   # still ack to Safaricom

    url = f"https://www.zohoapis.{ZOHO['dc']}/books/v3/salesreceipts?organization_id={ZOHO['org_id']}"

    payload = {
        'customer_id': ZOHO['customer_id'],
        'date': date.today().isoformat(),
        'payment_mode': 'M-Pesa',
        'deposit_to_account_id': ZOHO['deposit_to_id'],
        'reference_number': f"MPESA REF: {receipt}",
        'line_items': [{
            'name': 'POS / Online Sale',
            'rate': amount,
            'quantity': 1
        }],
        'notes': f"M-Pesa Checkout: {checkout_id}\nReceipt: {receipt}"
    }

    headers = {
        'Authorization': f"Zoho-oauthtoken {token}",
        'Content-Type': 'application/json'
    }

    r = requests.post(url, json=payload, headers=headers)

    if r.status_code == 201:
        return jsonify({'status': 'success'}), 200
    else:
        return jsonify({
            'status': 'zoho_failed',
            'code': r.status_code
            # 'details': r.text   ← only in dev
        }), 200

if __name__ == '__main__':
    app.run(debug=True)

6) Check Sales receipts in Zoho Books(Sales >Sales receipts)

The sale has been synchronized with Zoho Books

MPESA Sale posting to Zoho Books Automatically

Error handling

In the event that payment has not been completed, all API responses follow a consistent format.

// Success Response
{
  "success": true,
  "data": { ... }
}

// Error Response
{
  "success": false,
  "message": "Error description"
}

Learn more about error codes here

Support

For inquiries or support, contact [email protected] or +254 782 411 538.

Tuma is a powerful payment gateway that automates billing workflows by connecting invoices, apps and sales with M-PESA and all banks in Kenya.