Skip to main content

Overview

VoicePact implements multi-modal verification to accommodate both smartphone and feature phone users across African markets. Parties can confirm contracts using:
  • SMS confirmations with OTP codes
  • USSD menus (dial codes like *483#)
  • Cryptographic signatures for audit trail integrity
This inclusive approach ensures that informal sector participants without smartphones can still participate in digital contracting.

Verification Methods

1. SMS Confirmation

The primary verification method uses SMS with confirmation codes. Flow:
1. Contract created

2. SMS sent to all parties with contract summary + code

3. Party replies "YES-{CONTRACT_ID}" to confirm

4. System validates reply and updates signature status

5. When all parties confirm → Contract status = CONFIRMED
SMS generation (africastalking_client.py:407-430):
def generate_contract_sms(
    self,
    contract_id: str,
    contract_terms: Dict[str, Any]
) -> str:
    product = contract_terms.get('product', 'Product')
    quantity = contract_terms.get('quantity', '')
    unit = contract_terms.get('unit', '')
    total_amount = contract_terms.get('total_amount', 0)
    currency = contract_terms.get('currency', 'KES')
    delivery_date = contract_terms.get('delivery_deadline', '')
    
    quantity_str = f"{quantity} {unit}" if quantity and unit else "Items"
    amount_str = f"{currency} {total_amount:,.2f}" if total_amount else "Amount pending"
    date_str = f", Due: {delivery_date}" if delivery_date else ""
    
    return (
        f"VoicePact Contract Summary:\n"
        f"ID: {contract_id}\n"
        f"Product: {product} ({quantity_str})\n"
        f"Total: {amount_str}{date_str}\n"
        f"Reply YES-{contract_id} to confirm or NO-{contract_id} to decline"
    )
Sending confirmations (contracts.py:427-446):
async def send_contract_confirmations(
    contract_id: str,
    parties: List[Dict[str, str]],
    terms: Dict[str, Any],
    at_client: AfricasTalkingClient
):
    try:
        message = at_client.generate_contract_sms(contract_id, terms)
        recipients = [party["phone"] for party in parties]
        
        await at_client.send_sms(
            message=message,
            recipients=recipients
        )
        
        logger.info(f"Contract confirmations sent for {contract_id}")
        
    except Exception as e:
        logger.error(f"Failed to send contract confirmations: {e}")
SMS webhook handler (sms.py:293-328):
@router.post("/webhook")
async def sms_webhook(request: Request):
    try:
        form_data = await request.form()
        
        phone_number = form_data.get("from")
        message = form_data.get("text", "").upper().strip()
        
        # Handle contract confirmations
        if message.startswith("YES-") or message.startswith("NO-"):
            contract_id = message.split("-", 1)[1] if "-" in message else "unknown"
            action = "confirm" if message.startswith("YES-") else "reject"
            
            logger.info(f"Contract {action}: {contract_id} from {phone_number}")
            
            # Update signature status in database
            if action == "confirm":
                await confirm_contract(contract_id, phone_number, db)
            else:
                await reject_contract(contract_id, phone_number, db)
            
            return {
                "action": action,
                "contract_id": contract_id,
                "phone_number": phone_number
            }
        
        return {"status": "webhook_received"}
        
    except Exception as e:
        logger.error(f"SMS webhook error: {e}")
        return {"status": "webhook_error", "error": str(e)}
SMS confirmations work on any phone, including basic feature phones. Users simply send a text reply to confirm.

2. SMS OTP Codes

For higher-security workflows, VoicePact generates 6-digit OTP codes tied to each party. OTP generation (crypto_service.py:101-111):
def generate_sms_confirmation_code(
    self, 
    contract_id: str, 
    phone_number: str
) -> str:
    try:
        # Create daily-rotated hash
        content = f"{contract_id}:{phone_number}:{datetime.utcnow().date().isoformat()}"
        hash_obj = hashlib.sha256(content.encode('utf-8'))
        hex_hash = hash_obj.hexdigest()
        
        # Convert to 6-digit code
        numeric_code = int(hex_hash[:8], 16) % 1000000
        return f"{numeric_code:06d}"
    except Exception as e:
        logger.error(f"SMS code generation failed: {e}")
        raise CryptographicError(f"Failed to generate SMS code: {e}")
OTP verification (crypto_service.py:113-119):
def verify_sms_confirmation(
    self, 
    contract_id: str, 
    phone_number: str, 
    code: str
) -> bool:
    try:
        expected_code = self.generate_sms_confirmation_code(contract_id, phone_number)
        # Constant-time comparison to prevent timing attacks
        return hmac.compare_digest(code, expected_code)
    except Exception as e:
        logger.error(f"SMS verification failed: {e}")
        return False
Usage pattern:
SMS to party: "Your VoicePact confirmation code for contract AG-260306-A1B2C3 is: 482719. Reply with this code to confirm."

Party replies: "482719"

System validates code and confirms signature.
OTP codes are time-limited (daily rotation) and phone-specific, preventing replay attacks.

3. USSD Verification

USSD (Unstructured Supplementary Service Data) provides interactive menus accessible via short codes. USSD menu flow:
User dials: *483#

┌─────────────────────────────────┐
│ VoicePact                       │
│ 1. View My Contracts            │
│ 2. Confirm Delivery             │
│ 3. Check Payments               │
│ 4. Report Issue                 │
└─────────────────────────────────┘

User selects: 1

┌─────────────────────────────────┐
│ Select Contract:                │
│ 1. AG-260306-123 - Pending      │
│    (KES 150,000)                │
│ 2. GP-260305-456 - Active       │
│    (KES 50,000)                 │
└─────────────────────────────────┘

User selects: 1

┌─────────────────────────────────┐
│ Contract: AG-260306-123         │
│ Product: Maize (100 bags)       │
│ Amount: KES 150,000             │
│ Status: Pending                 │
│                                 │
│ 1. Confirm Contract             │
│ 2. Decline                      │
│ 0. Back                         │
└─────────────────────────────────┘
USSD session management (contract.py:312-340):
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)
    
    # Navigation state
    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,
        onupdate=datetime.utcnow
    )
    
    is_active: Mapped[bool] = mapped_column(Boolean, default=True)
    expires_at: Mapped[datetime] = mapped_column(DateTime)
