Normalization gives the merchant one operating language
Provider A can say “processing”, Provider B can return a numeric code and Provider C can expose a different callback schema. The billing system still needs to decide whether to wait, retry, close the invoice or escalate.
Use a small internal state model
- INITIATED — local attempt exists but provider processing has not meaningfully begun.
- AWAITING_CUSTOMER — customer action is required.
- PENDING — provider processing continues.
- SUCCEEDED — sufficient evidence confirms payment.
- FAILED — provider returned a final unsuccessful result.
- EXPIRED — the payment request timed out or its authorization window ended.
- UNKNOWN — there is not enough evidence to call the attempt final.
A Daraja mapping example
| Raw M-PESA Express outcome | Normalized state | Failure category | Operational effect |
|---|---|---|---|
| ResultCode 0 | SUCCEEDED | — | Close invoice after matching evidence |
| 1032 — customer cancelled | FAILED | CUSTOMER_CANCELLED | Invoice stays due; customer may retry |
| 1037 — timeout / unreachable | EXPIRED | AUTH_TIMEOUT | If evidence is incomplete, query first; offer another attempt only after a confirmed final result |
| 1 — insufficient balance | FAILED | INSUFFICIENT_FUNDS | Do not rapid-fire retries; offer later/alternate path |
| 2001 — wrong PIN (commonly reported in M-PESA Express) | FAILED | AUTHENTICATION_FAILED | Verify this API's ResultDesc; return control to customer; never request PIN |
| No reliable final result | UNKNOWN | UNRESOLVED | Block retry and query original attempt |
These are illustrative M-PESA Express interpretations, not universal meanings of numeric codes across Daraja APIs or providers. Confirm mappings against the actual API, environment and provider response before making retry decisions. Preserve the raw ResultCode and ResultDesc alongside the normalized state.
Do not normalize away the reason
FAILED is useful for invoice logic, but recovery still needs to know whether the failure was cancellation, insufficient balance, wrong PIN, timeout or another provider-specific reason.
Store both:
status = FAILED
failure_category = CUSTOMER_CANCELLED
provider_result_code = 1032
provider_result_desc = Request cancelled by user
Absorb the event timeline from provider-specific views
A single attempt may receive several events over time:
- Attempt created → AWAITING_CUSTOMER.
- Provider acknowledges request → PENDING.
- Callback delayed → remains PENDING or moves to UNKNOWN after the expected window.
- Status query confirms cancellation → FAILED.
The merchant should see one coherent timeline rather than a list of unrelated provider payloads.
Define allowed state transitions
A final SUCCEEDED state should not regress to PENDING because an older event arrived late. Likewise, a FAILED attempt should only change if the provider semantics genuinely support a later correction and your system records that correction explicitly.
Normalization is not capability normalization
Two providers can both map to SUCCEEDED while supporting different refund, settlement, authorization or country capabilities. Keep those as separate capability metadata.
Sources
- Safaricom M-PESA Express API: https://developer.safaricom.co.ke/apis/MpesaExpressSimulate
- Safaricom M-PESA Express Query API: https://developer.safaricom.co.ke/apis/MpesaExpressQuery



