Skip to main content

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

ParameterTypeDescription
OriginatorConversationIDrequired
str
StringUnique identifier for this request (used for tracing/reconciliation).
InitiatorNamerequired
str
StringAPI initiator username configured with Safaricom.
SecurityCredentialrequired
str
StringEncrypted security credential for the initiator.
CommandIDrequired
str
EnumType of payment. Allowed: SalaryPayment, BusinessPayment, PromotionPayment.
Amountrequired
int
IntegerAmount to disburse to the recipient.
PartyArequired
int
IntegerShortcode (Bulk Disbursement Account shortcode) sending the funds.
PartyBrequired
int
IntegerRecipient MSISDN. Will be normalized/validated as a Kenyan phone number.
Remarksrequired
str
StringFree text remarks (max 100 chars).
QueueTimeOutURLrequired
str
StringURL to receive timeout notifications if processing exceeds the provider timeout window.
ResultURLrequired
str
StringURL to receive the final result callback for the payment.
Occasion
str
StringOptional occasion (max 100 chars).

Response Schema

B2CResponse — the synchronous acknowledgement returned by client.b2c.send_payment(...)

ParameterTypeDescription
ConversationIDrequired
str | null
StringUnique ID generated by M-Pesa for tracking the asynchronous payment conversation.
OriginatorConversationIDrequired
str | null
StringEchoes the request's OriginatorConversationID, for tracking.
ResponseCoderequired
str | int
String/Integer'0' (or any all-zero string) indicates the request was accepted.
ResponseDescriptionrequired
str
StringHuman readable acknowledgement message.

Overview

Initiate B2C payouts and handle callbacks.

Quick Setup (Sync)

Python
# Example: using the MpesaClient facade to submit a B2C payment and inspect the typed response.
from mpesakit.client import MpesaClient
from mpesakit.b2c import B2CCommandIDType, B2CResponse
# Initialize the client with your credentials and environment
client = MpesaClient(consumer_key="...", consumer_secret="...", environment="sandbox")
# Build and send a B2C payment request
resp: 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 response
if resp.is_successful:
print("B2C sent:", resp.ResponseDescription)
else:
print("B2C failed:", resp.ResponseDescription, "code:", resp.ResponseCode)

Quick Setup (Async)

Python
import asyncio
from mpesakit.client import AsyncMpesaClient
from 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())

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.

Webhook receivers (FastAPI)
# 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, status
from fastapi.responses import JSONResponse
from mpesakit.security import is_mpesa_ip_allowed
from mpesakit.client import MpesaClient # or AsyncMpesaClient — process_b2c_callback is identical on both
from 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"))

Result Callback Schema

B2CResultCallback — posted to ResultURL once the payout completes or fails

ParameterTypeDescription
Result.ResultTyperequired
int
Integer0 = success, 1 = failure.
Result.ResultCoderequired
int | str
Integer/String0 indicates the payout succeeded; any other value is a failure code.
Result.ResultDescrequired
str
StringHuman readable result description.
Result.OriginatorConversationIDrequired
str
StringMatches the OriginatorConversationID from the initial request.
Result.ConversationIDrequired
str
StringMatches the ConversationID from the initial acknowledgement.
Result.TransactionID
str | null
StringM-Pesa transaction ID for the payout, when successful.
Result.ResultParameters
list[{Key, Value}]
ArrayKey/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

ParameterTypeDescription
Result.ResultTyperequired
int
IntegerResult type for the timeout notification.
Result.ResultCoderequired
int | str
Integer/StringCode identifying the timeout.
Result.ResultDescrequired
str
StringHuman readable description of the timeout.
Result.OriginatorConversationIDrequired
str
StringMatches the OriginatorConversationID from the initial request.
Result.ConversationIDrequired
str
StringMatches the ConversationID from the initial acknowledgement.

Callback Acknowledgement Schemas

What your webhook handler should return to Safaricom

ParameterTypeDescription
ResultCode
int | str
Integer/StringDefaults to 0. Used by B2CResultCallbackResponse and B2CTimeoutCallbackResponse — the typed models to return from your /result and /timeout handlers respectively.
ResultDesc
str
StringDefaults to 'Result received and processed successfully.' (result) or 'Timeout notification received and processed successfully.' (timeout).

Schemas & Runtime Behavior

Error Handling

Python
try:
resp = client.b2c.send_payment(...)
except Exception as exc:
print("Error sending B2C payment:", exc)

Testing & Expectations

  • Send payment:

    • The service posts payment requests to the provider using the configured HttpClient (or MpesaAsyncHttpClient) and supplies Authorization via TokenManager (or AsyncTokenManager).
    • Successful responses are returned as B2CResponse instances; call is_successful to check outcome. This holds whether the response came back directly (sync) or via await (async).
  • Request validation:

    • Invalid CommandID, malformed PartyB or overly long Remarks/Occasion should raise validation errors during model construction.
  • 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_registered returns True for 'Y', False for 'N', and None for missing/invalid values.

Next Steps