</>CodeWithKarani

Daraja Returns 429 With a Body Your Error Parser Cannot Read

Karani GeoffreyKarani Geoffrey8 min read

The nightly reconciliation job has been fine for months. Then one morning finance says the numbers do not tie out, and the job log for last night is full of lines like Daraja error: undefined. Or worse, it is full of nothing, because the error handler did err.response.data.errorCode, got undefined, decided that was not a real error, and carried on.

What actually happened is that you got rate limited. And the thing that rate limited you was not Daraja. It was the API gateway sitting in front of Daraja, which has its own error format, its own opinions about what "too fast" means, and no interest in the JSON shape the Daraja documentation showed you.

Every Daraja client I have seen in the wild has exactly one error parser. That is the bug. Daraja speaks two error dialects and only tells you about one of them.

an HTTP 429 from Daraja returns an Apigee gateway fault, not the usual Daraja {errorCode, errorMessage} object. Branch your error handling on the response shape, not on the status code alone.

  • Look for fault.detail.errorcode === 'policies.ratelimit.SpikeArrestViolation' (lowercase errorcode, note the casing).
  • Spike arrest limits how close together requests arrive, not how many you send per minute. Two calls fired at the same instant can trip it while you are nowhere near any hourly budget.
  • Back off exponentially with jitter and retry. Do not drop the request, and do not retry immediately.
  • Space out reconciliation and batch jobs with a minimum interval between calls instead of firing them concurrently.

The exact error: HTTP 429 with an Apigee spike arrest fault

This is the body. It is not Daraja's format:

{
  "fault": {
    "faultstring": "Spike arrest violation. Allowed rate : 10ps",
    "detail": {
      "errorcode": "policies.ratelimit.SpikeArrestViolation"
    }
  }
}

And this is what your parser was written for, the shape Daraja itself returns for an application-level problem:

{
  "requestId": "11728-2929992-1",
  "errorCode": "400.002.02",
  "errorMessage": "Bad Request - Invalid PhoneNumber"
}

Three differences that will break code written against the second shape:

  • The key is fault.detail.errorcode, nested two levels deep, versus a flat errorCode.
  • errorcode is all lowercase in the gateway fault. errorCode is camelCase in the Daraja fault.
  • There is no requestId, so whatever correlation ID you log alongside every Daraja error is suddenly missing.

Why this happens: two layers, two error vocabularies

Daraja is not a single service. It is an Apigee API gateway in front of Safaricom's payment backends. The gateway handles OAuth token validation, quota, and traffic shaping. Only requests that survive all of that reach the code that knows what an STK push is.

So there are two completely separate places an error can be produced, and they were written by different teams with different conventions:

Your reconciliation job one error parser Apigee gateway OAuth check, quota, spike arrest returns 429 { fault: { detail: { errorcode } } } Daraja backend STK push, C2B, B2C returns 400 / 500 { errorCode, errorMessage } Spike arrest is about spacing, not volume A rate of N per second is enforced as one request every 1/N seconds, not N in any given second. burst: three rejected even though it is only four calls paced: same four calls, all accepted
The gateway rejects requests before Daraja ever sees them, which is why the error shape is different.

The mechanism matters because it changes the fix. A spike arrest policy is not a counter that resets every minute. It smooths traffic by dividing the configured rate into an interval and rejecting anything that arrives inside that interval. If the allowed rate is expressed per second, the gateway effectively wants one request every 1/rate seconds. Fire five requests in the same millisecond and four of them are rejected, even if you make no other call for the rest of the hour.

This is why the failure looks so arbitrary. Teams look at their daily transaction volume, see it is tiny, and conclude the 429 must be a Safaricom problem. It is not. It is Promise.all() over an array of pending transactions.

The fix

Step 1: Parse by shape, not by assumption

Write one function that turns any Daraja HTTP failure into your own normalised error, and make the gateway path explicit:

type DarajaFailure = {
  kind: 'rate_limited' | 'gateway' | 'daraja' | 'unknown'
  code: string
  message: string
  retryable: boolean
  raw: unknown
}

