</>CodeWithKarani

M-Pesa "Invalid BusinessShortCode": The Python requests Bug Behind It

Karani GeoffreyKarani Geoffrey7 min read

The same payload works in Postman. It works when a colleague sends it from Node. In your Python service it comes back with this, every single time:

{
  "requestId": "16813-1590513-1",
  "errorCode": "400.002.02",
  "errorMessage": "Bad Request - Invalid BusinessShortCode"
}

So you check the shortcode. You check it against the portal. You regenerate the passkey. You paste it into a fresh file with no variables at all. You start wondering whether Safaricom disabled your account.

The shortcode was never the problem. In the overwhelming majority of these cases the bug is one character of Python: data=payload where it should be json=payload. Daraja receives a form-encoded body, fails to find the fields it expects, and reports the first one it looks for. Read the error as "I could not parse a BusinessShortCode out of what you sent", not "your BusinessShortCode is wrong".

In Python's requests, passing a dictionary to data= serialises it as application/x-www-form-urlencoded. Daraja needs JSON.

requests.post(url, headers=headers, data=payload)   # wrong: form encoded
requests.post(url, headers=headers, json=payload)   # right: JSON + correct Content-Type

json= also sets Content-Type: application/json for you. The suffix after Bad Request - always names the field Daraja could not accept, so read the whole errorMessage rather than reacting to the error code alone.

The exact error, and its siblings

Error code 400.002.02 is a generic invalid-parameter response. The useful information is entirely in the suffix:

400.002.02: Bad Request - Invalid BusinessShortCode
400.002.02: Bad Request - Invalid Timestamp
400.002.02: Bad Request - Invalid Amount
400.002.02: Bad Request - Invalid CheckoutRequestID
400.002.02: Bad Request - Invalid PhoneNumber
SuffixWhat it actually means
Invalid BusinessShortCodeAlmost always the body was not parsed as JSON. Occasionally a genuine mismatch between the shortcode and the passkey used to build the password.
Invalid TimestampFormat must be YYYYMMDDHHmmss with no separators, no T, no timezone suffix. ISO 8601 is rejected.
Invalid AmountMust be a positive whole number. 100.00 and "100.50" both fail. Round before you send.
Invalid PhoneNumberMust be 2547XXXXXXXX or 2541XXXXXXXX. Not 07..., not +254....
Invalid CheckoutRequestIDUsually a query sent within a second or two of initiating the push, before the record has propagated. Wait and retry rather than treating it as fatal.

Why this happens

requests gives you three ways to put a body on a POST and they are not interchangeable:

requests.post(url, data={"BusinessShortCode": 174379})
# body: BusinessShortCode=174379
# Content-Type: application/x-www-form-urlencoded

requests.post(url, data=json.dumps({"BusinessShortCode": 174379}))
# body: {"BusinessShortCode": 174379}
# Content-Type: not set at all

requests.post(url, json={"BusinessShortCode": 174379})
# body: {"BusinessShortCode": 174379}
# Content-Type: application/json

Only the third is correct on its own. The second is what the Postman code generator produces, and it happens to work because Postman also emits an explicit Content-Type: application/json header. Developers then simplify the snippet, replace json.dumps(payload) with the dictionary itself, keep data=, and the request silently changes shape. That is the exact path most people take into this bug.

On Daraja's side, the API gateway parses the body according to the declared content type. Given form-encoded input where it expected JSON, it ends up with no usable fields, walks its validation list in order, and returns on the first required field it cannot resolve. BusinessShortCode is near the top of that list, so that is the name you see. It is a parse failure wearing a validation error's clothes.

This is not exclusively a Python problem, it is just that Python makes it easiest to get wrong. In PHP the equivalent mistake is CURLOPT_POSTFIELDS with an array, which curl encodes as multipart form data unless you hand it a JSON string and set the header. In Go it is writing a url.Values body out of habit. Node's axios and fetch with a stringified body tend to get it right by default, which is why the colleague testing from Node cannot reproduce your failure and starts doubting your credentials.