USSD response builder (africastalking_client.py:382-385):
def build_ussd_response(self, text: str, end_session: bool = False) -> str:
    if end_session:
        return f"END {text}"
    return f"CON {text}"
  • CON = Continue session (show menu)
  • END = End session (final message)
USSD menu generators (africastalking_client.py:461-492):
def generate_ussd_contract_menu(self, contracts: List[Dict[str, Any]]) -> str:
    if not contracts:
        return "No active contracts found."
    
    menu_items = ["Select Contract:"]
    for i, contract in enumerate(contracts[:9], 1):
        status = contract.get('status', 'unknown')
        amount = contract.get('total_amount', 0)
        currency = contract.get('currency', 'KES')
        menu_items.append(
            f"{i}. {contract['id']} - {status.title()} ({currency} {amount:,.0f})"
        )
    
    return "\n".join(menu_items)

def generate_ussd_contract_detail(self, contract: Dict[str, Any]) -> str:
    product = contract.get('terms', {}).get('product', 'Product')
    quantity = contract.get('terms', {}).get('quantity', '')
    unit = contract.get('terms', {}).get('unit', '')
    amount = contract.get('total_amount', 0)
    currency = contract.get('currency', 'KES')
    status = contract.get('status', 'unknown')
    
    quantity_str = f" ({quantity} {unit})" if quantity and unit else ""
    
    return (
        f"Contract: {contract['id']}\n"
        f"Product: {product}{quantity_str}\n"
        f"Amount: {currency} {amount:,.2f}\n"
        f"Status: {status.title()}\n"
        f"1. Confirm Delivery\n"
        f"2. Report Issue\n"
        f"0. Back"
    )
USSD works on all phones without internet, making it ideal for rural areas with limited data connectivity.

4. Cryptographic Signatures

All verifications are backed by cryptographic signatures for tamper-proof audit trails. Ed25519 signature generation (crypto_service.py:65-76):
def sign_contract(self, contract_data: str, phone_number: str) -> str:
    try:
        # Derive signing key from phone number
        signing_key = self._derive_signing_key(phone_number)
        private_key = ed25519.Ed25519PrivateKey.from_private_bytes(signing_key)
        
        # Create timestamped message
        message = f"{contract_data}:{phone_number}:{datetime.utcnow().isoformat()}"
        signature = private_key.sign(message.encode('utf-8'))
        
        return base64.b64encode(signature).decode('utf-8')
    except Exception as e:
        logger.error(f"Contract signing failed: {e}")
        raise CryptographicError(f"Failed to sign contract: {e}")
Key derivation (crypto_service.py:246-259):
def _derive_signing_key(self, phone_number: str) -> bytes:
    try:
        # Use PBKDF2 to derive unique key per phone number
        kdf = PBKDF2HMAC(
            algorithm=hashes.SHA256(),
            length=32,
            salt=self.salt.encode('utf-8'),
            iterations=100000,
        )
        
        key_material = f"{self.master_key}:{phone_number}".encode('utf-8')
        return kdf.derive(key_material)
    except Exception as e:
        logger.error(f"Key derivation failed: {e}")
        raise CryptographicError(f"Failed to derive signing key: {e}")
