Transaction Status
Request transaction status from M-Pesa, receive result or timeout callbacks, and validate/interpret response metadata.
User Stories
As a payments engineer or backend developer at a merchant/PSP, I want to query the status of an MβPesa transaction and reliably handle result and timeout callbacks so I can reconcile payments, notify customers, and trigger compensating workflows when needed.
- Persona: Payments engineer, reconciliation analyst, or backend developer integrating MβPesa.
- Goal: Retrieve definitive transaction state (success, failed, pending) and process callbacks for downstream business logic.
- Acceptance criteria:
- Submit a Transaction Status query and receive an accepted response from Daraja.
- ResultURL receives structured result payloads and the service acknowledges with ResultCode 0.
- QueueTimeOutURL receives timeout notifications that trigger retry or compensation flows.
- Request validation prevents invalid MSISDNs, enforces identifier rules, and blocks overly long remarks/occasions.
Parameters Definition
| Parameter | Type | Description |
|---|---|---|
Initiatorrequired str | String | API username used to initiate the Transaction Status request. |
SecurityCredentialrequired str | String | Encrypted security credential (provided by M-Pesa Daraja API) used to authenticate the request. |
CommandIDrequired str | String | The operation type. Use 'TransactionStatusQuery'. |
TransactionID str | String | M-Pesa transaction identifier to query. Either this or OriginalConversationID must be provided. |
OriginalConversationID str | String | Original conversation id for the earlier request. Can be used when TransactionID is unavailable. |
PartyArequired int | Integer | Organization shortcode or MSISDN depending on IdentifierType. |
IdentifierTyperequired int | Integer (enum) | Identifier type for PartyA. Allowed: 1 (MSISDN), 2 (Till Number), 4 (Short Code). |
ResultURLrequired str | String | HTTPS endpoint that will receive the result callback when the query completes. |
QueueTimeOutURLrequired str | String | HTTPS endpoint that will receive a timeout notification if the query times out. |
Remarksrequired str | String | Short comment describing the query (max 100 characters). |
Occasion str | String | Optional occasion string (max 100 characters). |
Overview
The Transaction Status API lets you ask M-Pesa Daraja API for the current state of a previously submitted MβPesa transaction.
The request is asynchronous: M-Pesa will post the result to your configured ResultURL when processing completes, or to the QueueTimeOutURL if processing times out.
Response Schema
TransactionStatusResponse β the synchronous acknowledgement returned by client.transactions.query_status(...)
| Parameter | Type | Description |
|---|---|---|
ConversationIDrequired str | null | String | Unique ID for the transaction status request. |
OriginatorConversationIDrequired str | null | String | ID for tracking the request. |
ResponseCoderequired str | int | String/Integer | Status code. Any all-zero value (e.g. '0') indicates the request was accepted. |
ResponseDescriptionrequired str | String | Status message. |
- Use the
MpesaClientfacade for simple integration (handles tokens, headers and returns typed models). - Use the lower-level TransactionStatus service with
TokenManagerandHttpClientif you need direct control over requests, headers or middleware. - Both are available as sync and async variants (
MpesaClient/AsyncMpesaClient,TransactionStatus/AsyncTransactionStatus) β reach for async when querying from inside an async web framework, or when checking the status of many transactions during a reconciliation run.
The facade abstracts authentication, header injection and returns Pydantic models so you can call query operations with minimal boilerplate.
Quick Setup (Sync)
# Example (conceptual): query transaction status using the high-level clientfrom mpesakit import MpesaClientfrom mpesakit.transaction_status import TransactionStatusIdentifierType
client = MpesaClient(consumer_key="...", consumer_secret="...", environment="sandbox")
resp = client.transactions.query_status( initiator="api_user", security_credential="ENCRYPTED_CREDENTIAL", transaction_id="LK12345", party_a=600000, identifier_type=TransactionStatusIdentifierType.SHORT_CODE.value, result_url="https://your.example/result", queue_timeout_url="https://your.example/timeout", remarks="Check status")
if resp.is_successful: print("Request accepted:", resp.ResponseDescription)else: print("Request failed:", resp.ResponseDescription)- The facade returns typed response models with helpers (e.g., is_successful).
- Use the facade unless you require custom HTTP handling.
Quick Setup (Async)
import asynciofrom mpesakit import AsyncMpesaClientfrom mpesakit.transaction_status import TransactionStatusIdentifierType
async def main(): async with AsyncMpesaClient( consumer_key="...", consumer_secret="...", environment="sandbox" ) as client: resp = await client.transactions.query_status( initiator="api_user", security_credential="ENCRYPTED_CREDENTIAL", transaction_id="LK12345", party_a=600000, identifier_type=TransactionStatusIdentifierType.SHORT_CODE.value, result_url="https://your.example/result", queue_timeout_url="https://your.example/timeout", remarks="Check status" )
if resp.is_successful: print("Request accepted:", resp.ResponseDescription) else: print("Request failed:", resp.ResponseDescription)
asyncio.run(main())For a nightly reconciliation job checking the status of a batch of transaction IDs, gather the coroutines instead of querying one at a time β chunk large batches to respect rate limits:
transaction_ids = ["LK12345", "LK12346", "LK12347"]
responses = await asyncio.gather(*(
client.transactions.query_status(
initiator="api_user",
security_credential="ENCRYPTED_CREDENTIAL",
transaction_id=tid,
party_a=600000,
identifier_type=TransactionStatusIdentifierType.SHORT_CODE.value,
result_url="https://your.example/result",
queue_timeout_url="https://your.example/timeout",
remarks="Nightly reconciliation",
)
for tid in transaction_ids
))
In a long-lived service (e.g. AsyncMpesaClient wired up as a FastAPI dependency), construct it once at startup and call await client.aclose() on shutdown instead of opening a new async with block per request.
Result & Timeout Callbacks (webhooks)
The callback is an inbound HTTP POST from Safaricom to your ResultURL/QueueTimeOutURL β independent of whether the original query was sent via MpesaClient or AsyncMpesaClient. For the result payload, both clients expose process_transactions_callback, a one-call shorthand for TransactionStatusResultCallback.model_validate(payload); it's plain validation with no I/O, so it's never awaited even on the async client. There's no equivalent helper for the timeout payload, so that one is still validated directly against TransactionStatusTimeoutCallback, as in the full example below.
# Minimal FastAPI webhook receivers for Transaction Status Result & Timeout callbacks.# - Validates caller IP using is_mpesa_ip_allowed# - Parses the result payload via client.process_transactions_callback (or TransactionStatusTimeoutCallback directly for timeouts)# - Persists/queues the payload for downstream processing (TODO)# - Returns the acknowledgement JSON expected by M-Pesa Daraja API
from fastapi import FastAPI, Request, HTTPException, statusfrom fastapi.responses import JSONResponseimport logging
from mpesakit import MpesaClient # or AsyncMpesaClient β process_transactions_callback is identical on bothfrom mpesakit.security import is_mpesa_ip_allowedfrom mpesakit.transaction_status import ( TransactionStatusTimeoutCallback, TransactionStatusResultCallbackResponse, TransactionStatusTimeoutCallbackResponse,)
log = logging.getLogger(__name__)app = FastAPI()
client = MpesaClient(consumer_key="...", consumer_secret="...", environment="sandbox")
def _get_remote_ip(request: Request) -> str: xff = request.headers.get("x-forwarded-for") if xff: return xff.split(",")[0].strip() return request.client.host
@app.post("/webhooks/transaction-status/result")async def transaction_status_result(request: Request): remote_ip = _get_remote_ip(request) if not is_mpesa_ip_allowed(remote_ip): log.warning("Rejected TransactionStatus result callback from disallowed IP: %s", remote_ip) raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
try: payload = await request.json() except Exception as exc: log.exception("Failed reading JSON payload from %s: %s", remote_ip, exc) return JSONResponse( status_code=400, content=TransactionStatusResultCallbackResponse( ResultCode=1, ResultDesc="Invalid JSON payload." ).model_dump(), )
try: # Equivalent to TransactionStatusResultCallback.model_validate(payload) callback = client.process_transactions_callback(payload) except Exception as exc: log.exception("TransactionStatus result callback validation error: %s", exc) return JSONResponse( status_code=400, content=TransactionStatusResultCallbackResponse( ResultCode=1, ResultDesc=f"Invalid payload: {exc}" ).model_dump(), )
# TODO: persist callback (DB/queue) for reconciliation and business processing. log.info( "Received TransactionStatus result: OriginatorConversationID=%s ConversationID=%s ResultCode=%s TransactionID=%s", callback.Result.OriginatorConversationID, callback.Result.ConversationID, callback.Result.ResultCode, getattr(callback.Result, "TransactionID", None), )
ack = TransactionStatusResultCallbackResponse() # default success ack (ResultCode 0) return JSONResponse(status_code=200, content=ack.model_dump())
@app.post("/webhooks/transaction-status/timeout")async def transaction_status_timeout(request: Request): remote_ip = _get_remote_ip(request) if not is_mpesa_ip_allowed(remote_ip): log.warning("Rejected TransactionStatus timeout callback from disallowed IP: %s", remote_ip) raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
try: payload = await request.json() except Exception as exc: log.exception("Failed reading JSON payload from %s: %s", remote_ip, exc) return JSONResponse( status_code=400, content=TransactionStatusTimeoutCallbackResponse( ResultCode=1, ResultDesc="Invalid JSON payload." ).model_dump(), )
try: callback = TransactionStatusTimeoutCallback.model_validate(payload) except Exception as exc: log.exception("TransactionStatus timeout callback validation error: %s", exc) return JSONResponse( status_code=400, content=TransactionStatusTimeoutCallbackResponse( ResultCode=1, ResultDesc=f"Invalid payload: {exc}" ).model_dump(), )
# TODO: persist/queue timeout notification and trigger compensating workflows. log.info( "Received TransactionStatus timeout: OriginatorConversationID=%s ConversationID=%s ResultCode=%s", callback.Result.OriginatorConversationID, callback.Result.ConversationID, callback.Result.ResultCode, )
ack = TransactionStatusTimeoutCallbackResponse() # default success ack (ResultCode 0) return JSONResponse(status_code=200, content=ack.model_dump())- TransactionStatusRequest validation enforces:
IdentifierTypemust be one of [1, 2, 4].- When
IdentifierType== 1 (MSISDN) thePartyAvalue is normalized to a Kenyan MSISDN; invalid numbers raise validation errors. - At least one of
TransactionIDorOriginalConversationIDmust be provided. RemarksandOccasionmust not exceed 100 characters.
- These validations happen at request-model construction time and are identical whether you use the sync or async service.
- TransactionStatusResponse provides
is_successfulto check if the request was accepted β available the same way on both client variants' responses.
Result Callback Schema
TransactionStatusResultCallback β posted to ResultURL once the query completes
| Parameter | Type | Description |
|---|---|---|
Result.ResultTyperequired int | Integer | 0 = success, 1 = failure. |
Result.ResultCoderequired int | str | Integer/String | 0 indicates the query succeeded; any other value is a failure code. |
Result.ResultDescrequired str | String | Human readable result description. |
Result.OriginatorConversationIDrequired str | String | Matches the OriginatorConversationID from the initial request. |
Result.ConversationIDrequired str | String | Matches the ConversationID from the initial acknowledgement. |
Result.TransactionID str | null | String | M-Pesa transaction ID being queried, when available. |
Result.ResultParameters list[{Key, Value}] | Array | Key/value parameters. Exposed via TransactionStatusResultMetadata convenience properties: transaction_amount (TransactionAmount), transaction_receipt (TransactionReceipt), transaction_status (Status, e.g. 'Completed'/'Failed'), transaction_reason (Reason, optional failure reason). |
Timeout Callback Schema
TransactionStatusTimeoutCallback β posted to QueueTimeOutURL if the query doesn't complete in time
Uses the same Result shape as the Result Callback Schema above (it's the same TransactionStatusResultMetadata model), just delivered to QueueTimeOutURL instead of ResultURL, typically with a non-zero ResultCode and without TransactionID/ResultParameters.
Callback Acknowledgement Schemas
What your webhook handler should return to Safaricom
| Parameter | Type | Description |
|---|---|---|
ResultCode int | str | Integer/String | Defaults to 0. Used by TransactionStatusResultCallbackResponse and TransactionStatusTimeoutCallbackResponse β the typed models to return from your /result and /timeout handlers respectively. |
ResultDesc str | String | Defaults to 'Result received and processed successfully.' (result) or 'Timeout notification received and processed successfully.' (timeout). |
Result Payload (ResultParameters)
{ "Result": { "ResultType": 0, "ResultCode": 0, "ResultDesc": "The service request is processed successfully.", "OriginatorConversationID": "...", "ConversationID": "...", "TransactionID": "LKXXXX1234", "ResultParameters": [ {"Key": "TransactionAmount", "Value": 1000}, {"Key": "TransactionReceipt", "Value": "LKXXXX1234"}, {"Key": "Status", "Value": "Completed"}, {"Key": "Reason", "Value": "Optional failure reason"} ] }}The result metadata exposes convenience accessors:
- transaction_amount β numeric amount (if present).
- transaction_receipt β transaction receipt string.
- transaction_status β status string (e.g., "Completed", "Failed").
- transaction_reason β optional reason for failure.
Error Handling & Validation
- Constructing a TransactionStatusRequest raises clear ValueErrors for:
- invalid IdentifierType,
- missing TransactionID and OriginalConversationID,
- overly long Remarks/Occasion,
- invalid MSISDN normalization when IdentifierType == 1.
try: resp = client.transactions.query_status(...)except Exception as exc: print("Transaction status query failed:", exc)Testing & Expected Behaviors
-
Request validation:
- Invalid identifier types or invalid MSISDN values should raise errors during model construction.
- At least one of TransactionID or OriginalConversationID must be present.
-
HTTP interactions:
- The TransactionStatus service posts to /mpesa/transactionstatus/v1/query with Authorization header from TokenManager (or AsyncTokenManager).
- On successful acceptance the service returns a TransactionStatusResponse; use is_successful to determine accepted requests. This holds whether the response was awaited (async) or returned directly (sync).
-
Callbacks:
- ResultURL receives TransactionStatusResultCallback with Result metadata β parse it directly or via
process_transactions_callback; reply with ResultCode 0 to acknowledge receipt. - QueueTimeOutURL receives a timeout notification; handle and reconcile as needed.
- ResultURL receives TransactionStatusResultCallback with Result metadata β parse it directly or via
Next Steps
- Implement secure webhook endpoints (validate source IPs or signatures), persist callbacks for reconciliation, and build compensation/retry flows for timeouts.
- Add observability around transaction queries and callbacks to surface failed or delayed operations.
Related Documentationβ
- π‘ Webhook Setup Guide - tips for resilient webhook processing
- π Authentication & Token Management - how TokenManager works with services
- ποΈ Production Setup - go-live checklist, security and monitoring