The HTTP response that starts a payment is not the final payment result
With an asynchronous payment flow, the merchant can receive an acknowledgement before the customer has completed authorization or the provider has finalized the transaction.
Treating the initial request as the final outcome creates both false success and false failure bugs.
Model the attempt as a state machine
- INITIATED — local attempt created.
- AWAITING_CUSTOMER — provider expects customer authorization.
- PENDING — provider processing continues.
- UNKNOWN — final evidence is missing after the expected window.
- SUCCEEDED / FAILED / EXPIRED — final operational outcomes.
M-PESA Express has a specific query path
Persist the CheckoutRequestID from the M-PESA Express response. If the callback is late or missing, query the STK request with M-PESA Express Query.
Do not jump straight to Transaction Status when you do not yet have an M-PESA receipt number or Originator Conversation ID.
UNKNOWN is a control state, not a provider error
UNKNOWN tells the rest of the system to stop making assumptions. In particular:
- block a second payment attempt;
- suppress “payment failed” customer copy;
- query or verify the original attempt;
- surface a support exception if uncertainty persists.
Out-of-order events need monotonic transitions
A stale PENDING event that arrives after SUCCEEDED should not reopen the attempt. Define which state transitions are allowed and store provider timestamps where they help establish ordering.
Separate support playbook from developer logic
For the front-desk procedure when a customer says they paid but the payment is not showing, see the support playbook. The state model here explains the engineering behavior underneath it.
A timeout policy should be explicit
Decide how long an attempt can remain PENDING before moving to UNKNOWN, how often status queries are allowed and when persistent uncertainty enters manual review. Do not scatter those numbers across workers and cron jobs.
Give UNKNOWN an owner and a timeout policy
UNKNOWN should block unsafe payment retries, but it should not become a permanent parking state. Define when the system re-queries the provider, when it escalates to operations and how long an unresolved attempt can remain open before a human review is required.
The policy should use provider evidence rather than silently converting UNKNOWN to FAILED just because enough time passed.
Late success is a normal test case
Test what happens when an attempt is marked unresolved, a second process inspects it, and a success event arrives later. The system should close the invoice once, cancel further recovery and preserve the event order for support.
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
- Safaricom Transaction Status API: https://developer.safaricom.co.ke/apis/TransactionStatus



