</>CodeWithKarani

Daraja 404.001.03: Cache the Access Token, Don't Mint a New One

Karani GeoffreyKarani Geoffrey9 min read

The pattern is always the same. STK pushes work fine all morning. Then, somewhere around lunchtime, a handful fail. They work again immediately when you retry. Support says "it is intermittent". Then it happens again in the afternoon, and if you plot the failures on a timeline they are almost exactly an hour apart.

That is not intermittent. That is a token expiring, and something in your code deciding once per hour to find out the hard way.

The opposite failure mode is quieter and just as common: an integration that calls /oauth/v1/generate before every single API request. It never sees an expiry error, so nobody notices, right up until traffic doubles and Daraja starts throttling the OAuth endpoint. Neither of these is the right pattern, and Safaricom does not document the right pattern anywhere, so almost every codebase invents its own.

cache the access token, refresh it proactively before it expires, and never build the header by string concatenation without checking the space.

  • Store the token with an absolute expiry computed from the expires_in value that call returned, not from a hardcoded 3600.
  • Refresh when there is less than about 5 minutes of life left, not when a request fails.
  • The header must be exactly Authorization: Bearer <token>, one space, no newline. A token read from a file or an env file often carries a trailing \n.
  • If you run more than one worker process, put the cache in Redis with a short lock, not in a module-level variable.
  • Keep one reactive retry as a safety net: on 404.001.03, delete the cached token, fetch once, retry once, then give up.

The exact error: 404.001.03

On any Daraja endpoint, the expired-token response looks like this:

{
  "requestId": "11728-2929992-1",
  "errorCode": "404.001.03",
  "errorMessage": "Invalid Access Token"
}

Some client libraries and some community docs render the same condition as 404.001.03: Access Token Expired or Missing. Whatever wording you see, branch on the errorCode field only. Never parse errorMessage.

Important caveat before you write any retry logic: 404.001.03 is not exclusively an expiry error. It is also what you get when your app is not subscribed to the product you are calling, which happens constantly right after Go Live. I wrote about telling those apart in 401.003.01 is not 404.001.03. This article assumes you have already ruled that out and the token really is the problem.

What the OAuth endpoint actually gives you

curl -s -u "$CONSUMER_KEY:$CONSUMER_SECRET" \
  "https://api.safaricom.co.ke/oauth/v1/generate?grant_type=client_credentials"
{
  "access_token": "c9Sq0X4hZ2Kk1mFvT7yGqZ8bNpQ",
  "expires_in": "3599"
}

Three things in that tiny response trip people up.

First, expires_in comes back as a string, not a number. In Python that is harmless until you do arithmetic on it; in JavaScript, Date.now() + "3599" * 1000 works but Date.now() + "3599" silently produces string concatenation and a date somewhere around the year 50000. Coerce it explicitly.

Second, it is seconds of remaining life, and it is not always the same number. Compute your expiry from the value you were just handed. Hardcoding 3600 gives you a window that is already a second or two past reality before you have finished parsing the response.

Third, the token is scoped to the environment that issued it. A sandbox token against api.safaricom.co.ke fails, and a production token against sandbox.safaricom.co.ke fails, and both failures look like a token problem rather than the configuration problem they actually are.

One token hour, three strategies Fetch every call OAuth call before every request - works, then gets throttled under load Cache forever Valid 404.001.03 forever Cache + refresh Served from cache refresh window t = 0 t = 3300s t = 3599s The refresh window is the whole trick: you replace the token while the old one still works.
The failing hour boundary is not random. If your errors cluster once an hour, you are looking at a cache with no refresh window.

Why the naive fixes fail

Refreshing reactively on error means every token rollover costs you at least one failed customer payment. On an STK push that is a customer who saw nothing happen and will not try again. Reactive refresh is a safety net, not a strategy.

Caching in a module-level variable works perfectly in development and quietly stops working in production, because production runs gunicorn -w 4 or PM2 in cluster mode or three Kubernetes replicas. Each process has its own copy. Four processes means four OAuth calls per hour, which is fine, but it also means that when they all restart together after a deploy they all fetch at once, and they all expire at the same moment an hour later. You have built a thundering herd on a one-hour timer.

Trusting the wall clock without slack is the one that bites on cheap VPS instances. If your server clock has drifted forty seconds ahead of Safaricom's, a token you believe has ten seconds left has already been dead for thirty. A five-minute refresh window absorbs any realistic drift. Also run NTP, but do not depend on it being healthy.

The header bug that produces the identical error

