Skip to main content

Overview

USSD (Unstructured Supplementary Service Data) integration provides an interactive menu system for managing contracts without requiring internet connectivity or a smartphone. Users dial a short code to access VoicePact features.

No Internet Required

Works on any phone without data or WiFi connection

Interactive Menus

Navigate through hierarchical menus with number inputs

Real-Time Updates

Check contract status and confirm deliveries instantly

Session Management

Maintains context across menu navigation

How It Works

1

Dial USSD Code

User dials the VoicePact USSD short code (e.g., 38496#)
2

See Main Menu

System displays main menu with available options
3

Navigate Menus

User selects options by entering numbers, navigating through contract lists and details
4

Take Actions

Perform actions like confirming deliveries or reporting issues
When users dial the USSD code, they see:
Welcome to VoicePact
1. View My Contracts
2. Confirm Delivery
3. Check Payments
4. Help & Support
0. Exit
See ussd.py:309 for menu generation.

View My Contracts

Displays active contracts for the user’s phone number:
📋 Your Contracts:
1. active AG-2024-0012... (active)
2. confirmed AG-2024-0015... (confirmed)
3. pending AG-2024-0019... (pending)

0. Back to Main Menu
Selecting a contract shows details:
Contract Details
active AG-2024-0012...
Product: Maize
Value: KES 350,000
Status: Active

1. Confirm Delivery
2. Report Issue
0. Back
See ussd.py:96 for contract list handler.

Confirm Delivery

Options for delivery confirmation:
Confirm Delivery
Contract: AG-2024-0012...
1. Full Delivery
2. Partial Delivery
3. Report Issue
0. Back
Selecting “Full Delivery” completes the contract:
Full delivery confirmed!
Buyer will be notified.
Payment will be processed.
See ussd.py:251 for delivery handler.

Check Payments

💰 Payment Status
Your last payment: KES 5,000 (Released)
1. View all payments
0. Back to Main Menu

Help & Support

VoicePact Help
Call 0700123456 for support
SMS 'HELP' to 40404
0. Back to Main Menu

USSD Handler Implementation

The main USSD endpoint handles all menu interactions:
@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 for VoicePact"""
    # Get or create session
    session = await get_or_create_session(sessionId, phoneNumber, db)
    
    # Parse user input
    user_input = text.split('*')[-1] if text else ""
    
    # Route to appropriate menu handler
    if not text:
        response = main_menu()
        session.current_menu = "main"
    else:
        response = await handle_menu_navigation(
            session, user_input, phoneNumber, at_client, db
        )
    
    # Update session
    session.last_input = user_input
    session.last_response = response
    await db.commit()
    
    return response
See ussd.py:19 for full implementation.

Session Management

USSD sessions maintain state across menu navigation:
class USSDSession:
    session_id: str        # Unique session identifier
    phone_number: str      # User's phone number
    current_menu: str      # Current menu location
    context_data: dict     # Menu-specific data
    last_input: str        # Previous user input
    last_response: str     # Previous response shown
    expires_at: datetime   # Session expiration

Session Creation

async def get_or_create_session(
    session_id: str,
    phone_number: str,
    db: AsyncSession
) -> USSDSession:
    """Get existing USSD session or create new one"""
    result = await db.execute(
        select(USSDSession).where(USSDSession.session_id == session_id)
    )
    session = result.scalar_one_or_none()
    
    if not session:
        session = USSDSession(
            session_id=session_id,
            phone_number=phone_number,
            current_menu="main",
            context_data={},
            is_active=True,
            expires_at=datetime.utcnow() + timedelta(minutes=5)
        )
        db.add(session)
    
    return session
See ussd.py:357 for session management.

Response Format

USSD responses use specific prefixes:
  • CON: Continue session - show menu and wait for input
  • END: End session - show message and close
def build_ussd_response(text: str, end_session: bool = False) -> str:
    if end_session:
        return f"END {text}"
    return f"CON {text}"
See africastalking_client.py:382 for response builder. Menu handlers process user input based on current menu:
async def handle_menu_navigation(
    session: USSDSession,
    user_input: str,
    phone_number: str,
    at_client: AfricasTalkingClient,
    db: AsyncSession
) -> str:
    """Handle navigation between USSD menus"""
    current_menu = session.current_menu
    
    if current_menu == "main":
        return await handle_main_menu(session, user_input, phone_number, at_client, db)
    elif current_menu == "contracts":
        return await handle_contracts_menu(session, user_input, phone_number, at_client, db)
    elif current_menu == "contract_detail":
        return await handle_contract_detail(session, user_input, phone_number, at_client, db)
    elif current_menu == "delivery":
        return await handle_delivery_menu(session, user_input, phone_number, at_client, db)
    else:
        session.current_menu = "main"
        return main_menu()
See ussd.py:64 for navigation logic.

Contract Queries

Retrieve contracts for a phone number:
async def get_user_contracts(
    phone_number: str, 
    db: AsyncSession
) -> List[Contract]:
    """Get contracts for a phone number"""
    result = await db.execute(
        select(Contract)
        .join(ContractParty)
        .where(
            and_(
                ContractParty.phone_number == phone_number,
                Contract.status.in_([
                    ContractStatus.CONFIRMED,
                    ContractStatus.ACTIVE,
                    ContractStatus.PENDING
                ])
            )
        )
        .order_by(Contract.created_at.desc())
    )
    return result.scalars().all()
See ussd.py:383 for query implementation.

Status Updates

Update contract status from USSD:
async def update_contract_status(
    contract_id: str,
    status: ContractStatus,
    db: AsyncSession
):
    """Update contract status"""
    result = await db.execute(
        select(Contract).where(Contract.id == contract_id)
    )
    contract = result.scalar_one_or_none()
    
    if contract:
        contract.status = status
        if status == ContractStatus.COMPLETED:
            contract.completed_at = datetime.utcnow()
        await db.commit()
See ussd.py:405 for status updates.

Testing USSD Menus

Test menu generation without dialing:
response = httpx.get(
    "https://api.voicepact.com/api/v1/ussd/test/+254712345678"
)

result = response.json()
print(f"Phone: {result['phone_number']}")
print(f"Contracts: {result['contracts_count']}")
print(f"\nMenu:\n{result['ussd_menu']}")

Test Response

{
  "phone_number": "+254712345678",
  "contracts_count": 3,
  "ussd_menu": "Select Contract:\n1. AG-2024-001234 - Active (KES 350,000)\n2. AG-2024-001235 - Confirmed (KES 120,000)\n3. AG-2024-001236 - Pending (KES 85,000)"
}
See ussd.py:424 for test endpoint.

Status Emojis

Contracts display status indicators:
def get_status_emoji(status: ContractStatus) -> str:
    """Get emoji for contract status"""
    status_emojis = {
        ContractStatus.PENDING: "pending",
        ContractStatus.CONFIRMED: "confirmed",
        ContractStatus.ACTIVE: "active",
        ContractStatus.COMPLETED: "completed",
        ContractStatus.DISPUTED: "disputed",
        ContractStatus.CANCELLED: "cancelled",
        ContractStatus.EXPIRED: "expired"
    }
    return status_emojis.get(status, "unknown")
See ussd.py:343 for emoji mapping. Generate contract detail menus:
def contract_detail_menu(
    contract: Contract, 
    at_client: AfricasTalkingClient
) -> str:
    """Generate contract detail menu"""
    status_emoji = get_status_emoji(contract.status)
    product = contract.terms.get('product', 'Product')
    amount = contract.total_amount or 0
    currency = contract.currency
    
    menu_text = f"Contract Details\n"
    menu_text += f"{status_emoji} {contract.id[:12]}...\n"
    menu_text += f"Product: {product}\n"
    menu_text += f"Value: {currency} {amount:,.0f}\n"
    menu_text += f"Status: {contract.status.value.title()}\n\n"
    
    if contract.status == ContractStatus.ACTIVE:
        menu_text += "1. Confirm Delivery\n"
    
    menu_text += "2. Report Issue\n"
    menu_text += "0. Back"
    
    return at_client.build_ussd_response(menu_text, end_session=False)
See ussd.py:319 for menu generation.

Error Handling

Gracefully handle errors in USSD flow:
try:
    response = await handle_menu_navigation(
        session, user_input, phoneNumber, at_client, db
    )
    await db.commit()
    return response
except Exception as e:
    logger.error(f"USSD handler error: {e}")
    return at_client.build_ussd_response(
        "Service temporarily unavailable. Please try again later.",
        end_session=True
    )

Best Practices

  • Maximum 7 options per menu
  • Keep text under 182 characters
  • Use clear, concise labels
  • Number options consistently
  • Store selected items in session
  • Allow back navigation
  • Provide breadcrumbs
  • Clear session on exit
  • Set 5-minute session expiry
  • Save progress automatically
  • Allow resume from last position
  • Clear expired sessions
  • Confirm actions immediately
  • Show clear success/error messages
  • Notify other parties automatically
  • Send SMS confirmation

Integration with Africa’s Talking

VoicePact uses Africa’s Talking USSD API:
  • Service Code: Configured in config.py
  • Handler Endpoint: POST /api/v1/ussd/
  • Session Management: Automatic via Africa’s Talking
  • Response Format: CON/END prefixed text

Database Models

USSD session model:
class USSDSession(Base):
    __tablename__ = "ussd_sessions"
    
    id: Mapped[int] = mapped_column(Integer, primary_key=True)
    session_id: Mapped[str] = mapped_column(String(100), unique=True, index=True)
    phone_number: Mapped[str] = mapped_column(String(20), index=True)
    current_menu: Mapped[str] = mapped_column(String(50), default="main")
    context_data: Mapped[dict] = mapped_column(JSON, default=dict)
    last_input: Mapped[Optional[str]] = mapped_column(String(200))
    last_response: Mapped[Optional[str]] = mapped_column(Text)
    created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
    updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
    is_active: Mapped[bool] = mapped_column(Boolean, default=True)
    expires_at: Mapped[datetime] = mapped_column(DateTime, index=True)
See contract.py:312 for model definition.

Common Use Cases

Delivery Confirmation

  1. Farmer delivers goods
  2. Dials USSD code
  3. Selects “Confirm Delivery”
  4. Enters contract ID or selects from list
  5. Confirms full delivery
  6. Buyer notified via SMS
  7. Payment released automatically

Status Check

  1. User dials USSD code
  2. Selects “View My Contracts”
  3. Sees list of active contracts
  4. Selects contract for details
  5. Views current status and amount

Issue Reporting

  1. User discovers problem
  2. Dials USSD code
  3. Navigates to contract
  4. Selects “Report Issue”
  5. Contract marked as disputed
  6. Support team notified

Next Steps

SMS Verification

Learn about SMS confirmations

Voice Contracts

Create contracts from voice

Mobile Money

Integrate payments

Digital Signatures

Understand signatures

Build docs developers (and LLMs) love