Skip to main content

Overview

VoicePact integrates with mobile money platforms to enable secure escrow payments for contracts. Funds are held in escrow until both parties confirm the transaction is complete, protecting buyers and sellers.

Escrow Protection

Funds held securely until delivery is confirmed by both parties

M-Pesa Integration

Direct integration with Kenya’s leading mobile money platform

Automated Release

Payment released automatically when delivery is confirmed

Transaction Tracking

Real-time status updates for all payment transactions

How It Works

1

Contract Created

After voice negotiation, a contract is created with payment amount specified.
2

Payment Request

Buyer receives mobile money payment request (STK Push) on their phone.
3

Funds Locked

Payment is held in escrow - not accessible to buyer or seller.
4

Delivery Confirmed

Both parties confirm delivery through SMS or USSD.
5

Payment Released

Funds automatically transferred to seller’s mobile money account.

Payment Flow

Mobile Checkout

Initiate a payment from the buyer:
import httpx

response = httpx.post(
    "https://api.voicepact.com/api/v1/payments/checkout",
    json={
        "contract_id": "AG-2024-001234",
        "phone_number": "+254712345678",
        "amount": 350000,
        "currency_code": "KES",
        "metadata": {
            "product": "Maize",
            "quantity": "100 bags",
            "seller_phone": "+254723456789"
        }
    }
)

result = response.json()
print(f"Transaction ID: {result['transaction_id']}")
print(f"Status: {result['status']}")
print(f"Description: {result['description']}")

Checkout Flow

  1. API call initiates payment request
  2. M-Pesa sends STK Push to buyer’s phone
  3. Buyer enters M-Pesa PIN
  4. Payment confirmed and locked in escrow
  5. Webhook notification sent to VoicePact
  6. Contract status updated to “payment_locked”
See africastalking_client.py:240 for checkout implementation.

Checkout Response

{
  "transaction_id": "TXN-AG-2024-001234-001",
  "external_transaction_id": "AT-TXN-abc123xyz",
  "status": "pending",
  "description": "Waiting for user to confirm payment",
  "amount": 350000,
  "currency": "KES",
  "phone_number": "+254712345678",
  "created_at": "2024-03-06T10:30:00Z"
}

Payment Status

Query the status of a payment transaction:
transaction_id = "TXN-AG-2024-001234-001"
response = httpx.get(
    f"https://api.voicepact.com/api/v1/payments/status/{transaction_id}"
)

status = response.json()
print(f"Status: {status['status']}")
print(f"Amount: {status['amount']} {status['currency']}")
print(f"Payer: {status['payer_phone']}")
print(f"Recipient: {status['recipient_phone']}")
if status['status'] == 'released':
    print(f"Released at: {status['released_at']}")

Status Values

  • pending: Payment requested, awaiting user confirmation
  • locked: Payment received and held in escrow
  • released: Payment transferred to seller
  • refunded: Payment returned to buyer
  • failed: Payment failed or was cancelled
See africastalking_client.py:306 for status query.

Webhook Notifications

VoicePact receives real-time payment updates via webhooks:
# Webhook payload at /api/v1/payments/webhook
{
  "transactionId": "AT-TXN-abc123xyz",
  "phoneNumber": "+254712345678",
  "amount": "KES 350000.00",
  "status": "Success",
  "description": "Payment received from +254712345678",
  "requestMetadata": {
    "contract_id": "AG-2024-001234",
    "product": "Maize",
    "seller_phone": "+254723456789"
  },
  "sourceType": "Mpesa",
  "provider": "Mpesa",
  "providerChannel": "525900",
  "value": "KES 350000.00"
}

Webhook Processing

The webhook handler:
  1. Validates webhook signature for security
  2. Extracts transaction details
  3. Updates payment record in database
  4. Updates contract status
  5. Notifies relevant parties via SMS
  6. Triggers next workflow step
See africastalking_client.py:494 for webhook processing.

Payment Release

Release escrowed funds to the seller:
response = httpx.post(
    "https://api.voicepact.com/api/v1/payments/release",
    json={
        "contract_id": "AG-2024-001234",
        "transaction_id": "TXN-AG-2024-001234-001",
        "confirmed_by": [
            "+254712345678",  # Buyer
            "+254723456789"   # Seller
        ]
    }
)

