What you'll build
In this part you'll replace the hardcoded balance and meter data with real Supabase queries, and wire up the Africa's Talking Airtime API so that confirming an airtime purchase actually sends airtime to the user's phone.
By the end of this part:
- Account balances come from a Supabase
accountstable - Airtime top-ups go through the AT Airtime API
- Meter balances come from a Supabase
meterstable - Payments create a row in a
paymentstable
Set up Supabase
Go to app.supabase.com and create a new project. Once it's ready, go to SQL Editor and run:
create table accounts (
id uuid primary key default gen_random_uuid(),
phone text unique not null,
account_no text unique not null,
balance_ghs numeric(10,2) default 0,
name text not null
);
create table meters (
id uuid primary key default gen_random_uuid(),
meter_no text unique not null,
account_id uuid references accounts(id),
balance_ghs numeric(10,2) default 0
);
create table payments (
id uuid primary key default gen_random_uuid(),
meter_no text not null,
amount_ghs numeric(10,2) not null,
reference text not null,
status text default 'pending',
created_at timestamptz default now()
);
Seed a test account and meter:
insert into accounts (phone, account_no, balance_ghs, name)
values ('+233200000001', '12345', 45.00, 'Ama Mensah');
insert into meters (meter_no, account_id, balance_ghs)
select 'MTR001', id, 120.00 from accounts where account_no = '12345';
Copy your Project URL and service_role key from Settings → API and add them to .env:
SUPABASE_URL=https://your-project.supabase.co
SUPABASE_SERVICE_KEY=your_service_role_key
Install the Supabase client:
npm install @supabase/supabase-js
Build the data service layer
Create src/services/db.ts for the GH Utilities USSD service.
1. Initialise a Supabase client using SUPABASE_URL and SUPABASE_SERVICE_KEY from env.
2. Export getAccountBalance(accountNo: string): Promise<{ name: string; balance: number } | null> — queries the accounts table by account_no, returns name and balance_ghs cast to number, or null if not found.
3. Export getMeterBalance(meterNo: string): Promise<{ balance: number } | null> — queries meters table by meter_no, returns balance_ghs, or null.
4. Export recordPayment(meterNo: string, amountGhs: number, reference: string): Promise
Wire up the Airtime API
Create src/services/airtime.ts for the GH Utilities USSD service. Install africastalking npm package. Import and initialise with AT_USERNAME and AT_API_KEY from env. Export sendAirtime(phoneNumber: string, amount: string, currencyCode: string = "GHS"): Promise<{ success: boolean; errorMessage?: string }>. Call AT.AIRTIME.send({ recipients: [{ phoneNumber, amount, currencyCode }] }). Return { success: true } on success, { success: false, errorMessage: error.message } on failure. Log any errors but don't throw — USSD responses must always return something to AT.
Install the package first:
npm install africastalking
npm install -D @types/africastalking
Update the USSD handler to use real data
Update src/ussd/handler.ts to use the db and airtime services. At Level 2, balance check (steps[0]==="1", steps[1] is an account number): - Call getAccountBalance(steps[1]) - If null → CON "Account not found. Try again:\n0. Back" - If found → store { name, balance } in a module-level Map<sessionId, { name, balance }> so deeper levels can use it - Show "Account: [name]\nBalance: GHS [balance.toFixed(2)]\n\n1. View payment history\n0. Back" At Level 3, airtime confirm (steps[0]==="2", steps[2]==="1"): - Call sendAirtime(phoneNumber, steps[1]) where phoneNumber comes from the AT request body - If success → END "GHS [steps[1]] airtime sent to [phoneNumber]. Check your phone for the credit." - If failure → END "Airtime could not be processed. Please try again or contact support." At Level 2, meter lookup (steps[0]==="3", steps[1] is a meter number): - Call getMeterBalance(steps[1]) - If null → CON "Meter not found. Try again:\n0. Back" - If found → store { meterBalance } in session map keyed by sessionId - Show "Meter: [steps[1]]\nBalance due: GHS [balance.toFixed(2)]\n\n1. Pay GHS [balance.toFixed(2)]\n2. Pay different amount\n0. Back" At Level 4, payment confirm (steps[0]==="3", steps[2]==="1", steps[3]==="1"): - Generate reference "GHU-" + Math.random().toString(36).slice(2,8).toUpperCase() - Call recordPayment(steps[1], sessionData.meterBalance, reference) - END "Payment of GHS [amount] for meter [steps[1]] submitted.\nRef: [reference]\nSMS confirmation follows."
Test with real data
Restart the server. In the AT simulator, use phone +233200000001:
- Dial →
1→12345→ you should see "Ama Mensah" and a balance of45.00 - Dial →
2→5→1→ you should receive an actual airtime credit on your sandbox phone - Dial →
3→MTR001→1→1→ a row should appear in your Supabasepaymentstable
Project structure after Part 3
src/
services/
db.ts ← Supabase account + meter queries
airtime.ts ← AT Airtime API wrapper
ussd/
handler.ts ← state machine using real data
index.ts
What's next
In Part 4 we add SMS confirmation messages after every completed transaction and deploy the server to Railway so it runs 24/7 without ngrok.
BlocWeave