Same dictionary, two very different HTTP requests data=payload Content-Type: application/x-www-form-urlencoded Body: BusinessShortCode=174379&Amount=1 Daraja parses JSON, finds nothing, reports the first missing field. Bad Request - Invalid BusinessShortCode json=payload Content-Type: application/json Body: {"BusinessShortCode": 174379, ...} Fields resolve, validation runs on real values. ResponseCode: "0"
The error names a field, but the failure happened one layer earlier, at the body parser.

The fix

Step 1: Get an access token

The token endpoint is a GET with HTTP Basic auth, which trips people up because everything else in Daraja is a POST:

import base64, requests
from datetime import datetime
from requests.auth import HTTPBasicAuth

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

r = requests.get(
    f"{BASE}/oauth/v1/generate?grant_type=client_credentials",
    auth=HTTPBasicAuth(CONSUMER_KEY, CONSUMER_SECRET),
    timeout=30,
)
r.raise_for_status()
token = r.json()["access_token"]

Expected output: a JSON object with access_token and expires_in. Cache it, because it is valid for roughly an hour and requesting a new one on every payment is wasteful and slow on a Kenyan mobile link.

Step 2: Build the timestamp and password from the same instant

timestamp = datetime.now().strftime("%Y%m%d%H%M%S")
raw = f"{SHORTCODE}{PASSKEY}{timestamp}"
password = base64.b64encode(raw.encode()).decode()

The password is base64(shortcode + passkey + timestamp) with no separators. The timestamp you hash must be byte-identical to the one you send in the payload, which is why it goes into a variable rather than being computed twice. Generating it twice a second apart is a real bug I have seen in production code, and it surfaces as a confusing authentication failure rather than a timestamp error.

Step 3: Send the request with json=

payload = {
    "BusinessShortCode": SHORTCODE,
    "Password": password,
    "Timestamp": timestamp,
    "TransactionType": "CustomerPayBillOnline",
    "Amount": int(round(amount)),          # whole number, never a Decimal or float
    "PartyA": phone,                       # 2547XXXXXXXX
    "PartyB": SHORTCODE,
    "PhoneNumber": phone,
    "CallBackURL": "https://api.example.co.ke/mpesa/callback",
    "AccountReference": invoice_no[:12],
    "TransactionDesc": "Invoice payment",
}

resp = requests.post(
    f"{BASE}/mpesa/stkpush/v1/processrequest",
    headers={"Authorization": f"Bearer {token}"},
    json=payload,                          # not data=
    timeout=60,
)
print(resp.status_code, resp.text)

Expected output on success:

{
  "MerchantRequestID": "29115-34620561-1",
  "CheckoutRequestID": "ws_CO_191220191020363925",
  "ResponseCode": "0",
  "ResponseDescription": "Success. Request accepted for processing",
  "CustomerMessage": "Success. Request accepted for processing"
}

Note that ResponseCode: "0" means the push was accepted, not that the customer paid. The payment result arrives on your callback URL, and it will sometimes arrive twice, which is a separate problem I have written about in handling duplicate M-Pesa callbacks.

Step 4: Do not set Content-Type by hand

If you pass json= and also set "Content-Type": "application/json" in headers, you are fine but redundant. If you pass data= a dictionary and set that header, you have created the worst case: a header claiming JSON on a form-encoded body. Pick json= and let requests handle the header.

Verification

Do not guess what went on the wire. requests keeps the prepared request on the response object, so you can print exactly what was sent:

print(resp.request.headers.get("Content-Type"))
print(resp.request.body)
application/json
b'{"BusinessShortCode": 174379, "Password": "MTc0Mzc5...", "Timestamp": "20260724081203", ...}'

