Business to Customer (B2C)
Send payouts (salary, business or promotional payments) from your organization's disbursement account to customers, and handle result/timeout callbacks.
User Stories
- As a fintech product owner, I want to programmatically initiate B2C payouts so that customers receive funds immediately after approval.
- 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 |
|---|---|---|
OriginatorConversationIDrequired str | String | Unique identifier for this request (used for tracing/reconciliation). |
InitiatorNamerequired str | String | API initiator username configured with Safaricom. |
SecurityCredentialrequired str | String | Encrypted security credential for the initiator. |
CommandIDrequired str | Enum | Type of payment. Allowed: SalaryPayment, BusinessPayment, PromotionPayment. |
Amountrequired int | Integer | Amount to disburse to the recipient. |
PartyArequired int | Integer | Shortcode (Bulk Disbursement Account shortcode) sending the funds. |
PartyBrequired int | Integer | Recipient MSISDN. Will be normalized/validated as a Kenyan phone number. |
Remarksrequired str | String | Free text remarks (max 100 chars). |
QueueTimeOutURLrequired str | String | URL to receive timeout notifications if processing exceeds the provider timeout window. |
ResultURLrequired str | String | URL to receive the final result callback for the payment. |
Occasion str | String | Optional occasion (max 100 chars). |
Response Schema
B2CResponse — the synchronous acknowledgement returned by client.b2c.send_payment(...)
| Parameter | Type | Description |
|---|---|---|
ConversationIDrequired str | null | String | Unique ID generated by M-Pesa for tracking the asynchronous payment conversation. |
OriginatorConversationIDrequired str | null | String | Echoes the request's OriginatorConversationID, for tracking. |
ResponseCoderequired str | int | String/Integer | '0' (or any all-zero string) indicates the request was accepted. |
ResponseDescriptionrequired str | String | Human readable acknowledgement message. |
Overview
Initiate B2C payouts and handle callbacks.
For you to use this API in production you are required to apply for a Bulk Disbursement Account and obtain a Shortcode; you cannot perform these payments from a Pay Bill or Buy Goods (Till Number). To apply for a Bulk Disbursement Account, visit https://www.safaricom.co.ke/business/sme/m-pesa-payment-solutions
- Use the MpesaClient facade (recommended) for a simple, token-managed API to submit B2C payment requests and receive typed response models.
- Use the direct B2C service when you need fine-grained control over requests, headers, or custom behavior (token manager + http client).
- Both are available as sync and async variants (
MpesaClient/AsyncMpesaClient,B2C/AsyncB2C). Use the async variants insideasync defroute handlers, or when you need to fan out several payouts concurrently instead of sending them one at a time.
The facade handles authentication and returns Pydantic models (responses & helpers) so you can focus on business logic.
Quick Setup (Sync)
# Example: using the MpesaClient facade to submit a B2C payment and inspect the typed response.from mpesakit.client import MpesaClientfrom mpesakit.b2c import B2CCommandIDType, B2CResponse
# Initialize the client with your credentials and environmentclient = MpesaClient(consumer_key="...", consumer_secret="...", environment="sandbox")
# Build and send a B2C payment requestresp: B2CResponse = client.b2c.send_payment( originator_conversation_id="ocid-1234-5678", initiator_name="api_initiator", security_credential="ENCRYPTED_SECURITY_CREDENTIAL", command_id=B2CCommandIDType.BusinessPayment, amount=1500, party_a="600999", # Bulk disbursement shortcode party_b="254712345678", # Recipient MSISDN (normalized by SDK) remarks="Salary payout", queue_timeout_url="https://example.com/b2c/timeout", result_url="https://example.com/b2c/result", occasion="JulySalary",)
# Inspect the typed responseif resp.is_successful: print("B2C sent:", resp.ResponseDescription)else: print("B2C failed:", resp.ResponseDescription, "code:", resp.ResponseCode)- Facade calls return typed responses (e.g., B2CResponse).
- The client manages Authorization headers via the TokenManager automatically.
Quick Setup (Async)
client.b2c.send_payment(...) becomes a coroutine on AsyncMpesaClient. Use it in async route handlers, or to send a batch of payouts concurrently with asyncio.gather instead of awaiting each one sequentially.
import asynciofrom mpesakit.client import AsyncMpesaClientfrom mpesakit.b2c import B2CCommandIDType, B2CResponse
async def main(): async with AsyncMpesaClient( consumer_key="...", consumer_secret="...", environment="sandbox" ) as client: resp: B2CResponse = await client.b2c.send_payment( originator_conversation_id="ocid-1234-5678", initiator_name="api_initiator", security_credential="ENCRYPTED_SECURITY_CREDENTIAL", command_id=B2CCommandIDType.BusinessPayment, amount=1500, party_a="600999", party_b="254712345678", remarks="Salary payout", queue_timeout_url="https://example.com/b2c/timeout", result_url="https://example.com/b2c/result", occasion="JulySalary", )
if resp.is_successful: print("B2C sent:", resp.ResponseDescription) else: print("B2C failed:", resp.ResponseDescription, "code:", resp.ResponseCode)
asyncio.run(main())For payroll-style runs (many recipients, one request each), gather the coroutines instead of awaiting in a loop:
payouts = [
dict(party_b="2547XXXXXXXX", amount=1500, occasion="JulySalary"),
dict(party_b="2547YYYYYYYY", amount=2200, occasion="JulySalary"),
# ...
]
responses = await asyncio.gather(*(
client.b2c.send_payment(
originator_conversation_id=f"ocid-{p['party_b']}",
initiator_name="api_initiator",
security_credential="ENCRYPTED_SECURITY_CREDENTIAL",
command_id=B2CCommandIDType.SalaryPayment,
amount=p["amount"],
party_a="600999",
party_b=p["party_b"],
remarks="Salary payout",
queue_timeout_url="https://example.com/b2c/timeout",
result_url="https://example.com/b2c/result",
occasion=p["occasion"],
)
for p in payouts
))
Keep Safaricom's rate limits in mind — chunk large batches rather than firing hundreds of requests in one gather.
In a long-lived service, construct AsyncMpesaClient once at startup and call await client.aclose() on shutdown instead of opening a new async with block per request.
Callbacks (Result & Timeout)
Whether the original payout was sent via MpesaClient or AsyncMpesaClient, the callback is just an inbound HTTP POST from Safaricom to your ResultURL/QueueTimeOutURL — it isn't tied to which client variant made the request. You have two ways to validate it: construct the schemas directly (shown below), or use the client's process_b2c_callback helper for the result payload, which does the same validation in one call.
Both MpesaClient and AsyncMpesaClient expose process_b2c_callback(payload), which validates a raw dict into a B2CResultCallback object — equivalent to calling B2CResultCallback.model_validate(payload) yourself, just one call shorter. It's plain validation with no network I/O, so it's never awaited even on the async client:
callback = client.process_b2c_callback(payload) # same on MpesaClient or AsyncMpesaClient
There's no dedicated timeout-callback helper on the mixin, so timeout payloads are still validated directly against B2CTimeoutCallback, as shown in the full example below.
# Minimal FastAPI webhook receivers for B2C Result & Timeout callbacks.# - Validates caller IP using is_mpesa_ip_allowed# - Parses payload into SDK schemas (B2CResultCallback / B2CTimeoutCallback)# - 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 JSONResponsefrom mpesakit.security import is_mpesa_ip_allowedfrom mpesakit.client import MpesaClient # or AsyncMpesaClient — process_b2c_callback is identical on bothfrom mpesakit.b2c.schemas import ( B2CResultCallback, B2CTimeoutCallback, B2CResultCallbackResponse, B2CTimeoutCallbackResponse,)import logging
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/b2c/result")async def b2c_result(request: Request): remote_ip = _get_remote_ip(request) if not is_mpesa_ip_allowed(remote_ip): log.warning("Rejected B2C 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=B2CResultCallbackResponse(ResultCode=1, ResultDesc="Invalid JSON payload.").model_dump(), )
try: # Equivalent to B2CResultCallback.model_validate(payload) callback = client.process_b2c_callback(payload) except Exception as exc: log.exception("B2C result callback validation error: %s", exc) return JSONResponse( status_code=400, content=B2CResultCallbackResponse(ResultCode=1, ResultDesc=f"Invalid payload: {exc}").model_dump(), )
# TODO: persist callback (DB/queue) for reconciliation and business processing. log.info( "Received B2C result: OriginatorConversationID=%s ConversationID=%s ResultCode=%s", callback.Result.OriginatorConversationID, callback.Result.ConversationID, callback.Result.ResultCode, )
ack = B2CResultCallbackResponse() # default success ack return JSONResponse(status_code=200, content=ack.model_dump())
@app.post("/webhooks/b2c/timeout")async def b2c_timeout(request: Request): remote_ip = _get_remote_ip(request) if not is_mpesa_ip_allowed(remote_ip): log.warning("Rejected B2C 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=B2CTimeoutCallbackResponse(ResultCode=1, ResultDesc="Invalid JSON payload.").model_dump(), )
try: callback = B2CTimeoutCallback.model_validate(payload) except Exception as exc: log.exception("B2C timeout callback validation error: %s", exc) return JSONResponse( status_code=400, content=B2CTimeoutCallbackResponse(ResultCode=1, ResultDesc=f"Invalid payload: {exc}").model_dump(), )
# TODO: persist/queue timeout notification and trigger compensating workflows. log.info( "Received B2C timeout: OriginatorConversationID=%s ConversationID=%s ResultCode=%s", callback.Result.OriginatorConversationID, callback.Result.ConversationID, callback.Result.ResultCode, )
ack = B2CTimeoutCallbackResponse() # default success ack return JSONResponse(status_code=200, content=ack.model_dump(mode="json"))- Validate incoming payloads against the provided schemas (
B2CResultCallback/B2CTimeoutCallback), either directly or viaprocess_b2c_callbackfor the result payload. - Persist notifications for reconciliation before returning a success acknowledgement.
- Use
is_mpesa_ip_allowedto restrict callers to known Safaricom IP ranges. - Run your app behind a trusted proxy and ensure
X-Forwarded-Forhandling is correct for IP validation.
Result Callback Schema
B2CResultCallback — posted to ResultURL once the payout completes or fails
| Parameter | Type | Description |
|---|---|---|
Result.ResultTyperequired int | Integer | 0 = success, 1 = failure. |
Result.ResultCoderequired int | str | Integer/String | 0 indicates the payout 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 for the payout, when successful. |
Result.ResultParameters list[{Key, Value}] | Array | Key/value parameters. Exposed via B2CResultMetadata convenience properties: transaction_amount (TransactionAmount), transaction_receipt (TransactionReceipt), recipient_is_registered (B2CRecipientIsRegisteredCustomer), receiver_party_public_name (ReceiverPartyPublicName), transaction_completed_datetime (TransactionCompletedDateTime), charges_paid_account_available_funds, utility_account_available_funds, working_account_available_funds. |
Timeout Callback Schema
B2CTimeoutCallback — posted to QueueTimeOutURL if the payout doesn't complete in time
| Parameter | Type | Description |
|---|---|---|
Result.ResultTyperequired int | Integer | Result type for the timeout notification. |
Result.ResultCoderequired int | str | Integer/String | Code identifying the timeout. |
Result.ResultDescrequired str | String | Human readable description of the timeout. |
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 B2CResultCallbackResponse and B2CTimeoutCallbackResponse — 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). |
Schemas & Runtime Behavior
CommandIDmust be one of the supported enum values (SalaryPayment,BusinessPayment,PromotionPayment). Invalid CommandID raises a validation error.PartyB(recipient) is normalized/validated as a Kenyan phone number; invalid numbers raise a validation error.RemarksandOccasionare length-restricted (100 characters); exceeding the limit raises a validation error.- These validations run the same way whether the request is built for
B2CorAsyncB2C— only the transport differs.
B2CResponse.is_successfultreats any all-zero string (e.g., "0" or "00000000") as success. Empty strings or mixed non-zero codes are not considered successful.B2CResultMetadataexposes convenience properties for common result parameters:transaction_amount,transaction_receipt,recipient_is_registered(returns True/False/None),receiver_party_public_name,transaction_completed_datetime,charges/utility/working account balances.
Error Handling
try: resp = client.b2c.send_payment(...)except Exception as exc: print("Error sending B2C payment:", exc)- The SDK normalizes minor upstream inconsistencies so fields remain accessible (tests show the SDK tolerates typical provider quirks).
- Ensure you log and surface provider conversation IDs (
OriginatorConversationID/ConversationID) for troubleshooting and reconciliation. - Network/HTTP errors raised by the underlying HttpClient bubble up the same way whether you're calling the sync or async service — wrap the call (or the
await) in try/except.
Testing & Expectations
-
Send payment:
- The service posts payment requests to the provider using the configured
HttpClient(orMpesaAsyncHttpClient) and supplies Authorization viaTokenManager(orAsyncTokenManager). - Successful responses are returned as
B2CResponseinstances; callis_successfulto check outcome. This holds whether the response came back directly (sync) or viaawait(async).
- The service posts payment requests to the provider using the configured
-
Request validation:
- Invalid
CommandID, malformedPartyBor overly longRemarks/Occasionshould raise validation errors during model construction.
- Invalid
-
Result metadata:
- ResultParameters are provided as a list of Key/Value items. The SDK caches these into a dictionary so callers can access values using typed helper properties (
transaction_amount,transaction_receipt, etc.). recipient_is_registeredreturns True for 'Y', False for 'N', and None for missing/invalid values.
- ResultParameters are provided as a list of Key/Value items. The SDK caches these into a dictionary so callers can access values using typed helper properties (
Next Steps
- Implement robust webhook receivers for
ResultURLandQueueTimeOutURL; persist notifications and return the acknowledged response model. - Add observability around sends and callbacks, and establish retry/compensation flows for transient failures.
Related Documentation
- 📡 Webhook Setup Guide - Reliable endpoints and security best practices
- 🏗️ Production Setup - Go-live checklist, security and monitoring