Initiate Payout
The Initiate Payout endpoint allows you to send money to a customer. Use this to process refunds, disbursements, commission payments, or any other payout scenarios.
Endpoint
POST /payments/payouts/initiate
Authentication
Required: Yes (Bearer Token)
Authorization: Bearer YOUR_ACCESS_TOKEN
Request
Request Body
{
"payto": "+254712345678",
"amount": "1000.00",
"ref_no": "PAYOUT-2024-001",
"callback_url": "https://yourdomain.com/webhook",
"category": "other",
"narration": "Commission Payment #456",
"channel": 63902,
"app_code": 12345
}
Field Descriptions
| Field | Type | Required | Description |
|---|---|---|---|
payto | string | Yes | Recipient phone number (E.164 format, e.g., +254712345678) or bank account number |
amount | string | Yes | Payout amount in KES (decimal with 2 places) |
ref_no | string | Yes | Your unique reference number (max 50 chars) |
callback_url | string | Yes | URL for transaction notifications |
category | string | No | Transaction category: ecommerce, gaming, adult_content, forex, or other (default) |
narration | string | No | Disbursement description/narration sent to payment gateway (max 200 chars, default: "Disbursement") |
channel | integer | No | Payment channel code (e.g., 63902 for M-Pesa). Not required when payto is a phone number - the channel is automatically inferred from the phone carrier (Safaricom → M-Pesa, Airtel → Airtel Money, Telkom → T-Kash). Required for bank transfers - specify the bank channel code. See List Channels for available channels. |
app_code | integer | Yes | App code for multi-app scenarios |
Response
Success Response (200 OK)
{
"id": "PAYOUT-2024-001-ABC123",
"payto": "+254712345678",
"amount": "1000.00",
"ref_no": "PAYOUT-2024-001",
"status": "pending",
"category": "other",
"channel": 63902,
"channel_name": "M-Pesa",
"channel_type": "Mobile Money",
"created_at": "12/04/2024 14:30:45"
}
Response Fields
| Field | Type | Description |
|---|---|---|
id | string | Unique payout transaction identifier |
payto | string | Recipient phone number |
amount | string | Payout amount (KES) |
ref_no | string | Your reference number |
status | string | Transaction status |
category | string | Transaction category |
channel | integer | Payment channel code |
channel_name | string | Payment channel name (e.g., "M-Pesa", "Airtel Money", "NCBA Bank") |
channel_type | string | Channel type ("Mobile Money" or "Bank") |
created_at | string | Timestamp (DD/MM/YYYY HH:MM:SS) |
Status Codes
| Code | Status | Description |
|---|---|---|
| 200 | Success | Payout initiated successfully |
| 400 | Bad Request | Invalid parameters |
| 401 | Unauthorized | Invalid or expired token |
| 403 | Forbidden | Merchant not approved or app inactive |
| 404 | Not Found | App not found |
| 500 | Server Error | Internal server error |
Error Responses
400 Bad Request - Invalid Phone Number
{
"detail": "Invalid phone number"
}
400 Bad Request - Invalid Amount
{
"detail": "Invalid amount"
}
400 Bad Request - Insufficient Balance
{
"detail": "Insufficient balance in payout wallet"
}
400 Bad Request - Invalid Channel
{
"detail": "Invalid channel code: 99999"
}
400 Bad Request - Channel Required for Bank Transfer
{
"detail": "Channel is required for bank account payouts"
}
401 Unauthorized
{
"detail": "Invalid or expired token"
}
403 Forbidden - Merchant Not Approved
{
"detail": "Your merchant profile is not approved. Please ensure your profile has been verified before initiating payouts."
}
403 Forbidden - App Inactive
{
"detail": "App is not active"
}
403 Forbidden - Product Not Enabled
{
"detail": "App does not have payouts product enabled"
}
403 Forbidden - IP Not Allowed
{
"detail": "Access denied: IP address not allowed"
}
This error occurs when your app has IP restrictions configured and the request is coming from an IP address that is not in the allowed list. To fix this:
- Add your server's IP address to the allowed IPs list in your app settings
- Or remove all IP restrictions if you want to allow requests from any IP address
404 Not Found
{
"detail": "App not found"
}
Examples
- cURL
- Python
- JavaScript
curl -X POST https://sandbox.api.jpay.africa/api/v1/payments/payouts/initiate \
-H "Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9..." \
-H "Content-Type: application/json" \
-d '{
"payto": "+254712345678",
"amount": "1000.00",
"ref_no": "PAYOUT-2024-001",
"callback_url": "https://yourdomain.com/webhook",
"category": "other",
"narration": "Commission Payment #456",
"channel": 63902
}'
import requests
def initiate_payout(access_token, payto, amount, ref_no):
headers = {
'Authorization': f'Bearer {access_token}',
'Content-Type': 'application/json'
}
payload = {
'payto': payto,
'amount': str(amount),
'ref_no': ref_no,
'callback_url': 'https://yourdomain.com/webhook',
'category': 'other',
'narration': 'Commission Payment #456',
'channel': 63902 # Optional: M-Pesa channel code (auto-inferred for phone numbers)
}
response = requests.post(
'https://sandbox.api.jpay.africa/api/v1/payments/payouts/initiate',
headers=headers,
json=payload
)
if response.status_code == 200:
return response.json()
else:
print(f"Error: {response.status_code}")
print(response.json())
return None
# Usage
payout = initiate_payout(
access_token='your_token',
payto='+254712345678',
amount=1000.00,
ref_no='PAYOUT-2024-001'
)
async function initiatePayout(accessToken, payto, amount, refNo) {
const response = await fetch(
'https://sandbox.api.jpay.africa/api/v1/payments/payouts/initiate',
{
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
payto,
amount: amount.toString(),
ref_no: refNo,
callback_url: 'https://yourdomain.com/webhook',
category: 'other',
narration: 'Commission Payment #456',
channel: 63902 // Optional: M-Pesa channel code (auto-inferred for phone numbers)
})
}
);
const data = await response.json();
if (response.ok) {
console.log('Payout initiated:', data);
return data;
} else {
console.error('Error:', data);
throw new Error(data.detail);
}
}
// Usage
try {
const payout = await initiatePayout(
'your_token',
'+254712345678',
1000.00,
'PAYOUT-2024-001'
);
} catch (error) {
console.error('Failed to initiate payout:', error);
}
Payout Categories
Use appropriate categories for payout organization and compliance:
| Category | Description |
|---|---|
ecommerce | E-commerce and online shopping payouts |
gaming | Gaming and entertainment payouts |
adult_content | Adult content related payouts |
forex | Forex and trading payouts |
other | Other payout types (default) |
Category Usage: Categories help with transaction organization, reporting, and compliance. Choose the category that best matches your business type.
Payout Lifecycle
Initiated
↓
[pending] ← Processing (2-4 hours)
↓
[completed] ← Successfully delivered
↓
Recipient receives funds
or
[failed] ← Delivery failed
↓
Automatic refund to sender's wallet
Payout Wallet
Your merchant account has a Payout Wallet that holds funds for sending payouts:
- Funding: Payouts are debited from your collection wallet or funded through bank deposit
- Balance: Check your balance before initiating payouts to avoid failures
- Settlement: Funds settle within 2-4 hours for mobile, 24-48 hours for bank transfers
Important Notes
Before initiating payouts, ensure:
- ✅ Your merchant account is APPROVED (profile_status = approved)
- ✅ Your app has payouts product enabled
- ✅ Your app is ACTIVE
- ✅ Phone number is in E.164 format (e.g., +254712345678)
- ✅ Amount has 2 decimal places (e.g., 1000.00)
- ✅ Your payout wallet has sufficient balance
- ✅ Your server's IP address is whitelisted in the app's allowed IPs (if IP restrictions are configured)
Best Practices
Phone Number Validation
Always validate and format phone numbers before sending:
import phonenumbers
def format_phone_number(phone):
try:
parsed = phonenumbers.parse(phone, "KE")
if phonenumbers.is_valid_number(parsed):
return phonenumbers.format_number(parsed, phonenumbers.PhoneNumberFormat.E164)
except phonenumbers.NumberParseException:
pass
return None
Amount Validation
Validate amounts before sending:
from decimal import Decimal
def validate_amount(amount):
try:
decimal_amount = Decimal(str(amount))
# Check range
if decimal_amount < Decimal('1.00') or decimal_amount > Decimal('999999.99'):
return False, "Amount must be between 1.00 and 999999.99"
# Check decimal places
if decimal_amount.as_tuple().exponent < -2:
return False, "Amount must have maximum 2 decimal places"
return True, decimal_amount
except:
return False, "Invalid amount format"
Idempotency
Use unique reference numbers to ensure idempotency:
import uuid
from datetime import datetime
def generate_unique_ref_no(prefix="PAYOUT"):
timestamp = datetime.now().strftime("%Y%m%d%H%M%S")
unique_id = str(uuid.uuid4())[:8].upper()
return f"{prefix}-{timestamp}-{unique_id}"
# Usage
ref_no = generate_unique_ref_no()
# Result: PAYOUT-20240412143045-ABC12345
Error Handling with Retry
Implement comprehensive error handling:
def create_payout_with_retry(token_manager, payto, amount, ref_no, max_retries=3):
for attempt in range(max_retries):
try:
headers = token_manager.get_auth_headers()
response = requests.post(
'https://sandbox.api.jpay.africa/api/v1/payments/payouts/initiate',
headers=headers,
json={
'payto': payto,
'amount': str(amount),
'ref_no': ref_no
}
)
if response.status_code == 200:
return response.json()
elif response.status_code == 401:
# Token expired, refresh and retry
token_manager.refresh_access_token()
continue
elif response.status_code == 403:
# Merchant not approved or insufficient balance
print("Payout cannot be processed")
break
elif response.status_code == 429:
# Rate limited, wait and retry
time.sleep(2 ** attempt)
continue
else:
print(f"Unexpected error: {response.status_code}")
break
except requests.RequestException as e:
print(f"Request failed: {e}")
if attempt < max_retries - 1:
time.sleep(2 ** attempt)
return None
Wallet Balance Check
Check your payout wallet balance before processing:
def check_payout_wallet(access_token):
headers = {
'Authorization': f'Bearer {access_token}',
'Content-Type': 'application/json'
}
response = requests.get(
'https://sandbox.api.jpay.africa/api/v1/wallets/balance',
headers=headers
)
if response.status_code == 200:
data = response.json()
return {
'payout_wallet_balance': data['payout_wallet_balance'],
'collection_wallet_balance': data['collection_wallet_balance'],
'total_balance': data['total_balance']
}
return None
For detailed information on checking wallet balances, see Get Wallet Balance.
Webhook Notifications
When a payout is processed, JPay will POST to your callback_url with a standardized payload:
{
"notification": {
"type": "payout",
"event": "transaction.completed"
},
"data": {
"result_code": 0,
"result_description": "The service request is processed successfully",
"amount": "1000.00",
"ref_no": "PAYOUT-2024-001",
"beneficiary": {
"account": "+254712345678",
"kyc": null
},
"transaction_date": "2024-04-12T14:35:00.000Z",
"external_ref": "PR52345",
"trans_id": "QGX7654321"
}
}
Webhook Payload Structure
Notification Object
| Field | Type | Description |
|---|---|---|
type | string | Transaction type: collection or payout |
event | string | Event type: transaction.created, transaction.failed, or transaction.completed |
Data Object
| Field | Type | Description |
|---|---|---|
result_code | integer | Result code: 0 for success, any other value indicates failure |
result_description | string | Description of the transaction result or failure reason |
amount | string | Transaction amount in KES |
ref_no | string | Your reference number provided during initiation |
beneficiary | object | Beneficiary information |
beneficiary.account | string | Recipient phone number (payto field) in E.164 format |
beneficiary.kyc | string or null | Full names of the recipient. Currently null for payouts |
transaction_date | string | Transaction timestamp in ISO 8601 format (UTC) |
external_ref | string | External transaction reference from the payment gateway (may be empty) |
trans_id | string | Payment gateway transaction code (may be empty) |
Event Types
transaction.created- Transaction has been initiatedtransaction.completed- Transaction completed successfully (result_code = 0)transaction.failed- Transaction failed (result_code ≠ 0)
Result Code: A result_code of 0 indicates successful completion. Any other value indicates a failure, with details provided in result_description.
Transaction Date: The transaction_date field uses the completion time from the payment gateway when available, otherwise falls back to the creation time.
Next Steps
- List Payouts - Retrieve payout history
- Error Handling - Comprehensive error handling guide
- Best Practices - Integration best practices