Daraja invalid access token: 401.003.01 is not 404.001.03
The STK push worked in sandbox all week. You go live on Monday, point everything at
api.safaricom.co.ke, and the first real payment comes back with
Invalid Access Token. So you do what everyone does: refresh the token. Same error. You
add token caching with a safety margin, deploy again, watch the logs. Same error. You start
questioning whether the OAuth call is even returning a real token, so you print it, and it looks
perfectly fine.
Here is what nobody tells you. Daraja has two different errors that both say "Invalid Access Token", they come from two different layers, and they mean close to opposite things. One is genuinely about the token. The other is about whether your app is allowed to call that API at all, and no amount of token refreshing will ever fix it. Treating them as the same error is why people lose a week.
read the errorCode, not the
errorMessage.
401.003.01is rejected at the authorisation layer. Your token is wrong for what you are calling, almost always because it was generated against one environment and used against the other. Regenerate with the Consumer Key and Secret that belong to the environment whose base URL you are hitting.404.001.03means the request got past the door and was refused by the product. Sometimes that is a genuinely expired token or a malformedAuthorizationheader. Very often, especially just after Go Live, it means your app or shortcode is not enabled for that specific API, and only Safaricom can fix it.
If refreshing the token twice has not fixed it, stop refreshing the token.
The exact errors
At the OAuth or authorisation layer:
{
"requestId": "5388-1229944-1",
"errorCode": "401.003.01",
"errorMessage": "Invalid Access Token"
}
And on a normal API call such as /mpesa/stkpush/v1/processrequest or
/mpesa/b2b/v1/paymentrequest:
{
"requestId": "11728-2929992-1",
"errorCode": "404.001.03",
"errorMessage": "Invalid Access Token"
}
Some responses append extra context after the message text. The part you can rely on is the
errorCode field, and it is the only part your error handling should branch on.
Why there are two, and what each one is telling you
Daraja sits behind an API gateway. A request passes through two checkpoints. The first asks "is this a token this gateway issued, for this environment?" The second asks "is the app behind this token subscribed to the product you are calling, and does that product accept this request?"
401.003.01 | 404.001.03 | |
|---|---|---|
| Where it fires | Authorisation layer | The API product you called |
| Most common cause | Token from the other environment | Expired token, or app not subscribed to that API |
| Does refreshing the token help? | Only if you also fix the credentials or base URL | Sometimes. If not, it never will |
| Who fixes it | You | You, or Safaricom API support |
| Typical timing | First deploy to production | Right after Go Live, or after an hour of uptime |
Fixing 401.003.01
Step 1: Confirm the base URL and the credentials come from the same environment
There are exactly two hosts, and each one only accepts tokens minted by itself:
sandbox https://sandbox.safaricom.co.ke
production https://api.safaricom.co.ke
A sandbox Consumer Key cannot produce a token that works against
api.safaricom.co.ke, and the reverse is equally true. In code, the base URL and the
credential pair must come from the same configuration object, never from two separate environment
variables that can drift apart. That is the single most common production incident in this whole
integration.
Step 2: Fetch a token by hand and look at it
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)"
Expected output:
{"access_token":"c9SQxWWhmdVRlyh0zh8gZDTkubVF","expires_in":"3599"}
Three things to check here. The endpoint is a GET, not a POST; sending
POST produces its own confusing errors. The Basic credential must be base64 of
key:secret with no line breaks, which is what -w 0 guarantees and what a
naive echo | base64 gets wrong. And expires_in comes back as a string, not
a number, which trips up strongly typed clients that then cache the token for zero seconds or
forever.
Step 3: Rule out truncation
printf '%s' "$MPESA_ACCESS_TOKEN" | wc -c
printf '%s' "$MPESA_ACCESS_TOKEN" | od -c | tail -3
Tokens get mangled by column widths in a database, by unquoted values in a .env file,
by a logging framework that truncates long strings, and by copy-paste out of a terminal that wrapped
the line. Log the length and the last four characters rather than the whole token, and compare them
against what the OAuth call returned.
Fixing 404.001.03
Step 1: Check the obvious token problems first, once
The header must be exactly Authorization: Bearer <token>: one space, capital B,
no quotes around the token. Tokens are valid for 3599 seconds, so cache with a margin
rather than to the second:
import time, base64, requests
_token = {"value": None, "expires_at": 0}
def get_token(base_url, key, secret):
if _token["value"] and time.time() < _token["expires_at"]:
return _token["value"]
basic = base64.b64encode(f"{key}:{secret}".encode()).decode()
r = requests.get(
f"{base_url}/oauth/v1/generate?grant_type=client_credentials",
headers={"Authorization": f"Basic {basic}"},
timeout=15,
)
r.raise_for_status()
data = r.json()
_token["value"] = data["access_token"]
# expires_in is a string; renew 120s early
_token["expires_at"] = time.time() + int(data["expires_in"]) - 120
return _token["value"]
Note the base URL is a parameter, not a module constant. That is deliberate, and it is what makes step 1 of the previous section impossible to get wrong.
Step 2: If a fresh token still fails, stop debugging the token
This is the whole point of the article. A token that was issued sixty seconds ago and is rejected
with 404.001.03 is not an expired token. What that usually means is that the app behind
the token is not subscribed to the API product you are calling, or the shortcode has not been enabled
for it. It is extremely common for a newly live app to have STK push working while B2B or B2C returns
404.001.03 forever, because those products were never switched on for the shortcode.
You can confirm the shape of it quickly: if the same token succeeds against one endpoint and fails
against another, the token is fine and the entitlement is not. Nothing in your code can fix that.
Email apisupport@safaricom.co.ke with your app name, the production Consumer Key, the
shortcode, the endpoint you are calling and a requestId from a failed response, and ask
for the product to be enabled on that shortcode.
Step 3: Confirm the app you are using is the live one
After Go Live you are issued production credentials for a specific app tied to a specific shortcode. Teams routinely keep using the sandbox app's key and secret, or a second production app created for testing that was never mapped to the shortcode. Confirm on the portal which app owns the key you are using, and which shortcode that app is attached to.
Verification
Do this once, by hand, before you trust the code path:
# 1. Get a token from production
TOKEN=$(curl -s "https://api.safaricom.co.ke/oauth/v1/generate?grant_type=client_credentials" \
-H "Authorization: Basic $(printf '%s' "$CONSUMER_KEY:$CONSUMER_SECRET" | base64 -w 0)" \
| python3 -c 'import sys,json; print(json.load(sys.stdin)["access_token"])')
echo "token length: ${#TOKEN}"
# 2. Use it immediately against the endpoint that was failing
curl -s -X POST "https://api.safaricom.co.ke/mpesa/stkpushquery/v1/query" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"BusinessShortCode":"123456","Password":"...","Timestamp":"...","CheckoutRequestID":"ws_CO_..."}'
Interpret the result like this. A business-level error such as an unknown
CheckoutRequestID counts as a success here: authentication and entitlement both
worked. 401.003.01 means you are still in the wrong environment. 404.001.03
on a token that is seconds old means the entitlement problem, and your next action is an email, not a
commit.
What people get wrong
Retrying the same request with a new token, in a loop. When the cause is
entitlement, this turns one clear failure into a burst of identical calls, and Daraja will start rate
limiting you. Then you get a 429 wrapped in a body your parser cannot read, which is
a different problem entirely. Cap retries
at one, and only retry on 404.001.03, never on 401.003.01.
Fetching a new token before every single request. It looks safe and it is a good way to get rate limited on the OAuth endpoint at peak. Cache the token in shared storage, not in a per-process variable, if you run more than one worker.
Matching on the message string. Both errors say "Invalid Access Token", so any
code that branches on the message treats them identically, which is the whole trap. Branch on
errorCode.
Assuming an error mentioning a token is about the token. Daraja reuses the authentication vocabulary for authorisation problems. Once you accept that, a fresh token failing immediately becomes informative rather than maddening.
When it is still broken
- Check what is between you and Safaricom. Some proxies and API gateways strip or
rewrite the
Authorizationheader. Log the outbound headers from the machine that actually makes the call, not from your laptop. - Check for regenerated credentials. If someone clicked the regenerate button on the portal, every previously issued token and every cached secret is dead. It looks exactly like this error.
- Check the endpoint path. A typo in the path can be answered by the gateway with a generic error rather than a clean 404, which sends you hunting for a credential problem that does not exist.
- Keep the
requestId. It is the only identifier Safaricom support can use to look up your failure on their side. Log it on every non-2xx response, along with the error code and the endpoint. Support tickets without one go nowhere.
Once tokens are behaving, the next two things that will bite you are credential encryption for payouts, covered in error 2001 and the stale certificate trap, and callbacks that arrive more than once, covered in idempotency and the 1037 problem.
Frequently asked questions
- What is the difference between Daraja errors 401.003.01 and 404.001.03?
- 401.003.01 is raised at the authorisation layer, meaning the token itself was rejected, usually because it was generated with sandbox credentials and used against api.safaricom.co.ke or the other way round. 404.001.03 is raised after the request has been authorised, by the API product you called, and can mean either a genuinely expired token or that your app and shortcode are not enabled for that particular API. Branch on the errorCode field, because both return the same errorMessage text.
- Why does M-Pesa keep saying invalid access token when my token is brand new?
- If a token issued seconds ago is rejected with 404.001.03, the token is not the problem. The usual cause is that your app or shortcode is not subscribed to the API product you are calling, which is common straight after Go Live when STK push works but B2B or B2C does not. Only Safaricom API support can enable the product for your shortcode, so email them with your app name, production Consumer Key, shortcode and a failing requestId.
- How long does an M-Pesa Daraja access token last?
- The OAuth response returns expires_in as 3599 seconds, just under one hour, and it comes back as a string rather than a number, which trips up typed clients. Cache the token and refresh it a minute or two before expiry rather than on every request, and store it somewhere shared if you run multiple workers, otherwise each process fetches its own and you risk rate limiting on the OAuth endpoint.
- Can I use a sandbox M-Pesa access token in production?
- No. Sandbox tokens are issued by sandbox.safaricom.co.ke and are only valid there, and production tokens from api.safaricom.co.ke are only valid against production. Mixing them produces error 401.003.01. Keep the base URL and the Consumer Key and Secret in the same configuration object so they cannot drift apart between environments.