If the first line is application/x-www-form-urlencoded or None, you have found your bug without reading another line of Daraja documentation. If the body starts with b'BusinessShortCode= rather than b'{, same conclusion.

End-to-end, on sandbox: send a push to a test number with shortcode 174379, confirm you get ResponseCode: "0", then wait five seconds and query the status:

requests.post(f"{BASE}/mpesa/stkpushquery/v1/query",
              headers={"Authorization": f"Bearer {token}"},
              json={"BusinessShortCode": SHORTCODE, "Password": password,
                    "Timestamp": timestamp,
                    "CheckoutRequestID": checkout_request_id},
              timeout=60)

What people get wrong

Trusting the field name in the error. This is the whole trap. The message says BusinessShortCode, so hours go into verifying a shortcode that was correct from the first attempt. Daraja's error text names the field its validator stopped at, which on a parse failure is simply the first field in its list.

Regenerating credentials. New consumer key, new secret, new passkey, sometimes a whole new app in the portal. It changes nothing, and now you have two sets of credentials to keep straight and a production app that may be pointing at the old ones.

Casting the shortcode to a string, then to an int, then back. Daraja accepts either for BusinessShortCode. The type is not your problem. The encoding is.

Sending amounts as floats. "Amount": 100.0 serialises as 100.0 and gets rejected as an invalid amount. Cast to int and decide deliberately how you round, because in a real ledger that rounding is money and needs to match your invoice.

Treating an immediate Invalid CheckoutRequestID as failure. Query too fast and Daraja has not yet propagated the record. Back off a few seconds and retry before you mark a payment as failed, otherwise you will reject transactions the customer has already paid.

When it is still broken

  • 404.001.03 Invalid Access Token. Either the token expired, or you are mixing environments: a sandbox token against api.safaricom.co.ke, or production credentials against the sandbox host. Check the base URL and the credentials come from the same place.
  • The push succeeds but no callback arrives. The callback URL must be publicly reachable over HTTPS on port 443, with a valid certificate. It cannot be localhost, an IP address, or a self-signed host. Test with a tunnel during development and a real domain in production.
  • It works on sandbox and fails in production. Production has its own shortcode, passkey, consumer key and secret, and the transaction type differs between paybill and till number (CustomerPayBillOnline versus CustomerBuyGoodsOnline). Getting that wrong produces confusing validation errors. The full production setup is covered in my Daraja production guide.
  • Intermittent failures under load. Set explicit timeouts on every call, as in the snippets above. The default in requests is no timeout at all, and a hung Daraja request will hold a worker until something else kills it. On a link that occasionally drops, which describes most connections in this part of the world, an untimed call is how one slow payment takes out a whole pool of workers.
  • Every response looks like a 500 from the gateway. Error codes in the 500.001.* family are Daraja-side rather than payload problems. Log the full requestId from the response, because it is the only identifier Safaricom support can trace, and without it a ticket goes nowhere.

The broader lesson is worth carrying to every payment integration, not just this one: when an API tells you a specific field is invalid, verify what you actually put on the wire before you believe it. One print(resp.request.body) would have ended this in thirty seconds.

Frequently asked questions

Why does Daraja say Invalid BusinessShortCode when my shortcode is correct?
Because the error is usually a body parsing failure, not a validation failure. If you send the payload with data=payload in Python requests, it goes out form-encoded rather than as JSON, Daraja resolves none of the fields, and it reports the first required one on its list, which happens to be BusinessShortCode. Switching to json=payload fixes it.
What is the difference between data= and json= in Python requests for M-Pesa?
data= with a dictionary serialises to application/x-www-form-urlencoded and sets that Content-Type. json= serialises the dictionary to a JSON body and sets Content-Type: application/json, which is what the Daraja API expects. Passing data=json.dumps(payload) also works but only if you set the JSON Content-Type header yourself.
What timestamp format does the M-Pesa Daraja API require?
YYYYMMDDHHmmss with no separators, for example 20260724081203. ISO 8601 with dashes, a T separator or a timezone suffix is rejected as Invalid Timestamp. The same timestamp string must be used both in the payload and when building the base64 password from shortcode plus passkey plus timestamp.
Why does stkpushquery return Invalid CheckoutRequestID right after a successful push?
The record has not propagated yet. Querying within a second or two of initiating the STK push commonly returns that error even though the CheckoutRequestID is valid. Back off a few seconds and retry rather than marking the payment as failed, or you will reject transactions the customer has already paid.
#M-Pesa#Daraja#Python#requests#STK Push
Keep reading

Related articles