daraja-stk-push · diff
git:20261019.15b8109 to git:20261019.9e3c915
59 added, 75 removed. Audit A to A.
---
name: daraja-stk-push
- description: Implement Lipa na M-Pesa Online (M-Pesa Express / STK Push) and the STK Push Query with Safaricom Daraja — building the Password and Timestamp, sending the payment prompt to a customer's phone, handling the callback and polling status. Use for checkout flows where the business initiates a payment the customer approves with their PIN.
+ description: Implements Safaricom M-Pesa Express (Lipa na M-Pesa Online / STK Push) and STK Push Query. Covers building the Password and EAT Timestamp, sending the PIN prompt to a customer's phone, handling the stkCallback, polling status, and acting on ResultCodes such as 1032, 1037 and 2001. Use for checkout or "pay now" flows where the business starts a payment the customer approves with their M-Pesa PIN.
---
- # STK Push (Lipa na M-Pesa Online / M-Pesa Express)
+ # M-Pesa Express (STK Push)
## Flow
- 1. Your backend → `POST /mpesa/stkpush/v1/processrequest` (bearer token from `daraja-auth`).
- 2. Synchronous reply: `ResponseCode "0"` + `MerchantRequestID` + `CheckoutRequestID` → store as `PENDING`.
- 3. Customer's phone shows the prompt; they enter PIN, cancel, or ignore it (~60s timeout).
- 4. Daraja POSTs the result to `CallBackURL`. `ResultCode 0` = paid.
- 5. If no callback arrives, call **STK Query** with the `CheckoutRequestID`.
-
- ## Password & Timestamp
-
```
- Timestamp = YYYYMMDDHHmmss (EAT, UTC+3)
- Password = base64( BusinessShortCode + Passkey + Timestamp )
+ your backend --POST /mpesa/stkpush/v1/processrequest--> Daraja
+ <-- ResponseCode "0", MerchantRequestID, CheckoutRequestID (save as PENDING)
+ customer phone: PIN prompt (~60 s) → enter PIN | cancel | ignore
+ Daraja --POST stkCallback--> your CallBackURL (ResultCode 0 = paid)
+ no callback? --POST /mpesa/stkpushquery/v1/query--> final ResultCode
```
- The **same Timestamp** must go in the request body. Sandbox shortcode `174379` has a public test passkey shown on the Daraja simulator page.
- ## Request
+ ## Build the request
+ 1. Get the Timestamp and Password: `python scripts/stk_password.py <shortcode> <passkey>` prints both. Put **both** in the body.
+ The formula is `Password = base64(BusinessShortCode + Passkey + Timestamp)`, with the Timestamp in EAT.
+ 2. Fill in the body:
+
```json
{
"BusinessShortCode": "174379",
- "Password": "<base64>",
- "Timestamp": "20261019153045",
+ "Password": "<from script>",
+ "Timestamp": "<from script>",
"TransactionType": "CustomerPayBillOnline",
"Amount": 1,
"PartyA": "254712345678",
"PartyB": "174379",
"PhoneNumber": "254712345678",
- "CallBackURL": "https://example.com/payments/stk/callback",
- "AccountReference": "INV-1042",
+ "CallBackURL": "https://example.com/payments/stk/<secret-token>",
+ "AccountReference": "INV1042",
"TransactionDesc": "Order 1042"
}
```
- - `TransactionType`: `CustomerPayBillOnline` (Paybill) or `CustomerBuyGoodsOnline` (Till).
- - For Till: `BusinessShortCode` = the **store/head-office number** used for the passkey, `PartyB` = the **Till number**.
- - `AccountReference` ≤ 12 chars, `TransactionDesc` ≤ 13 chars (keep them short).
-
- ## Python
+ 3. Validate it, fix anything reported, and repeat until it prints `OK`: `python ../mpesa-daraja/scripts/validate_request.py stk body.json`
- ```python
- import base64
- from datetime import datetime, timezone, timedelta
+ **Paybill vs Till**
+ - Paybill: `CustomerPayBillOnline`, with `PartyB` = the Paybill.
+ - Till: `CustomerBuyGoodsOnline`, with `BusinessShortCode` = the **store/head-office number** (the one the passkey belongs to) and `PartyB` = the **Till number**. Getting this wrong gives `2028`.
- EAT = timezone(timedelta(hours=3))
+ **Limits**: `AccountReference` ≤ 12 characters and `TransactionDesc` ≤ 13. A long prompt fails with `1025`.
- def stk_push(phone: str, amount: int, ref: str, callback_url: str) -> dict:
- ts = datetime.now(EAT).strftime("%Y%m%d%H%M%S")
- pwd = base64.b64encode(f"{SHORTCODE}{PASSKEY}{ts}".encode()).decode()
- resp = daraja_post("/mpesa/stkpush/v1/processrequest", {
- "BusinessShortCode": SHORTCODE, "Password": pwd, "Timestamp": ts,
- "TransactionType": "CustomerPayBillOnline", "Amount": int(amount),
- "PartyA": phone, "PartyB": SHORTCODE, "PhoneNumber": phone,
- "CallBackURL": callback_url, "AccountReference": ref[:12],
- "TransactionDesc": "Payment",
- })
- if resp.get("ResponseCode") == "0":
- save_pending(resp["MerchantRequestID"], resp["CheckoutRequestID"], phone, amount, ref)
- return resp
- ```
+ ## Handle the result
- ## Callback payload
+ Callback success example: `Body.stkCallback.ResultCode == 0`, with `CallbackMetadata.Item[]` containing `Amount`, `MpesaReceiptNumber`, `TransactionDate` and `PhoneNumber`. A failed callback has **no** `CallbackMetadata`. Full payloads are in [reference.md](reference.md).
- Success:
- ```json
- {"Body":{"stkCallback":{
- "MerchantRequestID":"29115-34620561-1","CheckoutRequestID":"ws_CO_191220191020363925",
- "ResultCode":0,"ResultDesc":"The service request is processed successfully.",
- "CallbackMetadata":{"Item":[
- {"Name":"Amount","Value":1.00},
- {"Name":"MpesaReceiptNumber","Value":"NLJ7RT61SV"},
- {"Name":"TransactionDate","Value":20191219102115},
- {"Name":"PhoneNumber","Value":254708374149}]}}}}
+ ```python
+ def on_stk_callback(payload):
+ cb = payload["Body"]["stkCallback"]
+ items = {i["Name"]: i.get("Value") for i in cb.get("CallbackMetadata", {}).get("Item", [])}
+ txn = repo.get_by_checkout_id(cb["CheckoutRequestID"]) # locked row
+ if txn is None or txn.status != "PENDING":
+ return # duplicate or unknown → idempotent no-op
+ if cb["ResultCode"] == 0 and int(items["Amount"]) == txn.amount:
+ txn.mark_paid(items["MpesaReceiptNumber"])
+ else:
+ txn.mark_failed(cb["ResultCode"], cb["ResultDesc"])
```
- Failure has no `CallbackMetadata` — just `ResultCode`/`ResultDesc`. Parse `Item` as a name→value map (`Balance` may appear; items can be missing).
- ## STK Query
-
- `POST /mpesa/stkpushquery/v1/query`
- ```json
- {"BusinessShortCode":"174379","Password":"<base64>","Timestamp":"20261019153500","CheckoutRequestID":"ws_CO_..."}
- ```
- Returns `ResultCode`/`ResultDesc` once the transaction completes; an error like `500.001.1001` "The transaction is being processed" means try again later. Poll with backoff (e.g. 20s, 40s, 80s) — don't hammer it.
+ | ResultCode | Meaning | What to do |
+ |---|---|---|
+ | 0 | Paid | Verify the amount, then fulfil |
+ | 1 | Insufficient balance | Fail; offer a retry |
+ | 1032 | Cancelled by user | Fail; offer a retry |
+ | 1037 | Phone unreachable / no response | Fail; ask the user to unlock their phone and retry |
+ | 2001 | Wrong PIN | Fail; offer a retry |
+ | 1001 / `500.001.1001` "Unable to lock subscriber" | Another prompt is in progress | Wait about 1 minute, then retry |
+ | 1019 | Expired | Fail |
+ | 1025 / 9999 | Push failed | Shorten the AccountReference, then retry once |
+ | 2028 | TransactionType or PartyB not permitted | Fix the Paybill/Till configuration |
- ## Result codes you'll see
+ ## STK Query (when no callback arrives)
- | ResultCode | Meaning | Action |
- |---|---|---|
- | 0 | Success | Mark paid (verify amount & phone match) |
- | 1 | Insufficient balance | Fail; let user retry |
- | 1032 | Cancelled by user | Fail; allow retry |
- | 1037 | Timeout / phone unreachable | Fail or retry later |
- | 2001 | Wrong PIN | Fail; allow retry |
- | 1025 / 9999 | Error sending push | Retry once later |
- | 1001 | Customer has another transaction in progress | Wait and retry |
+ `POST /mpesa/stkpushquery/v1/query` with `BusinessShortCode`, `Password`, `Timestamp` (freshly generated) and `CheckoutRequestID`.
+ - An error saying "The transaction is being processed" means the result isn't final yet: poll again with backoff (20 s, 40 s, 80 s), up to about 5 minutes.
+ - Once final, the response carries the same `ResultCode` meanings as the callback.
## Pitfalls
- - Treating `ResponseCode 0` as payment success.
- - Using UTC instead of EAT for the timestamp → "Invalid Timestamp"/wrong password.
- - Phone in `07...` or `+254...` format → "Invalid PhoneNumber".
- - Double-clicked "Pay" buttons sending multiple prompts — debounce and dedupe per order.
- - Not verifying callback `Amount` equals the order amount.
+ - Fulfilling the order on `ResponseCode "0"`.
+ - Using UTC timestamps, or a Timestamp in the body that differs from the one used in the Password. Both give "Wrong credentials".
+ - Double-clicked pay buttons: allow at most one PENDING STK per order.
+ - Mixing sandbox and production passkeys.
+
+ ## MCP
+
+ - `mpesa:mpesa_stk_push(phone, amount, account_reference)` normalises the phone, builds the Password and validates lengths.
+ - `mpesa:mpesa_stk_query(checkout_request_id)` checks status.
+ - `mpesa:mpesa_parse_callback(payload)` flattens a callback.