Signature verification (crypto_service.py:78-99):
def verify_signature(
    self, 
    contract_data: str, 
    phone_number: str, 
    signature: str
) -> bool:
    try:
        signing_key = self._derive_signing_key(phone_number)
        private_key = ed25519.Ed25519PrivateKey.from_private_bytes(signing_key)
        public_key = private_key.public_key()
        
        signature_bytes = base64.b64decode(signature.encode('utf-8'))
        
        # Check multiple time windows (allows for clock skew)
        for time_window in [0, 1, 2]:
            test_time = datetime.utcnow().replace(
                minute=(datetime.utcnow().minute // 10 - time_window) * 10, 
                second=0, microsecond=0
            )
            test_message = f"{contract_data}:{phone_number}:{test_time.isoformat()}"
            
            try:
                public_key.verify(signature_bytes, test_message.encode('utf-8'))
                return True
            except:
                continue
        
        return False
    except Exception as e:
        logger.error(f"Signature verification failed: {e}")
        return False
Why Ed25519?
  • Small signatures (64 bytes) - efficient for SMS/USSD
  • Fast verification - critical for real-time responses
  • Strong security - equivalent to 3072-bit RSA
  • Deterministic - same input always produces same signature

Signature Status Tracking

Each party’s signature is tracked independently (contract.py:173-216):
class SignatureStatus(str, enum.Enum):
    PENDING = "pending"      # Awaiting confirmation
    SIGNED = "signed"        # Confirmed
    REJECTED = "rejected"    # Declined
    EXPIRED = "expired"      # Timeout

class ContractSignature(Base):
    __tablename__ = "contract_signatures"
    
    contract_id: Mapped[str] = mapped_column(
        String(50),
        ForeignKey("contracts.id", ondelete="CASCADE")
    )
    signer_phone: Mapped[str] = mapped_column(String(20), index=True)
    signature_method: Mapped[str] = mapped_column(
        String(20), 
        default="sms_confirmation"
    )
    signature_hash: Mapped[str] = mapped_column(String(128))
    signature_data: Mapped[Optional[dict]] = mapped_column(JSON, default=dict)
    status: Mapped[SignatureStatus] = mapped_column(
        SQLEnum(SignatureStatus),
        default=SignatureStatus.PENDING
    )
    signed_at: Mapped[Optional[datetime]] = mapped_column(DateTime)
    created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
    expires_at: Mapped[Optional[datetime]] = mapped_column(DateTime)

Phone Number Validation

Ensuring correct phone number formats (africastalking_client.py:390-405):
async def validate_phone_number(self, phone_number: str) -> bool:
    if not phone_number.startswith('+'):
        return False
    
    phone_digits = ''.join(filter(str.isdigit, phone_number))
    return len(phone_digits) >= 10 and len(phone_digits) <= 15

async def format_phone_number(
    self, 
    phone_number: str, 
    country_code: str = "254"
) -> str:
    phone_clean = ''.join(filter(str.isdigit, phone_number))
    
    if phone_clean.startswith(country_code):
        return f"+{phone_clean}"
    elif phone_clean.startswith('0'):
        return f"+{country_code}{phone_clean[1:]}"
    else:
        return f"+{country_code}{phone_clean}"

Multi-Party Confirmation

Contracts require all parties to confirm before activation:
# Check signature progress (contracts.py:313-337)
all_signatures_result = await db.execute(
    select(ContractSignature).where(
        ContractSignature.contract_id == contract_id
    )
)
all_signatures = all_signatures_result.scalars().all()

signed_count = sum(
    1 for sig in all_signatures 
    if sig.status == SignatureStatus.SIGNED
)

if signed_count == len(all_signatures):
    # All parties confirmed - update contract
    contract.status = ContractStatus.CONFIRMED
    contract.confirmed_at = datetime.utcnow()
    await db.commit()

Security Best Practices

Verification security:
  1. Constant-time comparison - Use hmac.compare_digest() to prevent timing attacks
  2. Key derivation - Never reuse master keys; derive per-phone keys with PBKDF2
  3. Signature expiration - Time-limited signatures prevent replay attacks
  4. Rate limiting - Prevent SMS/USSD flooding (planned)
  5. Phone verification - Validate format before processing
  6. Audit logging - Record all verification attempts

Testing

Send test SMS:
curl -X POST http://localhost:8000/sms/test \
  -d 'phone_number=+254712345678'
Simulate USSD session:
curl -X POST http://localhost:8000/ussd/webhook \
  -d 'sessionId=ATUid_test123' \
  -d 'phoneNumber=+254712345678' \
  -d 'text=*483#'

Build docs developers (and LLMs) love