---
title: 'Charge customers on M-Pesa: a Daraja STK push integration guide (2026)'
description: >-
  A developer guide to the M-Pesa Daraja API: get an OAuth token, send an STK
  push, validate the callback, and reconcile a payment. JS-first, with Python.
summary: >-
  How to integrate the M-Pesa Daraja API end to end: OAuth token, STK push (Lipa
  Na M-Pesa Online), the C2B callback, and safe reconciliation, with Sautikit
  wallet top-up as the worked example.
date: 2026-07-03T00:00:00.000Z
type: blog
---


The M-Pesa Daraja API is how your code asks Safaricom to charge a customer's phone. You send an STK push, the customer's handset prompts for their M-Pesa PIN, and a callback tells you whether the money moved. This guide wires that flow end to end (OAuth token, STK push, callback validation, and reconciliation) using a real worked example: topping up a Sautikit prepaid wallet.

> **TL;DR**
>
> - Daraja STK push (Lipa Na M-Pesa Online) is a two-step, asynchronous flow: `POST` the push, then wait for a **callback**; never trust the synchronous response as proof of payment.
> - Every request needs a short-lived **OAuth bearer token** (`/oauth/v1/generate`), and the STK push needs a base64 **password** = `Base64(Shortcode + Passkey + Timestamp)`.
> - Validate the callback's `ResultCode` (0 = success), dedupe on `CheckoutRequestID`, and only credit the customer after the callback confirms; this is where most integrations leak money.
> - The same pattern funds a **Sautikit** wallet over M-Pesa: STK push in, callback confirms, wallet credited in KES. No card, no FX.

## What the Daraja API actually is

Daraja is Safaricom's developer platform for M-Pesa. The endpoint you almost always want is **Lipa Na M-Pesa Online**, better known as **STK push**: your server initiates a charge, and the customer's phone pops a PIN prompt (the "SIM Toolkit" overlay, hence *STK*). The customer approves, and Safaricom debits their M-Pesa balance and credits your Paybill or Till.

There are four moving pieces:

1. **Credentials**: a Consumer Key + Consumer Secret (from the Daraja portal) that you exchange for a bearer token.
2. **The STK push request**: authenticated with that token, signed with a base64 password.
3. **The synchronous acknowledgement**: Safaricom accepts the request and returns a `CheckoutRequestID`. This is *not* confirmation of payment.
4. **The asynchronous callback**: Safaricom POSTs the final result to your `CallBackURL` seconds later. This *is* the source of truth.

The single most common Daraja bug is treating step 3 as if it were step 4. A `200` on the push means "I have queued the prompt", not "the customer paid".

## Step 0: get your credentials and the sandbox