result = response.json()
print(f"Release status: {result['status']}")
print(f"Amount released: {result['amount']} {result['currency']}")
print(f"Recipient: {result['recipient_phone']}")

Release Conditions

Payment is released when:
  1. Seller confirms delivery (via SMS/USSD)
  2. Buyer accepts delivery (via SMS/USSD)
  3. No disputes have been raised
  4. Contract is marked as “completed”

Payment Refund

Refund payment to buyer if contract is cancelled or disputed:
response = httpx.post(
    "https://api.voicepact.com/api/v1/payments/refund",
    json={
        "transaction_id": "TXN-AG-2024-001234-001",
        "reason": "Contract cancelled by mutual agreement",
        "initiated_by": "+254712345678"
    }
)

result = response.json()
print(f"Refund status: {result['status']}")
print(f"Amount refunded: {result['amount']} {result['currency']}")

Transaction History

Retrieve payment history for a contract:
contract_id = "AG-2024-001234"
response = httpx.get(
    f"https://api.voicepact.com/api/v1/payments/history/{contract_id}"
)

payments = response.json()
for payment in payments['transactions']:
    print(f"ID: {payment['transaction_id']}")
    print(f"Amount: {payment['amount']} {payment['currency']}")
    print(f"Status: {payment['status']}")
    print(f"Date: {payment['created_at']}")
    print("---")

Wallet Balance

Check VoicePact’s mobile money wallet balance:
response = httpx.get(
    "https://api.voicepact.com/api/v1/payments/balance"
)

balance = response.json()
print(f"Balance: {balance['balance']}")
print(f"Currency: {balance['currency']}")
print(f"Last updated: {balance['updated_at']}")
See africastalking_client.py:322 for balance query.

Payment Reference Generation

Generate unique payment reference codes:
from app.services.crypto_service import get_crypto_service

crypto = get_crypto_service()
reference = crypto.generate_payment_reference(
    contract_id="AG-2024-001234",
    amount=350000.00,
    phone_number="+254712345678"
)
print(f"Payment Reference: {reference}")  # e.g., "A7F5C3D8E9B2"
See crypto_service.py:121 for reference generation.

SMS Notifications

Automatic SMS notifications for payment events:

Payment Received

VoicePact Payment Received:
Contract: AG-2024-001234
Amount: KES 350,000.00
Status: Processing
You will receive confirmation shortly.

Payment Released

VoicePact Payment Released:
Contract: AG-2024-001234
Amount: KES 350,000.00
To: +254723456789
Transaction ID: TXN-AG-2024-001234-001

Payment Template

from app.services.africastalking_client import AfricasTalkingClient

client = AfricasTalkingClient()
message = client.generate_payment_sms(
    contract_id="AG-2024-001234",
    amount=350000,
    currency="KES",
    action="received"
)
See africastalking_client.py:432 for SMS templates.

Database Model

Payment records are stored in the database:
class Payment(Base):
    __tablename__ = "payments"
    
    id: Mapped[int] = mapped_column(Integer, primary_key=True)
    contract_id: Mapped[str] = mapped_column(String(50), ForeignKey("contracts.id"))
    transaction_id: Mapped[Optional[str]] = mapped_column(String(100), unique=True)
    external_transaction_id: Mapped[Optional[str]] = mapped_column(String(100))
    
    payer_phone: Mapped[str] = mapped_column(String(20))
    recipient_phone: Mapped[Optional[str]] = mapped_column(String(20))
    
    amount: Mapped[Decimal] = mapped_column(Numeric(precision=15, scale=2))
    currency: Mapped[str] = mapped_column(String(3), default="KES")
    
    payment_type: Mapped[str] = mapped_column(String(20), default="escrow")
    status: Mapped[PaymentStatus] = mapped_column(Enum(PaymentStatus), default=PaymentStatus.PENDING)
    
    payment_method: Mapped[Optional[str]] = mapped_column(String(50))
    created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
    confirmed_at: Mapped[Optional[datetime]] = mapped_column(DateTime)
    released_at: Mapped[Optional[datetime]] = mapped_column(DateTime)
    
    failure_reason: Mapped[Optional[str]] = mapped_column(String(200))
    retry_count: Mapped[int] = mapped_column(Integer, default=0)
    payment_metadata: Mapped[Optional[dict]] = mapped_column(JSON, default=dict)