export function classifyDarajaError(status: number, body: any): DarajaFailure {
  // Apigee gateway fault: { fault: { faultstring, detail: { errorcode } } }
  if (body && typeof body === 'object' && body.fault) {
    const code = body.fault?.detail?.errorcode ?? 'unknown.gateway.fault'
    const isRateLimit =
      status === 429 || String(code).startsWith('policies.ratelimit.')
    return {
      kind: isRateLimit ? 'rate_limited' : 'gateway',
      code: String(code),
      message: body.fault?.faultstring ?? 'Gateway fault',
      retryable: isRateLimit,
      raw: body,
    }
  }

  // Standard Daraja fault: { requestId, errorCode, errorMessage }
  if (body && typeof body === 'object' && body.errorCode) {
    return {
      kind: 'daraja',
      code: String(body.errorCode),
      message: String(body.errorMessage ?? ''),
      retryable: status >= 500,
      raw: body,
    }
  }

  return {
    kind: 'unknown',
    code: `http_${status}`,
    message: typeof body === 'string' ? body.slice(0, 300) : 'Unparseable body',
    retryable: status >= 500 || status === 429,
    raw: body,
  }
}

The unknown branch is not padding. Gateways return HTML error pages, empty bodies and plain text under load. Anything that assumes JSON will throw inside the error handler, which is the single most annoying class of production bug because the stack trace points at your logging code instead of the failure.

Step 2: Put a pacer in front of every Daraja call

A concurrency limit of 1 is not enough, because a fast backend means your "one at a time" calls still arrive microseconds apart. You need a minimum interval:

function pacer(minIntervalMs: number) {
  let next = 0
  return async function wait() {
    const now = Date.now()
    const runAt = Math.max(now, next)
    next = runAt + minIntervalMs
    if (runAt > now) await new Promise(r => setTimeout(r, runAt - now))
  }
}

const darajaPace = pacer(250) // start conservative, tune from observed 429s

Every outbound Daraja request awaits darajaPace() first. Start at a spacing you are confident about and tighten it only if your 429 rate stays at zero over a full reconciliation run. Safaricom does not publish the configured spike arrest rate, so the honest approach is to measure rather than to guess a number from a blog post.

Step 3: Back off with jitter on 429

async function callDarajaWithRetry(fn: () => Promise<Response>, attempts = 5) {
  for (let attempt = 0; attempt < attempts; attempt++) {
    await darajaPace()
    const res = await fn()
    if (res.ok) return res

    const body = await res.json().catch(() => null)
    const err = classifyDarajaError(res.status, body)
    if (!err.retryable || attempt === attempts - 1) throw err

    const retryAfter = Number(res.headers.get('retry-after'))
    const base = Number.isFinite(retryAfter) && retryAfter > 0
      ? retryAfter * 1000
      : 500 * 2 ** attempt
    const jitter = Math.random() * base * 0.3
    await new Promise(r => setTimeout(r, base + jitter))
  }
  throw new Error('unreachable')
}

Honour Retry-After when it is present, but do not build the logic around it. Spike arrest faults commonly arrive without that header, so the fallback path is the one that will actually run. The jitter matters more than people expect: without it, every worker that got a 429 at the same instant retries at the same instant, and you have rebuilt the burst that caused the problem.

Step 4: Stop tight-polling the STK Push Query endpoint

This is the number one source of these 429s. Someone writes a loop that calls /mpesa/stkpushquery/v1/query every 500ms until the transaction resolves, multiplied by however many transactions are pending. That is a load generator.

The callback is the primary signal. Query is a fallback for transactions where the callback never arrived, and it should run once, after a delay, from a scheduled job rather than a loop. If you are not yet treating callbacks as the source of truth, start with handling M-Pesa callbacks that fire twice, because polling harder is usually a symptom of not trusting the callback path.

Step 5: Turn the batch job into a drip

Replace the Promise.all with a queue that has a fixed drain rate. A reconciliation run of 2,000 transactions at 250ms spacing takes about eight minutes. That is fine. It runs at 3am and nobody is waiting for it. Eight minutes of paced requests beats forty seconds of requests where a third failed and got silently swallowed.

Verification

1. Confirm the classifier sees the right branch. Log kind and code on every failure, then deliberately burst:

for i in $(seq 1 20); do
  curl -s -o /dev/null -w "%{http_code}\n" \
    -H "Authorization: Bearer $TOKEN" \
    https://api.safaricom.co.ke/mpesa/stkpushquery/v1/query \
    -H 'Content-Type: application/json' -d @query.json &
