Skip to content

M-Pesa API

The M‑Pesa API, with the hard parts already handled

Send an STK push and get a signed webhook when the customer pays. Noria Pay runs Daraja on your own shortcode, confirms every STK push result with Safaricom and recovers the callbacks Daraja drops.

Charge by M-Pesa STK push
curl https://pay.noria.co.ke/v1/charges \
  -H "Authorization: Bearer $NORIA_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: invoice-1042" \
  -d '{
    "amount_minor": 250000,
    "currency": "KES",
    "channel": "mpesa",
    "payer_phone": "254712345678",
    "reference": "INV-1042",
    "description": "BillGrid invoice INV-1042"
  }'
const response = await fetch("https://pay.noria.co.ke/v1/charges", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.NORIA_API_KEY}`,
    "Content-Type": "application/json",
    "Idempotency-Key": "invoice-1042",
  },
  body: JSON.stringify({
    "amount_minor": 250000,
    "currency": "KES",
    "channel": "mpesa",
    "payer_phone": "254712345678",
    "reference": "INV-1042",
    "description": "BillGrid invoice INV-1042"
  }),
});
const result = await response.json();
import os
import requests

result = requests.post(
    "https://pay.noria.co.ke/v1/charges",
    headers={
        "Authorization": f"Bearer {os.environ['NORIA_API_KEY']}",
        "Idempotency-Key": "invoice-1042",
    },
    json={
        "amount_minor": 250000,
        "currency": "KES",
        "channel": "mpesa",
        "payer_phone": "254712345678",
        "reference": "INV-1042",
        "description": "BillGrid invoice INV-1042",
    },
    timeout=30,
).json()
<?php
$ch = curl_init('https://pay.noria.co.ke/v1/charges');
curl_setopt_array($ch, [
    CURLOPT_POST => true,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => [
        'Authorization: Bearer ' . getenv('NORIA_API_KEY'),
        'Content-Type: application/json',
        'Idempotency-Key: invoice-1042',
    ],
    CURLOPT_POSTFIELDS => json_encode([
        'amount_minor' => 250000,
        'currency' => 'KES',
        'channel' => 'mpesa',
        'payer_phone' => '254712345678',
        'reference' => 'INV-1042',
        'description' => 'BillGrid invoice INV-1042',
    ]),
]);
$result = json_decode(curl_exec($ch), true);
body, _ := json.Marshal(map[string]any{
	"amount_minor": 250000,
	"currency":     "KES",
	"channel":      "mpesa",
	"payer_phone":  "254712345678",
	"reference":    "INV-1042",
	"description":  "BillGrid invoice INV-1042",
})
req, _ := http.NewRequest("POST", "https://pay.noria.co.ke/v1/charges", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+os.Getenv("NORIA_API_KEY"))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", "invoice-1042")
res, err := http.DefaultClient.Do(req)
{
  "id": "0193a1d9-fcc3-7c3a-8d9d-838bd6273c4b",
  "type": "succeeded",
  "sequence": 3,
  "created_at": "2026-11-14T09:41:07Z",
  "data": {
    "transaction_id": "0193a1d9-e0a2-7b71-9f0e-2a6c5f1d8e40",
    "direction": "collection",
    "status": "succeeded",
    "provider": "daraja",
    "channel": "mpesa",
    "currency": "KES",
    "amount_minor": 250000,
    "settled_minor": 250000,
    "reference": "INV-1042",
    "provider_receipt": "TKE4X7Q2LM"
  }
}

One request sends the prompt. Pay confirms the result with Safaricom, then posts a signed webhook.

STK push without the Daraja paperwork

Add your consumer key, secret, passkey and shortcode once, in sandbox or live. Pay requests and caches OAuth tokens, builds the password and timestamp for every request and trims references to the lengths Daraja accepts.

  • Paybill (CustomerPayBillOnline) and till (CustomerBuyGoodsOnline)
  • Sandbox and live credentials kept apart
  • Tokens cached encrypted, refreshed before they expire

A callback is not proof of payment

Pay authenticates each callback with a per-transaction token, then checks the outcome with Safaricom before it settles a collection. A forged or replayed callback cannot mark an order paid.

  • Per-transaction callback tokens, stored as hashes
  • Duplicate and out-of-order callbacks settle a payment once
  • A unique receipt index stops double settlement

Callbacks that never arrive

If Safaricom has not called back within a grace period, Pay queries the transaction itself and applies the answer. A request that timed out is looked up, never sent again, so a customer is never charged twice.

B2C payouts and reversals

Upload Safaricom's certificate and your initiator credentials once. Pay builds the encrypted security credential for every B2C payment and reversal. Refunds go back through the method the customer paid with.

In code

Verify a webhook

HMAC-SHA256 over the timestamp, a dot and the raw body, compared in constant time.

Full API reference
Verify a webhook
import crypto from "node:crypto";

export function verifyNoriaSignature(rawBody, header, secret, toleranceSeconds = 300) {
  const parts = Object.fromEntries(header.split(",").map((part) => part.split("=")));
  const timestamp = Number(parts.t);
  if (!timestamp || Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) return false;
  const expected = crypto.createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest();
  const received = Buffer.from(parts.v1 || "", "hex");
  return received.length === expected.length && crypto.timingSafeEqual(received, expected);
}
import hashlib
import hmac
import time


def verify_noria_signature(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
    parts = dict(part.split("=", 1) for part in header.split(","))
    timestamp = int(parts.get("t", "0"))
    if not timestamp or abs(time.time() - timestamp) > tolerance:
        return False
    expected = hmac.new(secret.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, parts.get("v1", ""))
<?php
function verifyNoriaSignature(string $rawBody, string $header, string $secret, int $tolerance = 300): bool
{
    $parts = [];
    foreach (explode(',', $header) as $part) {
        [$key, $value] = array_pad(explode('=', $part, 2), 2, '');
        $parts[$key] = $value;
    }
    $timestamp = (int) ($parts['t'] ?? 0);
    if ($timestamp === 0 || abs(time() - $timestamp) > $tolerance) {
        return false;
    }
    $expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
    return hash_equals($expected, $parts['v1'] ?? '');
}
func verifyNoriaSignature(rawBody []byte, header, secret string, tolerance time.Duration) bool {
	var timestamp int64
	var signature string
	for part := range strings.SplitSeq(header, ",") {
		key, value, _ := strings.Cut(part, "=")
		switch key {
		case "t":
			timestamp, _ = strconv.ParseInt(value, 10, 64)
		case "v1":
			signature = value
		}
	}
	age := time.Since(time.Unix(timestamp, 0)).Abs()
	if timestamp == 0 || age > tolerance {
		return false
	}
	mac := hmac.New(sha256.New, []byte(secret))
	fmt.Fprintf(mac, "%d.%s", timestamp, rawBody)
	return hmac.Equal([]byte(hex.EncodeToString(mac.Sum(nil))), []byte(signature))
}

HMAC-SHA256 over the timestamp, a dot and the raw body, compared in constant time.

M-Pesa API questions

Something else? Ask us.

Do I need my own paybill or till?

Yes. Noria Pay calls Daraja with your own credentials, and Safaricom settles payments straight to your shortcode. Noria never holds your money.

Can I use a buy goods till instead of a paybill?

Yes. Each M-Pesa method is set to paybill or till, and STK push uses the matching Daraja transaction type.

What happens if the customer never enters their PIN?

The payment fails or expires once Safaricom says so. Pay never marks a payment succeeded or failed without Safaricom's answer, and a late answer is still applied.

Does Noria Pay accept fractions of a shilling?

No. M-Pesa settles whole shillings, so Pay rejects fractional KES amounts instead of rounding them and leaving your records out of step with your statement.

Guides

All guides

Related

Ready to take payments properly?

Tell us what you are building and which providers you use. We set up your workspace and help you go live.