Create an app in the [Daraja portal](https://developer.safaricom.co.ke) to get a **Consumer Key** and **Consumer Secret**. For STK push you also need:

- A **Business Shortcode**: `174379` in sandbox; your Paybill/Till in production.
- A **Passkey**: Safaricom provides the sandbox passkey; production passkeys come with your Go-Live approval.
- A public **CallBackURL**: an HTTPS endpoint Safaricom can reach. During development, tunnel to localhost (ngrok, cloudflared).

Keep the Consumer Secret and Passkey in environment variables. Never ship them in client code.

## Step 1: get an OAuth access token

Every Daraja call is bearer-authenticated. Exchange your key and secret for a token via HTTP Basic auth. Tokens are short-lived (about an hour), so fetch one per batch of work rather than per request, and refresh on `401`.

**JavaScript**

```javascript
const BASE = "https://sandbox.safaricom.co.ke"; // api.safaricom.co.ke in prod

async function getAccessToken() {
  const auth = Buffer.from(
    `${process.env.DARAJA_CONSUMER_KEY}:${process.env.DARAJA_CONSUMER_SECRET}`
  ).toString("base64");

  const res = await fetch(`${BASE}/oauth/v1/generate?grant_type=client_credentials`, {
    headers: { Authorization: `Basic ${auth}` },
  });
  if (!res.ok) throw new Error(`OAuth failed: ${res.status}`);

  const { access_token } = await res.json();
  return access_token; // valid ~3600s
}
```

**Python**

```python
import base64, os, requests

BASE = "https://sandbox.safaricom.co.ke"  # api.safaricom.co.ke in prod

def get_access_token() -> str:
    key = os.environ["DARAJA_CONSUMER_KEY"]
    secret = os.environ["DARAJA_CONSUMER_SECRET"]
    auth = base64.b64encode(f"{key}:{secret}".encode()).decode()

    res = requests.get(
        f"{BASE}/oauth/v1/generate",
        params={"grant_type": "client_credentials"},
        headers={"Authorization": f"Basic {auth}"},
        timeout=10,
    )
    res.raise_for_status()
    return res.json()["access_token"]  # valid ~3600s
```

## Step 2: build the password and send the STK push

The STK push body needs a `Password` and a matching `Timestamp`. The password is `Base64(Shortcode + Passkey + Timestamp)`, where the timestamp is `YYYYMMDDHHmmss` in **East Africa Time**. The timestamp in the password and the top-level `Timestamp` field must be identical, or Safaricom rejects the request.

**JavaScript**

```javascript
function darajaTimestamp() {
  // EAT is UTC+3, no DST.
  const eat = new Date(Date.now() + 3 * 60 * 60 * 1000);
  const p = (n) => String(n).padStart(2, "0");
  return (
    eat.getUTCFullYear() +
    p(eat.getUTCMonth() + 1) + p(eat.getUTCDate()) +
    p(eat.getUTCHours()) + p(eat.getUTCMinutes()) + p(eat.getUTCSeconds())
  );
}

async function stkPush({ phone, amount, accountRef, description }) {
  const token = await getAccessToken();
  const timestamp = darajaTimestamp();
  const shortcode = process.env.DARAJA_SHORTCODE; // 174379 in sandbox
  const password = Buffer.from(
    shortcode + process.env.DARAJA_PASSKEY + timestamp
  ).toString("base64");

  const res = await fetch(`${BASE}/mpesa/stkpush/v1/processrequest`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${token}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      BusinessShortCode: shortcode,
      Password: password,
      Timestamp: timestamp,
      TransactionType: "CustomerPayBillOnline",
      Amount: amount,                       // whole KES, integer
      PartyA: phone,                        // 2547XXXXXXXX
      PartyB: shortcode,
      PhoneNumber: phone,
      CallBackURL: process.env.DARAJA_CALLBACK_URL,
      AccountReference: accountRef,         // <= 12 chars, shown to payer
      TransactionDesc: description,
    }),
  });

  const data = await res.json();
  // { MerchantRequestID, CheckoutRequestID, ResponseCode: "0", ... }
  // ResponseCode "0" == prompt sent. NOT proof of payment.
  return data;
}
```

**Python**

```python
import base64, os, requests
from datetime import datetime, timezone, timedelta

EAT = timezone(timedelta(hours=3))  # UTC+3, no DST

def daraja_timestamp() -> str:
    return datetime.now(EAT).strftime("%Y%m%d%H%M%S")

def stk_push(phone: str, amount: int, account_ref: str, description: str) -> dict:
    token = get_access_token()
    timestamp = daraja_timestamp()
    shortcode = os.environ["DARAJA_SHORTCODE"]  # 174379 in sandbox
    password = base64.b64encode(
        (shortcode + os.environ["DARAJA_PASSKEY"] + timestamp).encode()
    ).decode()

    res = requests.post(
        f"{BASE}/mpesa/stkpush/v1/processrequest",
        headers={"Authorization": f"Bearer {token}"},
        json={
            "BusinessShortCode": shortcode,
            "Password": password,
            "Timestamp": timestamp,
            "TransactionType": "CustomerPayBillOnline",
            "Amount": amount,               # whole KES, integer
            "PartyA": phone,                # 2547XXXXXXXX
            "PartyB": shortcode,
            "PhoneNumber": phone,
            "CallBackURL": os.environ["DARAJA_CALLBACK_URL"],
            "AccountReference": account_ref,  # <= 12 chars, shown to payer
            "TransactionDesc": description,
        },
        timeout=15,
    )
    data = res.json()
    # ResponseCode "0" == prompt sent. NOT proof of payment.
    return data
```

A few field notes that trip people up:

- **`Amount` is a whole number of KES.** Daraja STK push does not accept cents. Round or reject fractional amounts before you get here.
- **`PartyA` / `PhoneNumber`** use the `2547XXXXXXXX` format: no `+`, no leading `0`. Normalise user input first.
- **`AccountReference`** is capped at 12 characters and is shown to the payer. Use a stable per-customer or per-wallet reference so you can reconcile.

## Step 3: receive and validate the callback

Seconds later, Safaricom POSTs the outcome to your `CallBackURL`. This is the payload you trust. Success is `ResultCode: 0`; anything else (customer cancelled, timeout, insufficient balance) is a non-zero code with a `ResultDesc` you should log.

**JavaScript**

```javascript
import express from "express";
const app = express();
app.use(express.json());

app.post("/daraja/callback", async (req, res) => {
  // ACK immediately; Safaricom retries if you are slow.
  res.json({ ResultCode: 0, ResultDesc: "Accepted" });

  const cb = req.body?.Body?.stkCallback;
  if (!cb) return;

  const { CheckoutRequestID, ResultCode, ResultDesc } = cb;

  if (ResultCode !== 0) {
    // 1032 = cancelled by user, 1037 = timeout, 1 = insufficient balance
    await markPaymentFailed(CheckoutRequestID, ResultCode, ResultDesc);
    return;
  }

  // Success: pull the confirmed amount and receipt out of the metadata array.
  const items = cb.CallbackMetadata?.Item ?? [];
  const get = (name) => items.find((i) => i.Name === name)?.Value;
  const amount = get("Amount");                 // confirmed KES
  const receipt = get("MpesaReceiptNumber");     // e.g. "NLJ7RT61SV"
  const phone = get("PhoneNumber");

  // Idempotent: dedupe on CheckoutRequestID before crediting anything.
  await creditOnce({ CheckoutRequestID, amount, receipt, phone });
});

app.listen(3000);
```

**Python**

```python
from flask import Flask, request, jsonify
app = Flask(__name__)

@app.post("/daraja/callback")
def daraja_callback():
    body = request.get_json(silent=True) or {}
    cb = body.get("Body", {}).get("stkCallback")
    if not cb:
        return jsonify(ResultCode=0, ResultDesc="Accepted")

    checkout_id = cb["CheckoutRequestID"]
    result_code = cb["ResultCode"]

    if result_code != 0:
        # 1032 = cancelled, 1037 = timeout, 1 = insufficient balance
        mark_payment_failed(checkout_id, result_code, cb.get("ResultDesc"))
        return jsonify(ResultCode=0, ResultDesc="Accepted")

    items = cb.get("CallbackMetadata", {}).get("Item", [])
    get = lambda name: next((i.get("Value") for i in items if i["Name"] == name), None)
    amount = get("Amount")               # confirmed KES
    receipt = get("MpesaReceiptNumber")  # e.g. "NLJ7RT61SV"
    phone = get("PhoneNumber")

    # Idempotent: dedupe on CheckoutRequestID before crediting anything.
    credit_once(checkout_id, amount, receipt, phone)
    return jsonify(ResultCode=0, ResultDesc="Accepted")
```

> 
> 
> **Reconcile on the callback amount, not the amount you requested.** A customer can be prompted for KES 500 and the callback is the record of what actually moved. Credit the `CallbackMetadata` amount, keyed on `CheckoutRequestID` so a retried callback never double-credits. This is the difference between a ledger that balances and one that quietly drifts.
> 
> 

## Step 4: reconcile safely (where money leaks)

Two rules keep a Daraja integration honest:

1. **Idempotency.** Safaricom can deliver the same callback more than once, and your own retries can replay it. Store `CheckoutRequestID` (and the `MpesaReceiptNumber`) with a unique constraint, and make crediting a no-op if you have seen it before. Never `balance += amount` without that guard.
2. **Timeout handling.** If no callback arrives, the payment is *unknown*, not *failed*. Use the **STK Push Query** endpoint (`/mpesa/stkpushquery/v1/query`) with the `CheckoutRequestID` to ask Safaricom for the final status before you decide. Do not silently cancel a charge that may have succeeded.

Treat the M-Pesa receipt number as the external transaction ID in your books. It is what the customer sees on their handset and what a support agent will quote back to you.

## Worked example: funding a Sautikit wallet over M-Pesa

Everything above is exactly how you'd let a customer top up a balance in your own product, and it's how **Sautikit** funds its prepaid wallet. Sautikit is a programmable voice API billed in KES: you claim a number for KES 100/month (ex. VAT; KES 116 incl. VAT), and outbound calls cost KES 0.05/sec (KES 3.00/min), billed per second from the moment the call connects. Inbound is free. There's no card and no USD invoice; you top the wallet up over M-Pesa with the same STK push flow above.

From your side it's one request; Sautikit runs the Daraja handshake, listens for the callback, and credits the wallet in KES:

**JavaScript**

```javascript
// Trigger an M-Pesa STK push that funds your Sautikit wallet.
const res = await fetch("https://api.sautikit.com/v1/topups", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.SAUTIKIT_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    provider: "mpesa",
    amount: 1000,            // KES
    phone: "+254700000000",  // the handset to prompt
  }),
});

const topup = await res.json();
// { id, status: "pending", ... }; the phone now shows the M-Pesa PIN prompt.
// A wallet.top_up webhook fires once the Daraja callback confirms.
```

**Python**

```python
import os, requests

# Trigger an M-Pesa STK push that funds your Sautikit wallet.
res = requests.post(
    "https://api.sautikit.com/v1/topups",
    headers={"Authorization": f"Bearer {os.environ['SAUTIKIT_API_KEY']}"},
    json={
        "provider": "mpesa",
        "amount": 1000,             # KES
        "phone": "+254700000000",   # the handset to prompt
    },
    timeout=15,
)
topup = res.json()
# { "id": ..., "status": "pending" }; the phone shows the M-Pesa PIN prompt.
# A wallet.top_up webhook fires once the Daraja callback confirms.
```

The value of letting the platform own the Daraja handshake is that the idempotency, callback validation, and reconciliation from steps 3–4 are already handled: the money only lands in your wallet once the callback confirms, keyed so a replayed callback never double-credits. You get a `wallet.top_up` webhook when it clears, and the balance is in shillings, ready to place calls.

## FAQ

### Is the STK push response proof that the customer paid?

No. A `ResponseCode` of `0` on the push means the PIN prompt was queued to the customer's phone. Payment is confirmed only by the asynchronous callback to your `CallBackURL` (or by querying STK Push Query). Build your logic around the callback.

### What are the common `ResultCode` values?

`0` is success. `1032` means the customer cancelled the prompt, `1037` is a timeout (no PIN entered), and `1` is insufficient M-Pesa balance. Log `ResultDesc` for anything non-zero.

### How do I avoid double-crediting a payment?

Dedupe on `CheckoutRequestID` and `MpesaReceiptNumber` with a unique constraint, and make the credit operation a no-op if you've already recorded that ID. Safaricom can redeliver callbacks, and your own retries can replay them.

### Why is my STK push rejected with an invalid-password error?

The `Password` is `Base64(Shortcode + Passkey + Timestamp)` and the `Timestamp` in the password must exactly match the top-level `Timestamp`, both in East Africa Time (`YYYYMMDDHHmmss`). A mismatch (often from a UTC vs EAT bug) is the usual cause.

### Can I use Daraja to charge cents?

STK push `Amount` is a whole number of KES. Handle sub-shilling logic in your own ledger before initiating the push.

### Do I have to build all of this to accept M-Pesa in a voice product?

No. If your product is voice, Sautikit runs the Daraja flow for wallet top-ups so you don't maintain the OAuth, password, callback, and reconciliation code yourself: you call `POST /v1/topups` and get a `wallet.top_up` webhook when the money clears.

## Get started

1. [Create a Sautikit workspace](/) and claim a number (from KES 116, instant).
2. Fund the wallet with `POST /v1/topups`: an M-Pesa STK push, no card required.
3. Place your first call in KES, billed per second from the moment it connects.

**[Start with Sautikit →](/)** &nbsp;·&nbsp; **[See pricing →](/pricing)** &nbsp;·&nbsp; **[Need SMS, WhatsApp & an agent desk? Helloduty →](https://helloduty.com)**

Building a full customer-experience flow around M-Pesa (payment reminders over SMS and WhatsApp, an agent desk, USSD menus) alongside voice? [Helloduty](https://helloduty.com) is the multi-channel platform Sautikit is part of, so voice stays focused while the rest of the stack lives one step away.

## Further reading

- [M-Pesa payment confirmation calls](/blog/mpesa-payment-confirmation-call): trigger a Sautikit voice call the moment a Daraja payment clears.
- [Pricing](/pricing): KES billing, per-second voice, free inbound.
