M-Pesa Daraja 500.001.1001 when your consumer key is actually correct
The STK push worked all afternoon. Then one request in maybe forty came back with "Wrong credentials", and by evening a client was asking why some customers see the M-Pesa prompt and some do not. You checked the consumer key. You regenerated it. You regenerated the secret too. It kept happening, at random, on the same code path with the same credentials.
Here is the thesis, and it is the reason this error wastes so many evenings: 500.001.1001 almost never means your consumer key is wrong. If your consumer key or secret were wrong you would not have an access token, and without an access token you would not reach the STK endpoint at all. You would get a token error, at a different step, with a different code.
What that error usually means is that the Timestamp you used to build the Password is not the Timestamp you sent in the body. Two lines of code, both correct in isolation, calling the clock twice.
generate the timestamp once, into a variable, and use that same variable in both places:
- Password = base64 of
BusinessShortCode + Passkey + Timestampconcatenated as one plain string, no separators, no hashing. - The
Timestampfield in the request body must be that identical string. - Format is exactly
YYYYMMDDHHmmss, 14 digits, in East Africa Time, not UTC.
If the error text says Merchant does not exist rather than Wrong credentials, it is the other cause: the shortcode is not activated for the environment you are calling, or you are mixing sandbox and production credentials.
The exact error
Daraja returns HTTP 500 with a JSON body along these lines:
{
"requestId": "43169-3253970-1",
"errorCode": "500.001.1001",
"errorMessage": "[MerchantValidate] - Wrong credentials"
}
Or, for the same numeric code:
{
"requestId": "16942-1904930-1",
"errorCode": "500.001.1001",
"errorMessage": "Merchant does not exist"
}
One code, two entirely unrelated root causes. This is the single most important thing to know about it, and it is why generic advice fails: half the internet is answering the wrong one of the two. Log the full errorMessage string, not just errorCode. If your integration only records the code, you have thrown away the one piece of information that tells you which problem you have.
| errorMessage | What it is telling you | Where to look |
|---|---|---|
[MerchantValidate] - Wrong credentials | The Password did not validate against the shortcode and passkey for the timestamp you sent | Timestamp consistency, passkey, base64 construction |
Merchant does not exist | The shortcode itself is not recognised on this environment | Shortcode activation, sandbox vs production mixing |
Why this happens: the two-clock bug
Safaricom's STK push authenticates each request twice. The bearer token proves your app is who it says it is. The Password proves this specific request is for this specific shortcode at this specific moment. That second check is a hash-free construction:
Password = base64( BusinessShortCode + Passkey + Timestamp )
Safaricom decodes your Password, splits it using the shortcode and passkey it holds on file, and compares the remaining 14 characters against the Timestamp field in your body. If they differ by even one second, the comparison fails and you get "Wrong credentials". It is not lying to you. From the API's point of view, the credential really is wrong: it is a credential for a moment in time that is not the moment you claimed.
Now look at the code almost everyone writes first:
# WRONG - calls the clock twice
def timestamp():
return datetime.now().strftime("%Y%m%d%H%M%S")
password = base64.b64encode(
f"{shortcode}{passkey}{timestamp()}".encode()
).decode()
payload = {
"BusinessShortCode": shortcode,
"Password": password,
"Timestamp": timestamp(), # <-- second call, different second
"TransactionType": "CustomerPayBillOnline",
...
}
Every line is defensible. The helper is clean, the format is right, the concatenation order is right. And it works, correctly, most of the time. It only fails when the two calls land either side of a second boundary. On a fast machine that might be one request in a hundred. On a loaded server, in a request that does a database write between those two lines, it might be one in ten.
Intermittent is the signature. If you are getting "Wrong credentials" on some requests and not others with unchanged credentials, you are looking at this bug and nothing else. Rotating API keys cannot fix a race against the system clock.
The other ways the timestamp goes wrong
- UTC instead of EAT. In JavaScript,
new Date().toISOString().replace(/\D/g, "").slice(0, 14)is a popular one-liner and it produces a UTC timestamp. Your VPS is almost certainly on UTC. Safaricom's platform works in East Africa Time. Three hours of drift is not a boundary race, it is a permanent failure, which at least has the mercy of being consistent. %mwhere%Mbelongs. Instrftime,%mis month and%Mis minute. A format string of%Y%m%d%H%m%Srenders the month twice and looks completely plausible at a glance. I have found this one in production code more than once.- ISO 8601 left in.
2026-07-24T15:30:45Zis not20260724153045. No dashes, no colons, no T, no Z, no milliseconds, no timezone offset. Fourteen digits, nothing else. - 12-hour clock.
%Iinstead of%Hsilently works for twelve hours a day.
The fix, step by step
Step 1: Generate the timestamp exactly once
from datetime import datetime
from zoneinfo import ZoneInfo
import base64
ts = datetime.now(ZoneInfo("Africa/Nairobi")).strftime("%Y%m%d%H%M%S")
password = base64.b64encode(
f"{shortcode}{passkey}{ts}".encode("utf-8")
).decode("utf-8")
payload = {
"BusinessShortCode": shortcode,
"Password": password,
"Timestamp": ts,
"TransactionType": "CustomerPayBillOnline",
"Amount": amount,
"PartyA": msisdn,
"PartyB": shortcode,
"PhoneNumber": msisdn,
"CallBackURL": callback_url,
"AccountReference": reference,
"TransactionDesc": description,
}
The equivalent in Node, using an explicit offset rather than a library, so there is nothing to get wrong:
const eat = new Date(Date.now() + 3 * 60 * 60 * 1000);
const ts = eat.toISOString().replace(/\D/g, "").slice(0, 14);
const password = Buffer
.from(`${shortcode}${passkey}${ts}`)
.toString("base64");
Then build the payload with that same ts constant. Better still, return both values from one function so it is structurally impossible to use them separately:
def stk_credentials(shortcode: str, passkey: str) -> tuple[str, str]:
ts = datetime.now(ZoneInfo("Africa/Nairobi")).strftime("%Y%m%d%H%M%S")
pw = base64.b64encode(f"{shortcode}{passkey}{ts}".encode()).decode()
return pw, ts
That is the real fix. Not "be careful", but a shape where carelessness cannot produce the bug. The same instinct applies across a payments integration generally, which I go into in building a payment provider abstraction layer.
Step 2: Decode your own password and look at it
Before you send anything, prove the two values agree:
echo -n "$PASSWORD" | base64 -d; echo
Expected output, for sandbox shortcode 174379:
174379bfb279f9aa9bdbcf158e97dd71a467cd2e0c893059b10f78e6b72ada1ed2c91920260724153045
Read it as three parts: the shortcode at the front, the passkey in the middle, and the last 14 characters are the timestamp. Compare those last 14 characters to the Timestamp field in your request body character by character. If they differ, you have found your bug and you can stop reading. If they match, move to step 3.
Step 3: Confirm the four credentials come from one Daraja app
Consumer key, consumer secret, shortcode and passkey are a set. They belong to a single app in the Daraja portal, in a single environment. It is remarkably easy to end up with a production consumer key, a sandbox passkey, and a shortcode from a third place, especially when credentials arrive by WhatsApp from three different people over two weeks.
grep -n "SHORTCODE\|PASSKEY\|CONSUMER" .env
Check the base URL matches too. Sandbox credentials against api.safaricom.co.ke, or production credentials against sandbox.safaricom.co.ke, produce exactly this error and no clearer signal.
Step 4: If the message says "Merchant does not exist"
Different problem entirely. The platform does not recognise the shortcode on this environment. Work through:
- Is the shortcode actually activated for Lipa Na M-Pesa Online, and not just a paybill that accepts manual payments? Go-live approval for STK push is a separate step from having a paybill.
- Are you using the sandbox test shortcode in production, or your real shortcode in sandbox? Sandbox only knows sandbox shortcodes.
- For Buy Goods (till numbers),
TransactionTypeisCustomerBuyGoodsOnlineandPartyBis the till number whileBusinessShortCodeis the head office store number. Critically, the shortcode you use to build the Password must be the same one you send asBusinessShortCode. Mixing the till number into the Password and the store number into the body fails with this code.
If all three check out, this is a Safaricom-side account state issue and no code change will fix it. That is a support ticket with your request ID, not a debugging session.
Verification
- Run 50 requests in a tight loop against sandbox and confirm zero failures. The bug is intermittent, so a single successful request proves nothing. This is the only test that actually distinguishes "fixed" from "got lucky".
Expected output: nothing at all. Anyfor i in $(seq 1 50); do curl -s -X POST "$BASE/mpesa/stkpush/v1/processrequest" \ -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -d @payload.json | grep -o '"errorCode":"[^"]*"' doneerrorCodeline means it is still broken. - Confirm a successful response shape. A working STK push returns
ResponseCodeof"0"along with aCheckoutRequestIDand aMerchantRequestID. Store theCheckoutRequestID: it is how you correlate the callback that arrives later. - Check the phone rings. A 200 from the STK endpoint means Safaricom accepted your request, not that the customer paid. The actual outcome arrives on your callback URL, and that is a completely separate set of failure modes, covered in M-Pesa callback failures and idempotency.
What people get wrong
"Regenerate your consumer key and secret." This is the top answer everywhere and it is answering a different error. If your key and secret were wrong, the OAuth call would have failed and you would never have got a token. Getting past OAuth and failing at STK push is proof the key and secret are fine.
"Hash the password." There is no hashing anywhere in this. It is base64 of a plain concatenated string. Base64 is an encoding, not encryption, and reversing it is exactly how step 2 works. Anyone telling you to SHA-256 the concatenation is confusing this with the security credential used by B2C, which is a different thing entirely and does involve encryption with Safaricom's public certificate.
"Add separators for readability." No colons, no pipes, no dashes between shortcode, passkey and timestamp. It is one unbroken string.
"Just retry on 500." Blind retries hide the boundary race behind a success rate that looks acceptable, until the day traffic doubles and the failure rate doubles with it. Worse, retrying a payment request without an idempotency strategy is how customers get charged twice. Fix the timestamp; retry only for genuine network faults.
When it is still broken
- Print the exact bytes you send. Log the serialised request body immediately before the HTTP call, not the dictionary before serialisation. Interceptors, middleware and serialisers have all been known to reformat a field on the way out.
- Check the server clock.
timedatectlshould show NTP synchronised. A VPS drifting by minutes will fail permanently and consistently, and you will chase the code instead of the host. - Test with the published sandbox values. Shortcode 174379 with the sandbox passkey from the Daraja portal, against the sandbox base URL. If that combination works and yours does not, the problem is your credentials or your shortcode's activation state, not your code.
- Keep the request ID. Every error response includes a
requestId. Safaricom support can trace it. An email saying "STK push fails sometimes" gets nowhere; an email with three request IDs and timestamps gets an answer.
If you are wiring up STK push from scratch rather than debugging an existing integration, the ordering of shortcode, passkey and timestamp, and the callback contract that follows, are covered end to end in the Daraja production guide. Get the credential construction right once, put it behind a single function, and this error never comes back.
Frequently asked questions
- What does M-Pesa Daraja error 500.001.1001 actually mean?
- It means the Password you sent did not validate for the shortcode and timestamp in your request, or the shortcode is not recognised on that environment. It does not mean your consumer key or secret is wrong, because a bad key or secret would have failed at the OAuth token step before you ever reached the STK push endpoint. Read the errorMessage text: 'Wrong credentials' points at the Password construction, while 'Merchant does not exist' points at shortcode activation.
- Why does my M-Pesa STK push fail only sometimes with the same credentials?
- Because you are almost certainly generating the timestamp twice, once when building the Password and once when building the request body. Most of the time both calls land in the same second and the request succeeds; occasionally they straddle a second boundary and the two values differ, so Safaricom rejects the Password. Generate the timestamp once into a variable and reuse that exact variable in both places.
- How do I build the M-Pesa STK push Password correctly?
- Concatenate your BusinessShortCode, your Passkey and the Timestamp into one plain string with no separators, then base64 encode it. There is no hashing involved. The Timestamp must be exactly 14 digits in YYYYMMDDHHmmss format in East Africa Time, and the identical string must also be sent as the Timestamp field in the request body.
- Does the M-Pesa timestamp need to be in EAT rather than UTC?
- Yes. Safaricom's platform works in East Africa Time, UTC+3, and most servers default to UTC. A UTC timestamp is three hours out and will fail consistently rather than intermittently. In Python use ZoneInfo('Africa/Nairobi'); in JavaScript, add three hours to the epoch before formatting, since toISOString always returns UTC.