Reversal
Reverse completed M-Pesa transactions by initiating a reversal request and handling asynchronous result notifications.
User Stories
- As a fintech product owner, I want to programmatically reverse M-Pesa transactions so that I can handle customer requests efficiently.
- As an integrations developer, I want a simple client and clear webhook callbacks so I can implement reliable end-to-end flows with minimal boilerplate.
- As a billing operations engineer, I want result and timeout notifications with acknowledgements so I can reconcile transactions and trigger retries or alerts when needed.
- As a reseller partner, I want a tested SDK and examples so I can onboard quickly and reduce integration defects.
Parameters Definition
| Parameter | Type | Description |
|---|---|---|
Initiatorrequired str | String | Name of the initiating user (must be pre-approved by Safaricom). |
SecurityCredentialrequired str | String | Encrypted credential of the user (base64 encoded). |
TransactionIDrequired str | String | Unique M-Pesa transaction ID to reverse. |
Amountrequired float | Float | Amount to reverse (should match original transaction). |
ReceiverPartyrequired int | Integer | Party receiving the reversed funds (shortcode or MSISDN). |
ResultURLrequired str | String | HTTPS endpoint that will receive the result notification. |
QueueTimeOutURLrequired str | String | HTTPS endpoint that will receive timeout notifications. |
Remarksrequired str | String | Reason for the reversal (<= 100 characters). |
Occasion str | String | Optional additional information (<= 100 characters). |
Response Schema
ReversalResponse — the synchronous acknowledgement returned by client.reversal.reverse(...)
| Parameter | Type | Description |
|---|---|---|
OriginatorConversationIDrequired str | null | String | Unique ID for the request message. |
ConversationIDrequired str | null | String | Unique ID for the transaction. |
ResponseCoderequired str | int | String/Integer | Status code of the reversal request. 0 means success. |
ResponseDescriptionrequired str | String | Description of the reversal request status. |
Overview
Reversal allows you to cancel or reverse a previously completed M-Pesa transaction. It is an asynchronous operation with callbacks for results and timeouts.
- Use the
MpesaClientfacade for simple and safe integration: it manages authentication, header injection and returns typed Pydantic models. - Use the Direct API (
Reversal service+TokenManager+HttpClient) if you need full control over request/response handling, middleware, or custom error behaviors. - Both are available as sync and async variants (
MpesaClient/AsyncMpesaClient,Reversal/AsyncReversal) — reach for async when initiating reversals from inside an async web framework, or when processing several refund requests concurrently.
The facade handles token retrieval and attaches Authorization headers so you can call high-level operations like initiating a reversal with minimal boilerplate.
Quick Setup (Sync)
# Example: initiate a reversal using the high-level clientfrom mpesakit import MpesaClient
client = MpesaClient(consumer_key="...", consumer_secret="...", environment="sandbox")
resp = client.reversal.reverse( initiator="TestInit610", security_credential="encrypted_credential", transaction_id="LKXXXX1234", amount=100, receiver_party=600610, result_url="https://your.example/result", queue_timeout_url="https://your.example/timeout", remarks="Wrong recipient", occasion="Refund")
if resp.is_successful: print("Reversal initiated successfully")else: print("Reversal failed:", resp.ResponseDescription)- The facade returns typed Pydantic models (e.g., ReversalResponse) for ergonomic access to fields and helpers like is_successful.
- Authentication tokens are handled transparently by the client.
Quick Setup (Async)
import asynciofrom mpesakit import AsyncMpesaClient
async def main(): async with AsyncMpesaClient( consumer_key="...", consumer_secret="...", environment="sandbox" ) as client: resp = await client.reversal.reverse( initiator="TestInit610", security_credential="encrypted_credential", transaction_id="LKXXXX1234", amount=100, receiver_party=600610, result_url="https://your.example/result", queue_timeout_url="https://your.example/timeout", remarks="Wrong recipient", occasion="Refund" )
if resp.is_successful: print("Reversal initiated successfully") else: print("Reversal failed:", resp.ResponseDescription)
asyncio.run(main())In a long-lived service (e.g. AsyncMpesaClient wired up as a FastAPI dependency handling refund requests), construct it once at startup and call await client.aclose() on shutdown instead of opening a new async with block per request.
Webhook Handling (Result & Timeout)
The callback is an inbound HTTP POST from Safaricom to your ResultURL/QueueTimeOutURL — independent of whether the original reversal was sent via MpesaClient or AsyncMpesaClient. For the result payload, both clients expose a process_reversal_callback helper that's a one-call shorthand for ReversalResultCallback.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 ReversalTimeoutCallback.
# Example: simple FastAPI endpoints for Reversal Result and Timeoutfrom fastapi import FastAPI, Request, HTTPExceptionfrom mpesakit import MpesaClient # or AsyncMpesaClient — process_reversal_callback is identical on bothfrom mpesakit.reversal import ReversalResultCallbackResponse, ReversalTimeoutCallback, ReversalTimeoutCallbackResponsefrom mpesakit.security.ip_whitelist import is_mpesa_ip_allowed
app = FastAPI()client = MpesaClient(consumer_key="...", consumer_secret="...", environment="sandbox")
@app.post("/reversal/result")async def reversal_result(request: Request): payload = await request.json() caller_ip = (request.headers.get("x-forwarded-for") or request.client.host).split(",")[0].strip() if not is_mpesa_ip_allowed(caller_ip): raise HTTPException(status_code=403, detail="forbidden")
# Equivalent to ReversalResultCallback.model_validate(payload) data = client.process_reversal_callback(payload) # process the result (update database, notify user, etc.) ack = ReversalResultCallbackResponse() return ack.model_dump(mode="json")
@app.post("/reversal/timeout")async def reversal_timeout(request: Request): payload = await request.json() caller_ip = (request.headers.get("x-forwarded-for") or request.client.host).split(",")[0].strip() if not is_mpesa_ip_allowed(caller_ip): raise HTTPException(status_code=403, detail="forbidden")
data = ReversalTimeoutCallback(**payload) # will validate incoming fields # process timeout notification (log, retry logic, etc.) ack = ReversalTimeoutCallbackResponse() return ack.model_dump(mode="json")- Result and timeout endpoints should return acknowledgements (ResultCode 0). This confirms receipt to Safaricom.
- Process the actual reversal result asynchronously based on the data in the result callback.
Result Callback Schema
ReversalResultCallback — posted to ResultURL once the reversal completes
| Parameter | Type | Description |
|---|---|---|
Result.ResultTyperequired int | Integer | 0 = success, 1 = waiting/other. |
Result.ResultCoderequired str | String | Result code for the reversal. Check alongside ResultDesc — Safaricom's sandbox has been observed returning non-zero codes on otherwise-successful reversals. |
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 for the reversal, when available. |
Result.ResultParameters.ResultParameter list[{Key, Value}] | Array | Transaction details, e.g. DebitAccountBalance, Amount, TransCompletedTime, OriginalTransactionID, Charge, CreditPartyPublicName, DebitPartyPublicName. |
Result.ReferenceData.ReferenceItem {Key, Value} | Object | Reference item, e.g. QueueTimeoutURL. |
Timeout Callback Schema
ReversalTimeoutCallback — posted to QueueTimeOutURL if the reversal doesn't complete in time
| Parameter | Type | Description |
|---|---|---|
Result.ResultTyperequired int | Integer | Result type for the timeout notification. |
Result.ResultCoderequired str | String | Code identifying the timeout, e.g. '1'. |
Result.ResultDescrequired str | String | e.g. 'The service request timed out.' |
Result.OriginatorConversationIDrequired str | String | Matches the OriginatorConversationID from the initial request. |
Result.ConversationIDrequired str | String | Matches the ConversationID from the initial acknowledgement. |
Callback Acknowledgement Schemas
What your webhook handler should return to Safaricom
| Parameter | Type | Description |
|---|---|---|
ResultCode int | str | Integer/String | Defaults to 0. Used by ReversalResultCallbackResponse and ReversalTimeoutCallbackResponse — 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). |
Responses & Helpers
{ "OriginatorConversationID": "71840-27539181-07", "ConversationID": "AG_20210709_12346c8e6f8858d7b70a", "ResponseCode": "0", "ResponseDescription": "Accept the service request successfully."}- ReversalResponse provides is_successful which validates the ResponseCode for easy success checks. Available identically on responses from the sync and async clients.
- The SDK normalizes minor provider response typos (e.g., 'OriginatorCoversationID') so fields are accessible reliably.
Error Handling
# Handle errors when calling the servicetry: resp = client.reversal.reverse(...)except Exception as exc: # The underlying HTTP client may raise exceptions on network errors; log and retry as appropriate print("Reversal failed:", exc)- HTTP or network errors raised by the HttpClient bubble up; wrap calls in try/except (sync) or around the awaited call (async) for robust production behavior.
- Tests exercise error flows to ensure exceptions propagate when the HTTP client fails, for both client variants.
Testing & Expected Behaviors
-
Reversal:
- The service posts to
/mpesa/reversal/v1/requestwithAuthorizationheader set viaTokenManager(orAsyncTokenManager). - Responses are returned as
ReversalResponseinstances, whether awaited from the async client or returned directly from the sync client. - The implementation tolerates a common provider typo ("OriginatorCoversationID") and maps it to
OriginatorConversationID.
- The service posts to
-
Validation:
- Incoming payloads are validated against
ReversalResultCallback(directly, or viaprocess_reversal_callback) andReversalTimeoutCallback. Missing required fields or invalid formats will raise validation errors. - Use the provided response schemas for acknowledgements; invalid codes are rejected by the model validator.
- Incoming payloads are validated against
Next Steps
- Implement robust webhook handlers for result and timeout notifications. Log and persist notifications to support reconciliation.
- Add observability and retry strategies around reversal calls and webhook processing to handle transient failures.
Related Documentation
- 📡 Webhook Setup Guide - Best practices for building reliable endpoints
- 🏗️ Production Setup - Go-live checklist, security and monitoring