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 provides conditional escrow for contract payments, holding funds until delivery is confirmed. This builds trust between parties by ensuring:
- Sellers are guaranteed payment upon delivery
- Buyers don’t pay until goods/services are received
- Funds are locked and can’t be withdrawn by either party during fulfillment
Payment Flow
The escrow lifecycle follows contract progression:
┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ PENDING │───▶│ LOCKED │───▶│ RELEASED │ or │ REFUNDED │
│ (Initiated) │ │ (In Escrow) │ │ (Delivered) │ │ (Cancelled) │
└──────────────┘ └──────────────┘ └──────────────┘ └──────────────┘
│ │ │ │
│ │ │ │
Checkout Contract Active Delivery OK Dispute
Payment Statuses
Defined in contract.py:43-48:
class PaymentStatus(str, enum.Enum):
PENDING = "pending" # Checkout initiated, awaiting confirmation
LOCKED = "locked" # Funds held in escrow
RELEASED = "released" # Payment sent to seller
REFUNDED = "refunded" # Returned to buyer (cancellation/dispute)
FAILED = "failed" # Transaction error
Payment Model
Payments are tracked in the database (contract.py:219-268):
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", ondelete="CASCADE")
)
# Transaction identifiers
transaction_id: Mapped[Optional[str]] = mapped_column(String(100), unique=True)
external_transaction_id: Mapped[Optional[str]] = mapped_column(String(100))
# Parties
payer_phone: Mapped[str] = mapped_column(String(20), index=True)
recipient_phone: Mapped[Optional[str]] = mapped_column(String(20))
# Amount details
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(
SQLEnum(PaymentStatus),
default=PaymentStatus.PENDING
)
# Timestamps
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)
# Error handling
failure_reason: Mapped[Optional[str]] = mapped_column(String(200))
retry_count: Mapped[int] = mapped_column(Integer, default=0)
Payments are linked to contracts via foreign key with CASCADE delete. If a contract is deleted, all associated payments are automatically removed.
Mobile Money Checkout
Initiating Payment
Endpoint: POST /payments/checkout (payments.py:36-93)
class PaymentRequest(BaseModel):
contract_id: str
amount: float = Field(..., gt=0)
currency: str = Field(default="KES")
phone_number: str
payment_type: str = Field(default="escrow")
@router.post("/checkout", response_model=PaymentResponse)
async def mobile_checkout(
request: PaymentRequest,
at_client: AfricasTalkingClient = Depends(get_africastalking_client),
db: AsyncSession = Depends(get_db)
):
# Verify contract exists
result = await db.execute(
select(Contract).where(Contract.id == request.contract_id)
)
contract = result.scalar_one_or_none()
if not contract:
raise HTTPException(status_code=404, detail="Contract not found")
# Create payment record
payment = Payment(
contract_id=request.contract_id,
payer_phone=request.phone_number,
amount=Decimal(str(request.amount)),
currency=request.currency,
payment_type=request.payment_type,
status=PaymentStatus.PENDING
)
db.add(payment)
await db.commit()
await db.refresh(payment)
# Initiate AT mobile checkout
response = await at_client.mobile_checkout(
phone_number=request.phone_number,
amount=request.amount,
currency_code=request.currency,
metadata={"contract_id": request.contract_id, "payment_id": payment.id}
)
# Update payment with transaction ID
if response.get('transactionId'):
payment.external_transaction_id = response['transactionId']
await db.commit()
return PaymentResponse(
payment_id=payment.id,
transaction_id=payment.external_transaction_id,
status=payment.status.value,
amount=float(payment.amount),
currency=payment.currency,
created_at=payment.created_at.isoformat()
)
Africa’s Talking Integration (africastalking_client.py:240-268):
async def mobile_checkout(
self,
phone_number: str,
amount: Union[int, float],
currency_code: str = "KES",
metadata: Optional[Dict] = None
) -> Dict[str, Any]:
checkout_data = {
'productName': settings.at_payment_product_name,
'phoneNumber': phone_number,
'currencyCode': currency_code,
'amount': amount,
'metadata': metadata or {}
}
loop = asyncio.get_event_loop()
response = await loop.run_in_executor(
None,
lambda: self.payment_service.mobile_checkout(checkout_data)
)
return response
The mobile checkout triggers an STK push (SIM Toolkit) on the buyer’s phone, prompting them to enter their M-Pesa PIN to authorize the payment.
Payment Webhooks
Africa’s Talking sends payment status updates to the webhook endpoint.
Webhook handler: POST /payments/webhook (payments.py:96-136)
@router.post("/webhook")
async def payment_webhook(
request: Request,
at_client: AfricasTalkingClient = Depends(get_africastalking_client),
db: AsyncSession = Depends(get_db)
):
form_data = await request.form()
transaction_id = form_data.get("transactionId")
status = form_data.get("status", "failed")
phone_number = form_data.get("phoneNumber")
amount = form_data.get("amount")
logger.info(f"Payment webhook received: {transaction_id}, {status}")
if transaction_id:
# Find payment by 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 # Funds in escrow
payment.confirmed_at = datetime.utcnow()
else:
payment.status = PaymentStatus.FAILED
payment.failure_reason = form_data.get("description", "Payment failed")
await db.commit()
logger.info(f"Payment {payment.id} updated: {payment.status.value}")
return {"status": "webhook_processed", "transaction_id": transaction_id}
Webhook signature verification (planned):
# Verify webhook authenticity (crypto_service.py:162-182)
def verify_webhook_signature(self, payload: str, signature: str) -> bool:
try:
expected_signature = self.generate_webhook_signature(payload)
return hmac.compare_digest(signature, expected_signature)
except Exception as e:
logger.error(f"Webhook signature verification failed: {e}")
return False
Escrow Locking
When payment status changes to LOCKED, the contract becomes ACTIVE:
# After webhook updates payment
if payment.status == PaymentStatus.LOCKED:
# Update associated contract
contract = await db.execute(
select(Contract).where(Contract.id == payment.contract_id)
)
contract.status = ContractStatus.ACTIVE
contract.confirmed_at = datetime.utcnow()
await db.commit()
Funds are now held by Africa’s Talking and cannot be accessed by either party.
Payment Release
When delivery is confirmed (buyer accepts goods):
# Seller confirms delivery via SMS/USSD
# Buyer approves delivery
# Update payment status
payment.status = PaymentStatus.RELEASED
payment.released_at = datetime.utcnow()
payment.recipient_phone = seller_phone
# Trigger disbursement to seller
await at_client.mobile_data_transfer(
phone_number=seller_phone,
amount=payment.amount,
currency_code=payment.currency,
metadata={"contract_id": payment.contract_id, "payment_id": payment.id}
)
# Update contract
contract.status = ContractStatus.COMPLETED
contract.completed_at = datetime.utcnow()
Mobile money transfer (africastalking_client.py:274-304):
async def mobile_data_transfer(
self,
phone_number: str,
amount: Union[int, float],
currency_code: str = "KES",
metadata: Optional[Dict] = None
) -> Dict[str, Any]:
transfer_data = {
'productName': settings.at_payment_product_name,
'recipients': [{
'phoneNumber': phone_number,
'currencyCode': currency_code,
'amount': amount,
'metadata': metadata or {}
}]
}
loop = asyncio.get_event_loop()
response = await loop.run_in_executor(
None,
lambda: self.payment_service.mobile_data(transfer_data)
)
return response
Refunds
If the contract is cancelled or disputed:
payment.status = PaymentStatus.REFUNDED
# Return funds to buyer
await at_client.mobile_data_transfer(
phone_number=buyer_phone,
amount=payment.amount,
currency_code=payment.currency,
metadata={"contract_id": payment.contract_id, "refund": True}
)
Payment Queries
Get Payment by ID
Endpoint: GET /payments/{payment_id} (payments.py:139-167)
@router.get("/{payment_id}", response_model=PaymentResponse)
async def get_payment(payment_id: int, db: AsyncSession = Depends(get_db)):
result = await db.execute(
select(Payment).where(Payment.id == payment_id)
)
payment = result.scalar_one_or_none()
if not payment:
raise HTTPException(status_code=404, detail="Payment not found")
return PaymentResponse(
payment_id=payment.id,
transaction_id=payment.external_transaction_id,
status=payment.status.value,
amount=float(payment.amount),
currency=payment.currency,
created_at=payment.created_at.isoformat()
)
Get Contract Payments
Endpoint: GET /payments/contract/{contract_id} (payments.py:170-200)
@router.get("/contract/{contract_id}")
async def get_contract_payments(contract_id: str, db: AsyncSession):
result = await db.execute(
select(Payment)
.where(Payment.contract_id == contract_id)
.order_by(Payment.created_at.desc())
)
payments = result.scalars().all()
payment_list = [
PaymentResponse(
payment_id=payment.id,
transaction_id=payment.external_transaction_id,
status=payment.status.value,
amount=float(payment.amount),
currency=payment.currency,
created_at=payment.created_at.isoformat()
)
for payment in payments
]
return {"contract_id": contract_id, "payments": payment_list}
Payment Reference Generation
Unique payment references are generated cryptographically (crypto_service.py:121-128):
def generate_payment_reference(
self,
contract_id: str,
amount: float,
phone_number: str
) -> str:
try:
content = f"{contract_id}:{amount}:{phone_number}"
hash_obj = hashlib.blake2b(content.encode('utf-8'), digest_size=8)
return hash_obj.hexdigest().upper()
except Exception as e:
logger.error(f"Payment reference generation failed: {e}")
raise CryptographicError(f"Failed to generate payment reference: {e}")
Error Handling
Retry logic for failed payments:
if payment.status == PaymentStatus.FAILED:
if payment.retry_count < MAX_RETRIES:
payment.retry_count += 1
payment.status = PaymentStatus.PENDING
# Retry checkout
await mobile_checkout(payment)
else:
# Send notification to buyer
logger.error(f"Payment {payment.id} failed after {MAX_RETRIES} retries")
Security Considerations
Payment security best practices:
- Webhook verification - Validate HMAC signatures from Africa’s Talking
- Idempotency - Use unique transaction IDs to prevent duplicate payments
- Amount validation - Verify webhook amounts match contract totals
- Audit logging - Record all payment state changes
- Database constraints - Enforce positive amounts at schema level
- Encryption - Sensitive payment metadata encrypted at rest (planned)
Testing Payments
Test endpoint: POST /payments/test/checkout (payments.py:203-227)
@router.post("/test/checkout")
async def test_payment(
phone_number: str,
amount: float = 100.0,
at_client: AfricasTalkingClient = Depends(get_africastalking_client)
):
response = await at_client.mobile_checkout(
phone_number=phone_number,
amount=amount,
currency_code="KES",
metadata={"test": "true"}
)
return {
"status": "test_initiated",
"phone_number": phone_number,
"amount": amount,
"response": response
}
Use Africa’s Talking sandbox for testing without real money.