This one costs people entire evenings, because the token is genuinely valid and the error says it is not.

# Broken. There is no space, so the header value is "Bearerc9Sq0X4..."
headers = {"Authorization": "Bearer" + token}

# Broken in a way you cannot see. token came from a file or a .env line
# and ends with a newline, so the header is malformed.
token = open("token.txt").read()
headers = {"Authorization": f"Bearer {token}"}

# Correct
headers = {"Authorization": f"Bearer {token.strip()}"}

The trailing-newline case is the nastier one, because printing the header looks completely normal in a terminal. Check the length, not the appearance:

assert "\n" not in token and token == token.strip()

While you are in that area, a related trap: some HTTP clients and some proxies quietly mangle headers or bodies in ways that only show up against Daraja. I hit one of those in the Invalid BusinessShortCode case, where the code was right and the request on the wire was not.

The pattern, in code

Step 1: a single token provider with an absolute expiry

import base64, os, time, requests

BASE = os.environ["DARAJA_BASE_URL"]          # https://api.safaricom.co.ke
KEY = os.environ["DARAJA_CONSUMER_KEY"]
SECRET = os.environ["DARAJA_CONSUMER_SECRET"]
REFRESH_MARGIN = 300                           # seconds


def fetch_token() -> tuple[str, float]:
    basic = base64.b64encode(f"{KEY}:{SECRET}".encode()).decode()
    r = requests.get(
        f"{BASE}/oauth/v1/generate",
        params={"grant_type": "client_credentials"},
        headers={"Authorization": f"Basic {basic}"},
        timeout=15,
    )
    r.raise_for_status()
    body = r.json()
    token = body["access_token"].strip()
    ttl = int(body["expires_in"])              # arrives as a string
    return token, time.time() + ttl

Expected result of calling this directly: a token roughly 28 characters long and an expiry about 3599 seconds in the future.

Step 2: cache it where every worker can see it

Redis, with the TTL doing the expiry work for you so you cannot get the arithmetic wrong twice:

import redis

r = redis.Redis.from_url(os.environ["REDIS_URL"], decode_responses=True)
CACHE_KEY = "daraja:token:prod"
LOCK_KEY = "daraja:token:prod:lock"


def get_token() -> str:
    token = r.get(CACHE_KEY)
    if token:
        return token

    # Only one worker fetches; the rest wait briefly and read the cache.
    if r.set(LOCK_KEY, "1", nx=True, ex=20):
        try:
            token, expires_at = fetch_token()
            ttl = max(int(expires_at - time.time()) - REFRESH_MARGIN, 60)
            r.set(CACHE_KEY, token, ex=ttl)
            return token
        finally:
            r.delete(LOCK_KEY)

    for _ in range(20):
        time.sleep(0.25)
        token = r.get(CACHE_KEY)
        if token:
            return token
    raise RuntimeError("Timed out waiting for a Daraja token refresh")

Note where the margin lives: it is subtracted from the Redis TTL, not checked at read time. The cache entry disappears five minutes before the token actually dies, so the next request naturally triggers a refresh while the old token is still perfectly usable. Note also that the cache key includes the environment. That one suffix prevents a whole category of "it worked in staging" incidents.

Step 3: one reactive retry, and only one

def daraja_post(path: str, payload: dict) -> dict:
    for attempt in (1, 2):
        resp = requests.post(
            f"{BASE}{path}",
            json=payload,
            headers={"Authorization": f"Bearer {get_token()}"},
            timeout=30,
        )
        body = resp.json()
        if body.get("errorCode") == "404.001.03" and attempt == 1:
            r.delete(CACHE_KEY)     # force a fresh fetch, then retry once
            continue
        return body
    return body

Two attempts. Not a loop with backoff, not three tries. If a freshly minted token is still rejected, the problem is not expiry and retrying will never fix it, it will just burn your OAuth quota while you stare at the logs. That is when you go back to the subscription and entitlement checks.

Step 4: the same shape in Node

let cached = null; // { token, expiresAt }

async function getToken() {
  const now = Date.now();
  if (cached && cached.expiresAt - now > 300_000) return cached.token;

  const basic = Buffer.from(`${KEY}:${SECRET}`).toString("base64");
  const res = await fetch(
    `${BASE}/oauth/v1/generate?grant_type=client_credentials`,
    { headers: { Authorization: `Basic ${basic}` } }
  );
  const body = await res.json();
  cached = {
    token: body.access_token.trim(),
    expiresAt: now + Number(body.expires_in) * 1000,  // Number(), not +
  };
  return cached.token;
}

