M-Pesa Invalid Access Token After Go-Live: It Is Not Your OAuth Code
Sandbox worked for three weeks. STK push, callbacks, the lot. Go-live paperwork went in, the approval email came back, you swapped the credentials, deployed, and the very first production request comes back with this:
{
"requestId": "16813-1590513-1",
"errorCode": "404.001.03",
"errorMessage": "Invalid Access Token"
}
So you do what any reasonable engineer does. You rewrite the OAuth call. You check the base64 encoding. You add a token cache, then remove the token cache. You try a fresh token on every single request. You read the same four Stack Overflow answers again. Two days go by.
The message is lying to you, and it is lying in a specific, predictable way. Daraja returns 404.001.03 Invalid Access Token in two completely different situations: when the token really is bad, and when the token is perfectly valid but your shortcode is not authorised for the product you are calling. There is no separate error code for the second case. Nearly every "invalid access token after go-live" report I have seen or been sent turns out to be the second one, and no amount of rewriting your OAuth client will ever fix it.
before you touch your code, find out which of the two cases you are in.
- Run one
curlagainsthttps://api.safaricom.co.ke/oauth/v1/generate?grant_type=client_credentialswith your production key and secret. If you get anaccess_tokenback, your credentials and your OAuth code are correct and the problem is not authentication. - In that case the shortcode is not enabled for the API you are calling. B2C and B2B are not switched on automatically when go-live completes; they need manual whitelisting by Safaricom.
- Check all four values actually changed: consumer key, consumer secret, passkey, and base URL (
api.safaricom.co.ke, notsandbox.safaricom.co.ke). - For B2C, B2B and balance queries, the security credential must be re-encrypted with the production certificate. The sandbox one produces a token-shaped string that always fails.
- Email
apisupport@safaricom.co.keearly, with your Daraja app ID, shortcode and arequestIdfrom a failed call. Escalating on day one is not impatience, it is the fix.
The exact error, and the split that tells you everything
The response above is what Daraja returns from a business endpoint. It is not what an OAuth failure looks like. Getting a token is a completely separate HTTP call to a completely separate host path, and it fails differently. So the first thing to do is call it directly and see which side of the line you are on:
CONSUMER_KEY='your_production_key'
CONSUMER_SECRET='your_production_secret'
curl -s -X GET \
'https://api.safaricom.co.ke/oauth/v1/generate?grant_type=client_credentials' \
-H "Authorization: Basic $(printf '%s' "$CONSUMER_KEY:$CONSUMER_SECRET" | base64 -w 0)"
A healthy response looks like this:
{
"access_token": "SGWcJPtNtYNPGm6uSYR9yPYrAI3Bm",
"expires_in": "3599"
}
If you get that, stop debugging your OAuth implementation. Right now. Your consumer key is valid, your secret is valid, your base64 is fine, your environment is correct. Every subsequent 404.001.03 on an STK push, a B2C payment or a balance query is an authorisation problem on the shortcode, not an authentication problem with your credentials.
Why this happens
Go-live does not enable every product
This is the big one. Completing the go-live process on the Daraja portal moves your app into production and issues production credentials. It does not, on its own, enable every API against your shortcode. STK push and C2B commonly work straight away. B2C and B2B frequently do not, because they have to be manually whitelisted against that shortcode by Safaricom, and that step happens on a human timeline that has nothing to do with your deployment.
The symptom is exactly what you would expect once you know: token works, STK push works, B2C returns 404.001.03. The documented cases I have read follow the same arc, including one where the developer changed nothing at all and the same code started working the moment the shortcode was whitelisted.
Four values change at go-live, and it is easy to move only three
| Value | Sandbox | Production |
|---|---|---|
| Base URL | https://sandbox.safaricom.co.ke | https://api.safaricom.co.ke |
| Consumer key / secret | Sandbox app | New production app, different values |
| Passkey | Public test passkey | Issued to you, per shortcode |
| Shortcode | Test shortcode | Your real paybill or till |
| Security credential | Encrypted with sandbox certificate | Re-encrypted with production certificate |
The one people miss is the last row. The security credential is your initiator password encrypted with Safaricom's public certificate, and there is a different certificate for sandbox and production. Encrypting with the wrong one produces a long base64 string that looks completely correct and is rejected every time. It is not a token, so it does not produce a token error, and Daraja's error responses in this area are not precise enough to say so.
Mixing environments inside one request
Tokens are scoped to the environment that issued them. If your token helper still has sandbox.safaricom.co.ke hardcoded while your STK push helper uses the production host, you will get a valid token and a genuinely invalid one at the same time. This is the case where the error message is actually telling the truth, and it is the reason to have exactly one base URL variable in the entire codebase.
# .env - one variable, used by every Daraja call
MPESA_BASE_URL=https://api.safaricom.co.ke
If you find yourself with MPESA_AUTH_URL and MPESA_API_URL as separate settings, that is the bug, and it will come back.
A newline in your base64 header
Worth thirty seconds of checking because it costs so much when it happens. On Linux, base64 wraps output at 76 characters by default, and a wrapped Authorization header is a broken Authorization header. Use base64 -w 0, or on macOS base64 without wrapping. In application code, use the language's own base64 encoder rather than shelling out. This one does produce a genuine token failure, which at least is honest.
Token lifetime
The token expires in 3599 seconds. Cache it, but cache it with a safety margin, something like 3300 seconds, and never cache it across processes without also handling a mid-flight expiry. If your errors are intermittent rather than constant, this is a much more likely explanation than shortcode whitelisting. Constant failure from the first request onwards is not a token lifetime problem.
The fix, step by step
Step 1: Get a production token by hand
Run the curl from earlier. Expect an access_token and "expires_in": "3599". If you do not get one, your key or secret is wrong, or you are still hitting the sandbox host, or the production app on the portal is not the app whose credentials you copied. Fix that before anything else.
Step 2: Use that exact token against the business endpoint
TOKEN='paste_the_token_from_step_1'
curl -s -X POST \
'https://api.safaricom.co.ke/mpesa/accountbalance/v1/query' \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{ ... your payload ... }'
You have now removed your application entirely from the picture. If this fails with 404.001.03 while step 1 succeeded, no change to your code can fix it. Write that sentence down, because it is the thing that stops the two-day rewrite.
Step 3: Regenerate the security credential with the production certificate
Download the production public certificate from the Daraja portal and re-encrypt your initiator password with it. Applies to B2C, B2B, reversal, transaction status and account balance. Do not reuse the string you generated during sandbox testing, and do not copy one from a tutorial.
Step 4: Confirm which products are enabled on the shortcode
Test them in order of how likely they are to be enabled: STK push first, then C2B register and simulate, then B2C, then B2B. The pattern of what works and what does not is your evidence. "STK push returns a valid CheckoutRequestID, B2C returns 404.001.03 with the same token" is a sentence that gets a support ticket resolved. "M-Pesa is not working" is not.
Step 5: Escalate, with evidence
Email apisupport@safaricom.co.ke. Use a subject line that names the error, for example Invalid Access Token error after going live, and include:
- Your Daraja app ID and organisation name
- The production shortcode
- Which APIs work and which return the error
- At least one
requestIdfrom a failed response, with a timestamp - The exact endpoint URL you are calling
The requestId is the field support can actually trace, so never throw it away in your error handling. Log the full Daraja error body, not just the message. If you are in Nairobi and it is urgent, follow the email with a call; whitelisting has run anywhere from a few hours to a couple of days in the cases I know of.
Verification
# 1. Token, from production, on demand
curl -s -X GET \
'https://api.safaricom.co.ke/oauth/v1/generate?grant_type=client_credentials' \
-H "Authorization: Basic $(printf '%s' "$KEY:$SECRET" | base64 -w 0)"
# expect: {"access_token":"...","expires_in":"3599"}
# 2. A real business call with that token
# expect on success: "ResponseCode": "0" and a ConversationID / CheckoutRequestID
A ResponseCode of 0 means Safaricom accepted the request. It does not mean the money moved. That answer arrives asynchronously on your callback URL, which will fire more than once, out of order, and sometimes for transactions you have already processed. If you have not built for that yet, read up on why your M-Pesa callback will fire twice before you go anywhere near real customer money.
What people get wrong
"Rewrite the OAuth flow." This is the default reaction and it is almost always wasted work. The token endpoint either returns a token or it does not, and it is a four-line HTTP call. If step 1 succeeds, the OAuth flow is correct by definition. Adding a retry wrapper, a different HTTP library or a hand-rolled base64 helper changes nothing except the number of places the bug could hide.
"Fetch a fresh token on every request to be safe." Now every payment depends on two upstream calls instead of one, you have doubled your failure surface, and you are hammering an endpoint that will happily rate limit you. Cache with a margin. If token freshness were the problem, the failures would be intermittent, not total.
"Retry the failed call in a loop." An authorisation failure is not transient. Retrying it produces a tidy log full of identical errors and, if you get the retry logic slightly wrong on a payment API, duplicate disbursements the day it starts working. Never retry a 404.001.03.
"The go-live email says approved, so everything is enabled." The approval covers the app moving to production. It says nothing about which products are live on the shortcode. Treat the email as the start of the process rather than the end of it, and test each API explicitly on day one instead of discovering it during your first real payout run.
When it is still broken
- Confirm you copied credentials from the production app, not the sandbox one. The Daraja portal shows both, they look identical, and the production app is a separate entry created by the go-live process.
- Check the shortcode type matches the API. Paybill and till number behave differently across C2B and STK push, and a mismatch produces errors that have nothing to do with what the message says.
- Check whether your organisation has more than one shortcode. If the app was whitelisted against a different one, everything looks approved on the portal and every call fails.
- Log and keep every
requestId. Without it, support cannot trace anything, and you will be asked to reproduce the failure while your client watches.
The wider lesson, and the reason I now build every integration this way: when a payment provider gives you an ambiguous error, your first move is to reduce the request to a single curl that proves or disproves one thing. Everything else is guessing with your client's money on the line. It is also a strong argument for keeping provider-specific quirks behind a payments port rather than a Daraja wrapper, so that when the answer really is "wait for Safaricom", the rest of your system does not care.
Frequently asked questions
- What does errorCode 404.001.03 Invalid Access Token actually mean on the M-Pesa Daraja API?
- It means Daraja rejected the request's authorisation, but it is returned in two different situations: a genuinely bad or expired token, and a valid token whose shortcode is not enabled for the API being called. There is no separate error code for the second case. Request a token directly from https://api.safaricom.co.ke/oauth/v1/generate first; if that returns an access_token, your credentials are fine and the problem is shortcode authorisation.
- Why does STK push work in production but B2C returns Invalid Access Token?
- Because completing go-live moves your app to production but does not automatically enable every product against your shortcode. B2C and B2B typically require manual whitelisting by Safaricom, which happens separately from the portal approval. The same token that works for STK push will keep failing on B2C until that whitelisting is done, and no code change will alter that.
- Do I need to regenerate the M-Pesa security credential when moving to production?
- Yes. The security credential is your initiator password encrypted with Safaricom's public certificate, and sandbox and production use different certificates. A credential encrypted with the sandbox certificate produces a valid-looking base64 string that is always rejected in production. Download the production certificate from the Daraja portal and re-encrypt before testing B2C, B2B, reversal, transaction status or account balance.
- How long does Safaricom take to whitelist a shortcode for B2C or B2B?
- In the cases I have seen it has ranged from a few hours to a couple of days after you raise it. Email apisupport@safaricom.co.ke with your Daraja app ID, the shortcode, which APIs work versus fail, and a requestId from a failed response, then follow up by phone if it is blocking a launch. Escalating on day one is the correct move, not impatience.