Daraja SDK Logomark
Daraja SDK
Endpoints

M-Pesa Express (STK Push)

Send a payment prompt to a customer's phone, and check its outcome.

MpesaExpressClient wraps two Daraja endpoints:

MethodDaraja endpointPurpose
mpesaExpress.push()POST /mpesa/stkpush/v1/processrequestSends the PIN-entry prompt
mpesaExpress.query()POST /mpesa/stkpushquery/v1/queryChecks the outcome of a previous push

daraja.stkPush() and daraja.stkPushQuery() are shortcuts for the same two methods, exposed on the top-level client because this is the endpoint most people reach for first.

Naming note

Safaricom's own portal labels the push endpoint "M-Pesa Express Simulate," which reads like a sandbox-only tool. It isn't this is the real, production-capable payment initiation call. The "Simulate" in the name is a Daraja naming quirk, not a hint about environment.

push()

Configure shared STK values once when creating the client. Values supplied in an individual request override these defaults, which is useful when handling multiple businesses or shortcodes.

daraja.ts
import { Daraja } from "@lumierelabs/daraja";

export const daraja = Daraja({
  consumerKey: process.env.DARAJA_CONSUMER_KEY!,
  consumerSecret: process.env.DARAJA_CONSUMER_SECRET!,
  shortcode: process.env.DARAJA_SHORTCODE!,
  passkey: process.env.DARAJA_PASSKEY!,
  callbackUrl: process.env.DARAJA_CALLBACK_URL!,
  environment: "sandbox",
});
interface StkPushRequest {
  businessShortCode?: string | number; // 5–7 digits; defaults to DarajaConfig.shortcode
  passkey?: string; // defaults to DarajaConfig.passkey
  transactionType: "CustomerPayBillOnline" | "CustomerBuyGoodsOnline";
  amount: number; // whole number, 1–250,000 KES
  partyA: string; // format "254XXXXXXXXX"  debited
  partyB?: string | number; // your shortcode/till; defaults to DarajaConfig.shortcode
  phoneNumber: string; // where the prompt is sent, usually === partyA
  callBackURL?: string; // defaults to DarajaConfig.callbackUrl
  accountReference: string; // max 12 characters  shown to the customer
  transactionDesc?: string; // max 13 characters, default "Payment"
}

interface StkPushResponse {
  MerchantRequestID: string;
  CheckoutRequestID: string;
  ResponseCode: string;
  ResponseDescription: string;
  CustomerMessage: string;
}
const result = await daraja.mpesaExpress.push({
  transactionType: "CustomerPayBillOnline",
  amount: 1,
  partyA: "254708374149",
  phoneNumber: "254708374149",
  accountReference: "INV-1042",
  transactionDesc: "Order 1042",
});

console.log(result.CheckoutRequestID); // e.g. "ws_CO_260820261234567890"

Password and Timestamp are generated for you

Daraja requires a Password field computed as Base64(BusinessShortCode + Passkey + Timestamp), where Timestamp must be in YYYYMMDDHHmmss format in East Africa Time, regardless of your server's own timezone. Get the timezone wrong (most cloud servers run UTC) and Daraja rejects the request with a generic authentication failure that has nothing to do with your actual OAuth token. push() and query() compute both fields internally using Africa/Nairobi explicitly you never touch Password or Timestamp directly.

Validation the SDK performs before sending

  • businessShortCode must be 5–7 digits, using DarajaConfig.shortcode when omitted
  • passkey uses DarajaConfig.passkey when omitted
  • partyB uses DarajaConfig.shortcode when omitted
  • amount must be a whole number between 1 and 250,000 (Safaricom's documented per-transaction cap)
  • partyA and phoneNumber must match 254XXXXXXXXX (12 digits)
  • accountReference ≤ 12 characters, transactionDesc ≤ 13 characters
  • callBackURL uses DarajaConfig.callbackUrl when omitted and goes through callback URL validation

All failures throw DarajaError with errorCode: 'INVALID_STK_REQUEST'.

query()

interface StkQueryRequest {
  businessShortCode?: string | number; // same shortcode used in push(), or DarajaConfig.shortcode
  passkey?: string; // same passkey used in push(), or DarajaConfig.passkey
  checkoutRequestId: string; // CheckoutRequestID from push()'s response
}

interface StkQueryResponse {
  ResponseCode: string;
  ResponseDescription: string;
  MerchantRequestID: string;
  CheckoutRequestID: string;
  ResultCode: string;
  ResultDesc: string;
}
const status = await daraja.mpesaExpress.query({
  checkoutRequestId: result.CheckoutRequestID,
});

if (status.ResultCode === "0") {
  console.log("Payment completed");
} else {
  console.log("Payment not completed:", status.ResultDesc);
}

ResultCode is a string here, not a number

StkQueryResponse.ResultCode is typed as string because that's what Daraja returns on this particular endpoint compare against '0', not 0. This is one of several spots across Daraja where the same logical field (a result code) ships as a different JSON type depending on which endpoint you're hitting. The C2B callback payload you receive from Safaricom uses a numeric ResultCode; this query response does not.

Handling the async callback

push() resolves once Daraja accepts the request the real result (customer entered their PIN, cancelled, or the prompt timed out after ~20 seconds unanswered) is POSTed to callBackURL separately, shaped like this:

{
  "Body": {
    "stkCallback": {
      "MerchantRequestID": "29115-34620561-1",
      "CheckoutRequestID": "ws_CO_260820261234567890",
      "ResultCode": 0,
      "ResultDesc": "The service request is processed successfully.",
      "CallbackMetadata": {
        "Item": [
          { "Name": "Amount", "Value": 1 },
          { "Name": "MpesaReceiptNumber", "Value": "NLJ7RT61SV" },
          { "Name": "TransactionDate", "Value": 20260822163045 },
          { "Name": "PhoneNumber", "Value": 254708374149 }
        ]
      }
    }
  }
}

CallbackMetadata.Item is only present when ResultCode === 0. A cancelled or failed prompt omits it entirely check for its existence before indexing into it. See the framework-specific handlers in Framework Integrations for copy-pasteable parsing.

Sandbox callbacks are unreliable

In sandbox, it's common for the callBackURL POST to arrive late, arrive twice, or not arrive at all during Safaricom's periodic sandbox instability windows this is a known, long-standing complaint in Daraja community channels, not a bug in your handler. Always build a query() fallback (poll on a timer, or expose a manual "check status" button) rather than assuming the callback is guaranteed delivery, even in production.

On this page