done; wait

You want to see 429 in that output and, in your application logs, kind=rate_limited code=policies.ratelimit.SpikeArrestViolation. If you see kind=unknown, your parser still is not reading the fault.

2. Confirm the pacer works. Run the same burst through your client rather than curl. Total wall time should be roughly count * minIntervalMs, and the 429 count should be zero.

3. Confirm nothing is dropped. After a reconciliation run, the count of transactions marked resolved plus the count deferred to the next run must equal the count you started with. If it does not, something is still being swallowed by an error handler.

What people get wrong

Common responseWhy it fails
Retry immediately on any errorRetrying inside the spike arrest interval guarantees another 429 and multiplies load exactly when the gateway is shedding it.
Treat 429 as "we are banned, alert the team"It is a throttle, not a suspension. The correct response is to slow down and continue.
catch (e) { logger.error(e.response.data.errorCode) }Logs undefined for every gateway fault, so the incident looks like a silence rather than a rate limit.
Raise the timeoutTimeouts are irrelevant here. The gateway answers fast; it just answers 429.
Add more workers to clear the backlog fasterMore concurrency means tighter spacing, which is the thing being rejected. The backlog gets worse.
Open a Safaricom support ticket firstReasonable eventually, but verify your own request spacing first. Support will ask, and "we fire all pending transactions concurrently" is not a strong opening position.

When it is still broken

  1. You get 500 instead of 429. Apigee deployments can be configured to return 500 for rate limit violations. Check the body for the same policies.ratelimit.* errorcode before you treat a 500 as a backend failure, otherwise your retry policy will be wrong in both directions.
  2. The errorcode says QuotaViolation, not SpikeArrestViolation. That is a different policy: a fixed allowance over a window, not a spacing rule. Pacing will not help. You need to reduce total call volume or talk to Safaricom about your quota.
  3. It is the OAuth token endpoint that is throttling. If you fetch a fresh access token before every call instead of caching it until expiry, you are doubling your request rate for no reason. Cache the token and refresh on a timer with a safety margin.
  4. Two services share one set of credentials. The gateway limits by app credentials, so your well-behaved reconciliation job can be throttled by a badly behaved checkout service using the same consumer key. Give each workload its own app, or put both behind one shared pacer.

None of this is exotic. It is the same discipline as any third-party integration: know which layer produced the error before you decide what it means. If you are building this properly rather than patching it, keeping the retry and classification logic behind a payments port rather than a Daraja wrapper means you only have to solve it once, and the full Daraja production setup covers the callback side that stops you needing to poll at all.

Frequently asked questions

What does policies.ratelimit.SpikeArrestViolation mean on the M-Pesa Daraja API?
It means the Apigee gateway in front of Daraja rejected your request because it arrived too soon after the previous one. Spike arrest enforces a minimum spacing between requests rather than a total per minute, so a small number of calls fired concurrently can trigger it while your overall volume is low. The response is HTTP 429 with an Apigee fault body, not Daraja's normal errorCode JSON.
Why does my Daraja error handler log undefined when I hit a rate limit?
Because the 429 body is shaped {"fault": {"faultstring": ..., "detail": {"errorcode": ...}}} while your parser reads a flat errorCode field. Note the casing too: the gateway uses lowercase errorcode and Daraja uses camelCase errorCode. Add an explicit branch that checks for a fault object before falling back to the Daraja shape, plus a branch for non-JSON bodies.
How should I retry after a Daraja 429?
Back off exponentially with random jitter and retry rather than dropping the payment request. Honour a Retry-After header if one is present, but do not depend on it, because spike arrest faults often arrive without one. The jitter is essential: without it every worker that was throttled retries at the same moment and recreates the burst.
How do I stop my M-Pesa reconciliation job from getting rate limited?
Replace concurrent fan-out such as Promise.all with a pacer that enforces a minimum interval between outbound calls, and stop polling the STK Push Query endpoint in a tight loop. Treat the callback as the primary result signal and use the query endpoint as a delayed fallback for transactions whose callback never arrived. A reconciliation run that takes eight paced minutes is better than one that takes forty seconds and silently loses a third of its results.
#M-Pesa#Daraja#Rate Limiting#Apigee#Reconciliation
Keep reading

Related articles