The Number() call is not decoration. Without it, now + body.expires_in * 1000 happens to work because * coerces, but any refactor to now + body.expires_in produces a string and an expiry check that is always true. This is a single-process cache; behind PM2 cluster mode or multiple pods, move it to Redis as above.

Verification

Three checks, in increasing order of confidence.

1. The cache is actually being used. Watch the OAuth calls, not the STK calls:

redis-cli --scan --pattern 'daraja:token:*'
redis-cli ttl daraja:token:prod

Expected: one key, and a TTL that counts down and never exceeds about 3299 (3599 minus your 300-second margin). If the TTL jumps back to its maximum on every request, your read path is not hitting the cache.

2. The header is byte-correct. Print the exact header value length and compare it to the token length plus seven:

h = f"Bearer {get_token()}"
print(len(h), repr(h[:10]))

Expected: the repr shows 'Bearer c9S' with the space visible, and no \n anywhere in the string.

3. It survives a rollover. The only real test. Deliberately shorten the margin in a staging environment so a refresh happens every few minutes, then leave a script sending a low-value request on a loop for half an hour. Count the 404.001.03 responses. The target is zero, not "a few at the boundary".

What people get wrong

"Just refresh on every request, it is only one extra call." It is one extra call per payment, and Daraja rate limits. When you hit that limit you get a 429 with a body shaped nothing like the errors you parse, which is its own unpleasant surprise. It also doubles your latency on the request a customer is waiting for.

Storing the token in the database. People do this to share it between workers, then add a row per fetch, then wonder why the table has 40,000 rows of expired secrets in it. If you need shared state, use a cache with a TTL. A token is not a record.

Retrying with exponential backoff on 404.001.03. Backoff is for transient failures. An entitlement problem is not transient; you will wait 1, 2, 4, 8 seconds and fail anyway, having made the customer wait fifteen seconds for the same error.

Logging the token. It is a bearer credential valid for the next hour against real money movement. Log the first six characters and the expiry if you must, never the whole thing, and check your error reporting tool is not capturing request headers.

When it is still broken

  • Check the base URL and credentials came from the same environment. Print both at startup, with the key masked. This is still the single most common cause of "the token is invalid".
  • Check the clock. timedatectl status should report System clock synchronized: yes. A drifted clock makes every expiry calculation a guess.
  • Capture the actual bytes on the wire. If the header looks right in your code and wrong to Daraja, something between you and them is rewriting it. A proxy, a corporate gateway, or a client library doing something helpful.
  • Confirm the app is subscribed to the specific product. If a token minted three seconds ago is rejected, it is not an expiry problem, and the fix is a support ticket rather than more code. The post-Go-Live version of this is a different animal entirely.

The token lifecycle is about fifty lines of code and it is worth writing once, carefully, in the one place your whole integration goes through. Every hour of every day it either quietly works or quietly costs you a payment.

Frequently asked questions

How long does an M-Pesa Daraja access token last?
The OAuth endpoint returns an expires_in value, typically 3599 seconds, which is just under one hour. Treat that returned value as authoritative rather than hardcoding 3600, and subtract a safety margin of around five minutes so you refresh while the old token still works. The value arrives as a JSON string, so coerce it to an integer before doing any arithmetic with it.
Should I request a new Daraja token for every API call?
No. It doubles the latency of every customer-facing request and burns through the OAuth endpoint's rate limit, which returns a 429 in a response format most error handlers cannot parse. Cache the token in Redis or an equivalent shared cache with a TTL slightly shorter than its real lifetime, and let expiry of the cache entry trigger the refresh.
Why does my valid Daraja token still return 404.001.03?
Two common causes beyond genuine expiry. First, a malformed Authorization header: it must be exactly 'Bearer' then one space then the token, and a token read from a file or env line often carries a trailing newline that breaks it. Second, 404.001.03 is also returned when your app is not subscribed to the API product you are calling, which is common right after Go Live and cannot be fixed by refreshing the token.
How do I cache a Daraja token across multiple worker processes?
Put it in Redis rather than a module-level variable, because gunicorn workers, PM2 cluster processes and Kubernetes replicas each get their own copy of in-process state. Use a short SET NX lock so only one worker performs the OAuth fetch on a cold cache while the others poll for the result. Include the environment in the cache key so a sandbox token can never be served to a production request.
#M-Pesa#Daraja#OAuth#Redis#Python
Keep reading

Related articles