Documentation Index Fetch the complete documentation index at: https://mintlify.com/mutuiris/voicepact/llms.txt
Use this file to discover all available pages before exploring further.
Overview
VoicePact receives real-time notifications from Africa’s Talking through webhooks for voice calls, SMS delivery, payment status, and other events. This guide covers webhook configuration, security, and implementation.
Webhook Architecture
Event Flow
Webhook Types
VoicePact handles four types of webhooks:
Type Endpoint Purpose Source File Voice /api/v1/voice/webhookCall recordings, status app/api/v1/endpoints/voice.py:223SMS /api/v1/sms/webhookDelivery reports, replies app/api/v1/endpoints/sms.py:293Payment /api/v1/payments/webhookPayment confirmations app/api/v1/endpoints/payments.py:96USSD /api/v1/ussd/Interactive sessions app/api/v1/endpoints/ussd.py:19
Configuration
Environment Variables
# Webhook Configuration
WEBHOOK_BASE_URL = https://your-domain.com
WEBHOOK_SECRET = your_webhook_secret_key_here
# Africa's Talking Credentials
AT_USERNAME = your_username
AT_API_KEY = your_api_key
Configuration Class
From app/core/config.py:172-182:
class Settings ( BaseSettings ):
webhook_base_url: Optional[ str ] = Field(
default = None ,
description = "Base URL for webhooks"
)
webhook_secret: SecretStr = Field(
default_factory = lambda : SecretStr(secrets.token_urlsafe( 32 )),
description = "Secret for webhook signature validation"
)
Africa’s Talking Dashboard Setup
Login to Africa’s Talking dashboard
Navigate to your application settings
Configure webhook URLs :
Voice callback: https://your-domain.com/api/v1/voice/webhook
SMS delivery reports: https://your-domain.com/api/v1/sms/webhook
Payment notifications: https://your-domain.com/api/v1/payments/webhook
USSD callback: https://your-domain.com/api/v1/ussd/
Webhook Security
Signature Verification
All webhooks should be verified using HMAC signatures:
Implementation
From app/services/africastalking_client.py:119-129:
def verify_webhook_signature ( self , payload : str , signature : str ) -> bool :
"""Verify webhook signature using HMAC-SHA256"""
if not self .webhook_secret or not signature:
return False
expected_signature = hmac.new(
self .webhook_secret.encode(),
payload.encode(),
hashlib.sha256
).hexdigest()
return hmac.compare_digest(
f "sha256= { expected_signature } " ,
signature
)
Usage in Endpoints
from fastapi import HTTPException
@router.post ( "/webhook" )
async def secure_webhook (
request : Request,
at_client : AfricasTalkingClient = Depends(get_africastalking_client)
):
# Get request body and signature
body = await request.body()
signature = request.headers.get( "X-Africastalking-Signature" )
# Verify signature
if not at_client.verify_webhook_signature(
payload = body.decode(),
signature = signature
):
logger.warning( "Invalid webhook signature" )
raise HTTPException( status_code = 401 , detail = "Invalid signature" )
# Process webhook
...
Best Practices
Always verify signatures in production
Use HTTPS for webhook endpoints
Rotate secrets regularly
Log suspicious requests
Implement rate limiting
Return 200 OK quickly to avoid retries
Voice Webhooks
Endpoint Configuration
Endpoint: POST /api/v1/voice/webhook
Location: app/api/v1/endpoints/voice.py:223-258
Africa’s Talking sends voice webhooks with the following data:
{
"sessionId" : "ATVId_9c5f41e..." ,
"phoneNumber" : "+254712345678" ,
"recordingUrl" : "https://voice.africastalking.com/recordings/..." ,
"duration" : 120 ,
"status" : "completed" ,
"dialDestinationNumber" : "+254723456789" ,
"isActive" : "0" ,
"dtmfDigits" : "" ,
"currencyCode" : "KES" ,
"amount" : "3.50"
}
Implementation
@router.post ( "/webhook" )
async def voice_webhook (
request : Request,
at_client : AfricasTalkingClient = Depends(get_africastalking_client),
db : AsyncSession = Depends(get_db)
):
try :
# Parse form data
form_data = await request.form()
session_id = form_data.get( "sessionId" )
phone_number = form_data.get( "phoneNumber" )
recording_url = form_data.get( "recordingUrl" )
duration = form_data.get( "duration" )
status = form_data.get( "status" , "completed" )
logger.info( f "Voice webhook: session= { session_id } , status= { status } " )
# Find recording in database
if session_id:
result = await db.execute(
select(VoiceRecording).where(
VoiceRecording.recording_id == session_id
)
)
recording = result.scalar_one_or_none()
if recording:
# Update recording details
recording.recording_url = recording_url
recording.duration = int (duration) if duration else None
recording.processing_status = status
await db.commit()
logger.info( f "Updated recording { session_id } " )
return { "status" : "webhook_processed" , "session_id" : session_id}
except Exception as e:
logger.error( f "Voice webhook error: { e } " )
return { "status" : "webhook_error" , "error" : str (e)}
Voice Status Codes
Status Description CompletedCall completed successfully AnsweredCall was answered NotAnsweredCall not answered BusyLine was busy InvalidPhoneNumberInvalid number format
Processing Recording
After receiving a voice webhook, process the recording:
async def process_voice_recording ( recording_url : str , recording_id : str ):
"""Process voice recording after webhook"""
voice_processor = await get_voice_processor()
# Download and transcribe
result = await voice_processor.process_voice_to_contract(
audio_source = recording_url,
is_url = True
)
# Extract contract terms
transcript = result[ "transcript" ]
terms = result[ "terms" ]
# Create contract
contract_generator = await get_contract_generator()
contract_result = await contract_generator.process_voice_to_contract(
transcript = transcript,
terms = ContractTerms( ** terms),
parties = result.get( "parties" , []),
contract_type = "agricultural_supply"
)
logger.info( f "Contract { contract_result[ 'contract_id' ] } created from recording" )
SMS Webhooks
Endpoint Configuration
Endpoint: POST /api/v1/sms/webhook
Location: app/api/v1/endpoints/sms.py:293-328
Incoming SMS
{
"from" : "+254712345678" ,
"to" : "40404" ,
"text" : "YES-AG-2024-001" ,
"date" : "2024-03-06 10:30:00" ,
"id" : "ATXid_sample123" ,
"linkId" : "SampleLinkId123" ,
"networkCode" : "63902"
}
Delivery Report
{
"id" : "ATXid_sample123" ,
"status" : "Success" ,
"phoneNumber" : "+254712345678" ,
"networkCode" : "63902" ,
"retryCount" : 0 ,
"failureReason" : ""
}
Implementation
@router.post ( "/webhook" )
async def sms_webhook ( request : Request):
try :
form_data = await request.form()
webhook_data = dict (form_data)
logger.info( f "SMS webhook: { webhook_data } " )
# Check if it's an incoming message or delivery report
if "from" in webhook_data:
# Incoming SMS
return await handle_incoming_sms(webhook_data)
else :
# Delivery report
return await handle_delivery_report(webhook_data)
except Exception as e:
logger.error( f "SMS webhook error: { e } " )
return { "status" : "webhook_error" , "error" : str (e)}
Handling Incoming SMS
async def handle_incoming_sms ( webhook_data : Dict[ str , Any]):
"""Process incoming SMS message"""
phone_number = webhook_data.get( "from" )
message = webhook_data.get( "text" , "" ).upper().strip()
# Handle contract confirmations
if message.startswith( "YES-" ):
contract_id = message.split( "-" , 1 )[ 1 ]
# Update contract status
await confirm_contract(contract_id, phone_number)
# Send confirmation SMS
at_client = await get_africastalking_client()
await at_client.send_sms(
message = f "Contract { contract_id } confirmed. Thank you!" ,
recipients = [phone_number]
)
return {
"status" : "confirmed" ,
"contract_id" : contract_id,
"phone_number" : phone_number
}
elif message.startswith( "NO-" ):
contract_id = message.split( "-" , 1 )[ 1 ]
# Reject contract
await reject_contract(contract_id, phone_number)
return {
"status" : "rejected" ,
"contract_id" : contract_id,
"phone_number" : phone_number
}
# Handle delivery confirmations
elif message.startswith( "ACCEPT-" ):
contract_id = message.split( "-" , 1 )[ 1 ]
await accept_delivery(contract_id, phone_number)
return { "status" : "delivery_accepted" , "contract_id" : contract_id}
elif message.startswith( "DISPUTE-" ):
contract_id = message.split( "-" , 1 )[ 1 ]
await dispute_delivery(contract_id, phone_number)
return { "status" : "delivery_disputed" , "contract_id" : contract_id}
# Unknown command
return {
"status" : "webhook_received" ,
"message" : "Message received but not processed"
}
SMS Status Codes
Status Description SuccessMessage delivered successfully SentMessage sent to carrier QueuedMessage queued for sending FailedDelivery failed RejectedMessage rejected by carrier
Payment Webhooks
Endpoint Configuration
Endpoint: POST /api/v1/payments/webhook
Location: app/api/v1/endpoints/payments.py:96-136
Successful Payment
{
"transactionId" : "ATPid_SampleTxnId123" ,
"category" : "MobileCheckout" ,
"provider" : "Mpesa" ,
"providerRefId" : "MPESA_REF_123" ,
"providerChannel" : "525900" ,
"clientAccount" : "VoicePact" ,
"productName" : "VoicePact" ,
"sourceType" : "PhoneNumber" ,
"source" : "+254712345678" ,
"destinationType" : "BankAccount" ,
"destination" : "Payment Wallet" ,
"value" : "KES 50000.00" ,
"transactionFee" : "KES 25.00" ,
"providerFee" : "KES 0.00" ,
"status" : "Success" ,
"description" : "The payment was successful" ,
"requestMetadata" : {
"contract_id" : "AG-2024-001" ,
"payment_id" : "123"
},
"providerMetadata" : {
"name" : "John Farmer" ,
"phoneNumber" : "+254712345678"
},
"transactionDate" : "2024-03-06 10:30:00"
}
Failed Payment
{
"transactionId" : "ATPid_SampleTxnId123" ,
"status" : "Failed" ,
"description" : "Insufficient funds" ,
"phoneNumber" : "+254712345678" ,
"value" : "KES 50000.00" ,
"productName" : "VoicePact"
}
Implementation
@router.post ( "/webhook" )
async def payment_webhook (
request : Request,
at_client : AfricasTalkingClient = Depends(get_africastalking_client),
db : AsyncSession = Depends(get_db)
):
try :
form_data = await request.form()
transaction_id = form_data.get( "transactionId" )
status = form_data.get( "status" , "failed" )
phone_number = form_data.get( "source" ) or form_data.get( "phoneNumber" )
amount = form_data.get( "value" )
description = form_data.get( "description" )
logger.info(
f "Payment webhook: tx= { transaction_id } , "
f "status= { status } , amount= { amount } "
)
# Find payment record
if transaction_id:
result = await db.execute(
select(Payment).where(
Payment.external_transaction_id == transaction_id
)
)
payment = result.scalar_one_or_none()
if payment:
# Update payment status
if status.lower() == "success" :
payment.status = PaymentStatus. LOCKED
payment.confirmed_at = datetime.utcnow()
# Notify parties
await notify_payment_success(
payment.contract_id,
float (payment.amount),
payment.currency
)
else :
payment.status = PaymentStatus. FAILED
payment.failure_reason = description
# Notify failure
await notify_payment_failure(
payment.contract_id,
phone_number,
description
)
await db.commit()
logger.info( f "Payment { payment.id } updated: { payment.status.value } " )
return {
"status" : "webhook_processed" ,
"transaction_id" : transaction_id
}
except Exception as e:
logger.error( f "Payment webhook error: { e } " )
return { "status" : "webhook_error" , "error" : str (e)}
Payment Status Codes
Status Description SuccessPayment completed successfully PendingConfirmationAwaiting user PIN entry PendingValidationBeing validated by provider FailedPayment failed CancelledUser cancelled payment
USSD Webhooks
Endpoint Configuration
Endpoint: POST /api/v1/ussd/
Location: app/api/v1/endpoints/ussd.py:19-61
{
"sessionId" : "ATUid_session123" ,
"serviceCode" : "*483#" ,
"phoneNumber" : "+254712345678" ,
"text" : "1*2*3" ,
"networkCode" : "63902"
}
Implementation
@router.post ( "/" )
async def ussd_handler (
request : Request,
sessionId : str = Form( ... ),
serviceCode : str = Form( ... ),
phoneNumber : str = Form( ... ),
text : str = Form( "" ),
at_client : AfricasTalkingClient = Depends(get_africastalking_client),
db : AsyncSession = Depends(get_db)
):
"""Main USSD handler"""
try :
# Get or create session
session = await get_or_create_session(sessionId, phoneNumber, db)
# Parse user input
user_input = text.split( '*' )[ - 1 ] if text else ""
# First request - show main menu
if not text:
response = main_menu()
session.current_menu = "main"
else :
# Handle menu navigation
response = await handle_menu_navigation(
session, user_input, phoneNumber, at_client, db
)
# Update session
session.last_input = user_input
session.last_response = response
session.updated_at = datetime.utcnow()
await db.commit()
return response
except Exception as e:
logger.error( f "USSD error: { e } " )
return at_client.build_ussd_response(
"Service unavailable. Please try again." ,
end_session = True
)
USSD responses must be plain text with special prefixes:
CON : Continue session (show menu, wait for input)
END : End session (final message)
# Continue session
response = "CON Welcome to VoicePact \n 1. View Contracts \n 2. Exit"
# End session
response = "END Thank you for using VoicePact!"
Webhook Processing
Data Processing
Process and normalize webhook data:
# From app/services/africastalking_client.py:494-518
async def process_webhook_data (
self ,
webhook_data : Dict[ str , Any]
) -> Dict[ str , Any]:
"""Process and normalize webhook data"""
processed_data = {
'timestamp' : datetime.utcnow().isoformat(),
'original_data' : webhook_data
}
# Extract common fields
if 'status' in webhook_data:
processed_data[ 'status' ] = webhook_data[ 'status' ]
if 'transactionId' in webhook_data:
processed_data[ 'transaction_id' ] = webhook_data[ 'transactionId' ]
if 'phoneNumber' in webhook_data:
processed_data[ 'phone_number' ] = await self .format_phone_number(
webhook_data[ 'phoneNumber' ]
)
if 'amount' in webhook_data:
try :
amount_str = str (webhook_data[ 'amount' ]).replace( ',' , '' )
processed_data[ 'amount' ] = float (amount_str)
except ( ValueError , TypeError ):
processed_data[ 'amount' ] = 0.0
return processed_data
Error Handling
Webhook Failures
@router.post ( "/webhook" )
async def resilient_webhook ( request : Request):
try :
# Process webhook
result = await process_webhook(request)
return result
except Exception as e:
# Log error but return 200 to prevent retries
logger.error( f "Webhook processing error: { e } " , exc_info = True )
# Store failed webhook for manual review
await store_failed_webhook(
endpoint = request.url.path,
payload = await request.body(),
error = str (e)
)
# Return 200 to prevent AT retries
return { "status" : "error_logged" , "error" : str (e)}
Retry Logic
Africa’s Talking will retry webhooks if they receive:
Non-2xx status codes
Timeouts (>30 seconds)
Connection errors
Best Practice : Always return 200 OK, even for errors, and handle failures asynchronously.
Testing Webhooks
Local Testing with ngrok
Install ngrok :
Start your server :
uvicorn app.main:app --reload --port 8000
Create tunnel :
Configure webhook URL :
https://abc123.ngrok.io/api/v1/voice/webhook
Manual Testing
Test webhooks manually using curl:
# Test voice webhook
curl -X POST http://localhost:8000/api/v1/voice/webhook \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "sessionId=ATVId_test123" \
-d "phoneNumber=+254712345678" \
-d "recordingUrl=https://example.com/recording.mp3" \
-d "duration=120" \
-d "status=completed"
# Test SMS webhook
curl -X POST http://localhost:8000/api/v1/sms/webhook \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "from=+254712345678" \
-d "to=40404" \
-d "text=YES-AG-2024-001" \
-d "date=2024-03-06 10:30:00" \
-d "id=ATXid_test123"
# Test payment webhook
curl -X POST http://localhost:8000/api/v1/payments/webhook \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "transactionId=ATPid_test123" \
-d "status=Success" \
-d "phoneNumber=+254712345678" \
-d "amount=KES 50000.00"
Monitoring
Logging
import logging
logger = logging.getLogger( __name__ )
@router.post ( "/webhook" )
async def monitored_webhook ( request : Request):
# Log incoming webhook
logger.info(
f "Webhook received: { request.url.path } " ,
extra = {
"headers" : dict (request.headers),
"client_host" : request.client.host
}
)
try :
result = await process_webhook(request)
# Log success
logger.info( f "Webhook processed successfully: { result } " )
return result
except Exception as e:
# Log error with full traceback
logger.error(
f "Webhook processing failed: { e } " ,
exc_info = True ,
extra = { "request_id" : request.headers.get( "X-Request-ID" )}
)
return { "status" : "error" , "error" : str (e)}
Metrics
Track webhook metrics:
from prometheus_client import Counter, Histogram
webhook_requests = Counter(
'webhook_requests_total' ,
'Total webhook requests' ,
[ 'endpoint' , 'status' ]
)
webhook_duration = Histogram(
'webhook_processing_seconds' ,
'Webhook processing time' ,
[ 'endpoint' ]
)
@router.post ( "/webhook" )
async def instrumented_webhook ( request : Request):
endpoint = request.url.path
with webhook_duration.labels( endpoint = endpoint).time():
try :
result = await process_webhook(request)
webhook_requests.labels( endpoint = endpoint, status = 'success' ).inc()
return result
except Exception as e:
webhook_requests.labels( endpoint = endpoint, status = 'error' ).inc()
raise
Troubleshooting
Webhooks Not Received
Check URL accessibility :
curl -X POST https://your-domain.com/api/v1/voice/webhook
Verify webhook configuration in AT dashboard
Check firewall rules - allow AT IP ranges
Review application logs for errors
Test with ngrok for local development
Invalid Signatures
Verify webhook secret matches AT dashboard
Check signature header name and format
Ensure raw body is used for verification
Test signature locally :
import hmac
import hashlib
payload = "your_webhook_payload"
secret = "your_webhook_secret"
signature = hmac.new(
secret.encode(),
payload.encode(),
hashlib.sha256
).hexdigest()
print ( f "sha256= { signature } " )
Webhook Timeouts
Return 200 quickly - process asynchronously
Use background tasks for heavy processing
Optimize database queries
Add request timeout monitoring
Best Practices
Return 200 OK quickly : Process webhooks asynchronously
Verify all signatures : Never skip signature verification in production
Use idempotency : Handle duplicate webhooks gracefully
Log everything : Comprehensive logging helps debugging
Monitor failures : Track failed webhooks for manual review
Test thoroughly : Test with actual AT sandbox before production
Handle retries : AT will retry failed webhooks
Validate data : Don’t trust webhook data blindly
Use HTTPS : Never use HTTP in production
Set timeouts : Don’t let webhook processing block
Next Steps
Africa's Talking Core integration documentation
Mobile Money Payment webhook details
Voice Processing Voice webhook handling
API Reference Webhook API documentation