See contract.py:219 for model definition.

Circuit Breaker Pattern

Payment service uses circuit breaker for reliability:
class CircuitBreaker:
    def __init__(self, failure_threshold: int = 5, recovery_timeout: int = 60):
        self.failure_threshold = failure_threshold
        self.recovery_timeout = recovery_timeout
        self.failure_count = 0
        self.last_failure_time = None
        self.state = 'CLOSED'  # CLOSED, OPEN, HALF_OPEN
    
    async def call(self, func, *args, **kwargs):
        if self.state == 'OPEN':
            if time.time() - self.last_failure_time < self.recovery_timeout:
                raise CircuitBreakerOpen("Circuit breaker is OPEN")
            else:
                self.state = 'HALF_OPEN'
        
        try:
            result = await func(*args, **kwargs)
            self.reset()
            return result
        except Exception as e:
            self.record_failure()
            raise e
See africastalking_client.py:31 for circuit breaker implementation.

Retry Logic

Payments are retried automatically on failure:
from tenacity import retry, stop_after_attempt, wait_exponential

@retry(
    stop=stop_after_attempt(3),
    wait=wait_exponential(multiplier=1, min=1, max=10)
)
async def mobile_checkout(
    self,
    phone_number: str,
    amount: Union[int, float],
    currency_code: str = "KES",
    metadata: Optional[Dict] = None
) -> Dict[str, Any]:
    # Payment logic here
    pass
See africastalking_client.py:236 for retry configuration.

Error Handling

try:
    response = httpx.post("/api/v1/payments/checkout", json=payload)
    response.raise_for_status()
    result = response.json()
    
except httpx.HTTPStatusError as e:
    if e.response.status_code == 400:
        # Invalid payment request
        print(f"Invalid request: {e.response.json()['detail']}")
    elif e.response.status_code == 402:
        # Payment required - insufficient funds
        print("Insufficient funds in mobile money account")
    elif e.response.status_code == 503:
        # Service unavailable
        print("Payment service temporarily unavailable")
        # Implement exponential backoff retry
except Exception as e:
    print(f"Unexpected error: {str(e)}")

Best Practices

  • Verify contract exists and is valid
  • Check payment amount matches contract
  • Confirm phone number format
  • Validate currency code
  • Verify webhook signatures
  • Process asynchronously
  • Implement idempotency
  • Log all webhook data
  • Send 200 OK immediately
  • Poll status for pending transactions
  • Set timeout limits (e.g., 5 minutes)
  • Handle expired transactions
  • Retry failed payments
  • Never release without confirmation from both parties
  • Implement dispute resolution process
  • Set escrow timeout periods
  • Log all state changes with audit trail

Security Considerations

Webhook Signature Verification

from app.services.crypto_service import CryptoService

crypto = CryptoService()

# Verify webhook signature
def verify_webhook(payload: str, signature: str) -> bool:
    return crypto.verify_webhook_signature(payload, signature)
See crypto_service.py:176 for signature verification.

Audit Trail

All payment actions are logged:
audit_signature = crypto.create_audit_signature(
    action="payment_released",
    contract_id="AG-2024-001234",
    actor="+254712345678",
    data={
        "amount": 350000,
        "transaction_id": "TXN-AG-2024-001234-001",
        "recipient": "+254723456789"
    }
)
See crypto_service.py:130 for audit signatures.

Testing

Use Africa’s Talking sandbox for testing:
# Set environment to sandbox
export AT_ENVIRONMENT=sandbox
export AT_API_KEY=your_sandbox_api_key

# Test payment with test phone numbers
test_phones = [
    "+254711000000",  # Always succeeds
    "+254711000001",  # Always fails
    "+254711000002",  # Timeout
]

Cost Structure

Mobile money transaction fees:
  • M-Pesa to Business: ~1-3% transaction fee
  • Business to M-Pesa: Flat rate + percentage
  • Balance Inquiry: Free
  • Failed Transactions: No charge

Next Steps

Voice Contracts

Create payment-enabled contracts

SMS Verification

Confirm payments via SMS

USSD Integration

Check payment status via USSD

Digital Signatures

Cryptographic payment verification

Build docs developers (and LLMs) love