</>CodeWithKarani

Daraja invalid access token: 401.003.01 is not 404.001.03

Karani GeoffreyKarani Geoffrey7 min read

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.01 is 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.03 means the request got past the door and was refused by the product. Sometimes that is a genuinely expired token or a malformed Authorization header. 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?"

Your request Bearer <token> Checkpoint 1: authorisation Is this token ours, for this environment? Was it issued to a live app? Checkpoint 2: the API product Is this app subscribed to STK / B2B / B2C? Is the token still inside its hour? 401.003.01 Wrong environment, truncated token, or credentials rotated since issuance. 404.001.03 Expired token or bad header, or the app is not enabled for this API. Both checkpoints report the same words to you. Only the errorCode tells you which one you failed, and the fixes have nothing in common: one is a code change, the other is a support ticket.
The message is identical at both checkpoints. That single piece of API design is responsible for most of the confusion around this error.
401.003.01404.001.03
Where it firesAuthorisation layerThe API product you called
Most common causeToken from the other environmentExpired token, or app not subscribed to that API
Does refreshing the token help?Only if you also fix the credentials or base URLSometimes. If not, it never will
Who fixes itYouYou, or Safaricom API support
Typical timingFirst deploy to productionRight 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

  1. Check what is between you and Safaricom. Some proxies and API gateways strip or rewrite the Authorization header. Log the outbound headers from the machine that actually makes the call, not from your laptop.
  2. 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.
  3. 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.
  4. 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.
#M-Pesa#Daraja#OAuth#STK Push#API Integration
Keep reading

Related articles