Building a Serverless USSD Application with AWS and Africa's Talking

In today’s digital landscape, reaching users in regions with limited internet connectivity or smartphone adoption requires creative solutions. USSD (Unstructured Supplementary Service Data) technology offers a powerful way to deliver mobile services to any mobile phone, regardless of internet connectivity or device sophistication.

In this post, I’ll walk through how we built a Fintech application, a financial services application using USSD technology, AWS serverless architecture, and Africa’s Talking as our telecom integration partner.

What is USSD?

USSD is a protocol used by GSM cellular phones to communicate with a service provider’s computers. When users dial codes like *3664#, they initiate a real-time, session-based connection that allows for interactive menu-driven service experiences.

Unlike SMS, USSD creates a real-time connection that remains open during the session, allowing for a conversational exchange between the user and the application server.

Architecture Overview

Our application follows a serverless architecture pattern hosted on AWS. Here’s how the system works:

  1. A user dials a USSD code (e.g., *3664#) on their mobile phone
  2. The telecom provider receives this request and forwards it to Africa’s Talking
  3. Africa’s Talking triggers our callback API endpoint
  4. Our serverless function processes the request and returns a text response
  5. The response is displayed on the user’s phone, allowing them to navigate through menus

Technical Implementation

AWS Serverless Framework

We chose the Serverless Framework for deployment to AWS for several reasons:

  • Zero server management
  • Automatic scaling
  • Pay-per-execution pricing model
  • Simplified deployment and management

Our serverless.yml file configures the necessary AWS resources, including Lambda functions, API Gateway endpoints, and permissions.

service: feature-phone
frameworkVersion: "3"
useDotenv: true
deprecationNotificationMode: error
disabledDeprecations:
  - UNSUPPORTED_CLI_OPTIONS
  - CLI_OPTIONS_SCHEMA_V3
  - CONFIG_VALIDATION_MODE_DEFAULT_V3
provider:
  name: aws
  runtime: python3.10
  stage: ${opt:stage, 'dev'}
  region: ${env:REGION}
  versionFunctions: false
  vpc:
    securityGroupIds:
      - ${ssm:EC2_RDS_SG}
    subnetIds:
      - ${ssm:PRIVATE_SUBNET_ID_2}
  environment:
    RDS_CLUSTER_ARN : ${ssm:RDS_CLUSTER_ARN}
    RDS_SECRET_ARN : ${ssm:RDS_SECRET_ARN}
    DATABASE_REGION : ${ssm:DATABASE_REGION}

resources:
  Resources:
    ApiGatewayRestApi:
      Type: AWS::ApiGateway::RestApi
      Properties:
        Name: ${self:service}
        Policy:
          Version: '2012-10-17'
          Statement:
            - Effect: Allow
              Principal: "*"
              Action: execute-api:Invoke
              Resource: "*"
              Condition:
                IpAddress:
                  aws:SourceIp:  # IP address of Africa's talking
                    - 18.133.205.228
                    - 35.177.236.113
                    - 3.8.44.1

functions:
  callback:
    handler: handlers/callback.handler
    runtime: python3.10
    layers:
      - ${ssm:PYTHON_LAYER}
    events:
      - http:
          path: /callback
          method: post
          cors: true
  logs:
    handler: handlers/logs.handler
    runtime: python3.10
    layers:
      - ${ssm:PYTHON_LAYER}
    events:
      - http:
          path: /logs
          method: post
          cors: true

Integration with Africa’s Talking

A critical component of our USSD application is the telecom integration partner. For this, we chose Africa’s Talking, a leading communication API provider that simplifies telecom integration across Africa.

About Africa’s Talking

Africa’s Talking provides a suite of APIs that enable developers to easily integrate various communication channels into their applications. Their platform supports:

  • USSD services
  • SMS messaging
  • Voice services
  • Airtime distribution
  • Payments

Founded in 2010, Africa’s Talking has become the go-to communication API provider in Africa, with a presence in over 20 countries. Their mission aligns with ours: to make digital services more accessible across the continent.

How the Integration Works

When integrating with Africa’s Talking for USSD services, the flow works as follows:

  1. Service Setup: We registered our USSD short code (*3664#) through Africa’s Talking
  2. Callback Configuration: We configured a callback URL in their dashboard that points to our AWS API Gateway endpoint
  3. Request Processing: When a user dials our code, Africa’s Talking forwards the request to our callback URL
  4. Response Handling: Our server processes the request and returns a formatted response, which Africa’s Talking displays to the user

The integration uses a simple HTTP POST request format, with parameters like:

  • sessionId: A unique identifier for the USSD session
  • serviceCode: The USSD code that was dialed
  • phoneNumber: The phone number of the user
  • text: The user’s input during the session

Sample Callback Request

POST /callback HTTP/1.1
Host: api.example.com
Content-Type: application/x-www-form-urlencoded

sessionId=ATUid_123456789&serviceCode=*3664%23&phoneNumber=+2557000000&text=1*2

Sample Response

Our application responds with a simple text string that includes:

CON Welcome to Paymaart
1. View Balance
2. Make Payment
3. View Transactions
4. View Profile
5. Update PIN
6. Forgot PIN
9. Exit

The CON prefix indicates that the session should continue, while END would terminate the session.

Benefits of Using Africa’s Talking

Using Africa’s Talking as our telecom integration partner provided several advantages:

  1. Simplified Integration: Their well-documented APIs made integration straightforward
  2. Multi-Country Support: We can easily expand to new markets where they have a presence
  3. Reliability: Their infrastructure is robust and designed for scale
  4. Additional Services: We can expand to include SMS notifications and voice services using the same platform
  5. Developer Support: Their technical support team was responsive when we encountered integration challenges

If you’re building a USSD application in Africa, Africa’s Talking provides a reliable and developer-friendly platform to handle the telecom integration aspects, allowing you to focus on your core application logic.

Project Structure

Our application follows a modular structure:

feature-phone/
├── data/
├── handlers/
│   ├── callback.py
│   └── logs.py
├── menu/
│   ├── account_balance.py
│   ├── forgot_pin.py
│   ├── home.py
│   ├── payment.py
│   ├── profile.py
│   ├── transactions.py
│   └── update_pin.py
├── payment/
│   ├── customer.py
│   └── service_fee.py
└── serverless.yml

This structure separates concerns:

  • handlers/ contains Lambda function entry points
  • data/ holds utility functions and data access logic
  • menu/ contains screens for different application features
  • payment/ manages payment-specific functionality

The Callback Handler

The heart of our application is the callback handler which processes requests from Africa’s Talking:

def handler(event, _context):
    # load the event from Africa's Talking
    data: str = event.get('body')
    try:
        # decode the URL-encoded string and parse it into a dictionary
        data: dict = dict(parse_qsl(data))
        
        # Authentication logic
        session_id: str = data.get('sessionId')
        if not session_id or session_id not in session_store:
            user: str = customer_auth(data=data)
            session_store.update({session_id: user})
            
        # Process navigation
        navigation_code: list[str] = data.get('text', '').split('*')
        navigation_code: list[str] = navigation_tree(navigation_code.copy())
        
        # Generate appropriate response based on navigation
        response: dict = {}
        if not navigation_code or navigation_code == ['']:
            response: dict = home_screen()
        elif navigation_code[-1] == '9':
            response: dict = session_end()
        else:
            user_session = session_store.get(session_id)
            response: dict = session_continue(text=navigation_code,
                                             user_session=user_session)
        
        # Return formatted response
        return {
            'statusCode': 200,
            'headers': headers,
            'body': transform_response(response=response)
        }
    except Exception as err:
        # Error handling
        # ...

Authentication System

Authentication is a critical component of our USSD app. When a user dials our USSD code, we authenticate them based on their phone number:

def customer_auth(data):
    """
    The function authenticates customers based on their phone number
    and returns session data if authorized.
    """
    db_cursor: cursor = db_conn.cursor()
    result: dict = get_data(cursor=db_cursor, query=get_query(
        'customer_auth'), fetch_all=False,
        params=(data.get('phoneNumber'),))
    
    if not result:
        db_cursor.close()
        raise Unauthorized
    else:
        # Fetch additional KYC data
        kyc_result: dict = get_data(cursor=db_cursor, query=get_query(
            'customer_kyc'), fetch_all=False,
            params=(result.get('user_id'),))
        db_cursor.close()
        
        # Store user data for the session
        return {
            'session_id': data.get('sessionId'),
            'user_id': result.get('user_id'),
            'phone_number': data.get('phoneNumber'),
            'email': result.get('email'),
            'created_at': result.get('created_at'),
            'name': {
                'first': result.get('first_name'),
                'middle': result.get('middle_name'),
                'last': result.get('last_name'),
            },
            'kyc_status': result.get('kyc_status')
        }

The database connection is maintained throughout the session to improve performance:

# db connection
db_conn: connection = get_db_connection()

def get_db_conn():
    """
    Return already initialized connection for this session
    """
    return db_conn

This approach allows us to authenticate users seamlessly and maintain their session state throughout their interaction with the application.

Navigation System

One of the challenges with USSD applications is managing navigation. Our application implements a navigation tree that tracks the user’s position in the menu structure:

def navigation_tree(navigation) -> str:
    """
    This function handles the management of
    navigation path involving back track of session
    """
    result: list = []
    i: int = 0
    
    while i < len(navigation):
        if navigation[i] == '0' and result:
            result.pop()  # Remove the preceding element (go back)
        else:
            result.append(navigation[i])
        i += 1
    return result

This allows users to navigate forward by entering menu options and backward by entering ‘0’.

Menu System

Our menu system routes users based on their input. The main routing function determines which feature to access:

def session_continue(text: list, user_session: dict) -> dict:
    """
    Parse the flow of options
    """
    if text[0] == '1':  # view balance
        return account_balance.main(text=text, user_session=user_session)
    if text[0] == '2':  # payment list (options)
        return payment.main(text=text, user_session=user_session)
    # ... other options
    else:
        raise InvalidOption

Each feature module follows a similar pattern, handling different path lengths for sub-menus:

def main(text: list, user_session: dict) -> dict:
    """
    View Balance path
    """
    path_length: int = len(text)
    path_param: str = text[path_length-1]
    check_user_kyc(user_session)
    
    if path_length == 1:  # get email
        return input_current_pin(user_session=user_session)
    if path_length == 2:
        if verify_current_db_pin(user_session=user_session, given_pass=path_param):
            return view_balance(user_session=user_session)
        else:
            raise InvalidPIN
    else:
        raise InvalidOption

Error Handling

We’ve implemented custom error classes to provide clear feedback to users:

class InvalidOption(Exception):
    """
    Exception when user selects invalid option from menu
    """
    def response(self, message: list = None):
        if not message:
            message = [
                'Invalid Option.'
            ]
        return {
            'message': message,
            'end': True
        }

class InvalidPIN(Exception):
    """
    Exception when user enters invalid PIN
    """
    def response(self, message: list = None):
        if not message:
            message = [
                'Invalid PIN.'
            ]
        return {
            'message': message,
            'end': True
        }

class InvalidOTP(Exception):
    """
    Exception when user enters invalid OTP
    """
    def response(self, message: list = None):
        if not message:
            message = [
                'Invalid OTP.'
            ]
        return {
            'message': message,
            'end': True
        }

These custom exceptions allow us to provide consistent error messages and manage the user experience when something goes wrong.

Response Formatting

Responses are formatted as dictionaries with message content and session status:

def view_balance(user_session: dict) -> int:
    """
    Function to view account balance
    """
    balance_data = get_user_balance(user_id=user_session.get('user_id'))
    balance_update_time = int(balance_data.get('updated_at') or 0)
    
    if not balance_update_time:
        balance_update_time = user_session.get('created_at')
    
    updated_time = format_update_time(epoch_time=balance_update_time)
    
    return {
        'message': [
            'Account balance:',
            f"{float(balance_data.get('total_balance',0)):,.2f} MWK",
            f'Last Updated: {updated_time}'
        ],
        'end': True
    }

The transform_response function then converts these dictionaries to the format expected by Africa’s Talking.

User Experience

Let’s look at how our USSD application appears to users:

The USSD interface is intentionally simple, with clear numbering for options and concise text to fit within the character limits.

Security Considerations

Security is paramount in financial applications. Our implementation includes:

  1. PIN Verification: Users must enter their PIN to access sensitive operations
  2. Session Management: Each session has a unique identifier
  3. Error Handling: Custom error classes for different scenarios
  4. KYC Verification: Checks to ensure users are properly verified
  5. Database Security: Prepared statements to prevent SQL injection

Challenges and Lessons

Building a USSD application comes with unique challenges:

  1. Character Limitations: USSD messages are limited to 182 characters, requiring careful message design
  2. Session Management: Managing state across multiple interactions
  3. Navigation Complexity: Creating an intuitive navigation system with limited input options
  4. Error Handling: Providing clear error messages to users
  5. Testing: Testing USSD applications requires specialized tools

Future Improvements

Looking ahead, we’re considering:

  1. Caching: Implementing more sophisticated caching to improve performance
  2. Analytics: Adding better tracking to understand user behavior
  3. Language Support: Adding multiple language options
  4. Transaction Receipts: SMS confirmations for critical transactions
  5. Enhanced Security: Additional security layers such as one-time passwords for critical operations

Conclusion

Building a USSD application using AWS serverless architecture and Africa’s Talking has allowed us to create a scalable, cost-effective financial service accessible to users without smartphones or reliable internet connectivity.

This approach democratizes access to financial services, particularly in regions where traditional banking infrastructure is limited. By leveraging simple mobile technology available on any GSM phone, we’re helping bridge the digital divide and make e-transactions truly universal.

If you’re considering building a similar application, I hope this overview provides a helpful starting point for your journey.

5 Likes