DEV Community

Ruita Wachira
Ruita Wachira Subscriber

Posted on

Karibu Give; USSD Micro-Philanthropy for the Next Billion Givers

DEV Weekend Challenge: Generosity Edition Submission πŸ’œ

This is a submission for Weekend Challenge: Generosity Edition

What I Built

Karibu Give (Swahili for Welcome, Give) is a USSD micro-donation platform that works on a kabambe phone(feature phone) with no data, no app, no account , just a phone number and a mobile-money PIN. Built for the International Day of Charity.

Two things stop generosity from scaling in Kenya and across Africa:

  1. You need a smartphone to give. Most donation platforms are web-forms that assume Chrome, data bundles, and card rails. 40% of Kenyan adults still use feature phones.
  2. You need trust to give again. Donations disappear into a black box. Donors never see where 50 KES actually went.

Karibu Give attacks both:

  • Dial *384*6120# β†’ 1. Donate β†’ Pick a cause β†’ Enter 50 β†’ Confirm β†’ M-Pesa STK prompt in 2 seconds. No internet, no app store, no signup. Session state is managed server-side via sessionId (Africa's Talking USSD callback at POST /ussd).
  • Real M-Pesa money movement β€” STK Push is triggered via my dedicated M-Pesa Service https://mpesa-service-3s2d.onrender.com/stkpush, which wraps Daraja API (POST {phone:"2547...", amount} β†’ CheckoutRequestID:"ws_CO_...").

  • SQLite = source of truth, Snowflake = audit trail β€” every pending β†’ completed/failed transition via POST /payment-callback (Body.stkCallback.CheckoutRequestID Daraja shape + AT shape) is synced to DONATIONS_ANALYTICS in Snowflake with phone_hash = SHA256(phone).slice(0,16) , never raw PII. POST /admin/sync-snowflake batch-retries unsynced rows.

  • Only 3 causes can be active at a time β€” admin (/admin behind ADMIN_USER/PASSWORD Basic Auth) creates charities (name, emoji, target_amount, description), toggles active, edits, deletes (blocked if donations exist). USSD and landing page render only active causes, so the choice stays focused. The limit is enforced in SQLite (countActive()<3) and in the UI (Activate disables at 3/3).

  • Two separate AI cards β€” not one bolted-on feature:

    • ✨ Google AI Impact Summary (Gemini 1.5 Flash via @google/generative-ai) β€” plain-language donor transparency paragraph: "Together 6 gifts totaling KES 845… School Books leads… avg 141 KES… Asante!" Built from last 20 completed donations + per-cause progress. Purple card, purple Generate summary button.
    • ❄️ Snowflake AI Analytics (Cortex COMPLETE('llama3-8b', ?)) β€” trend analytics over live aggregates from DONATIONS_ANALYTICS (totals by cause, daily trend, largest gift, average). Prompt is bound via binds: ['llama3-8b', prompt] per Node.js driver docs, no string concat. Blue card, blue Refresh analytics button β€” on-demand, not on every page load.
  • Mock-everything gracefully β€” no AT_API_KEY? MOCK checkout. No GOOGLE_AI_API_KEY? Heuristic summary. No SNOWFLAKE_*? SQLite aggregates with (Generated locally β€” Snowflake Cortex mock mode). npm install && npm start demos end-to-end offline.

Demo

πŸ”— Live app: https://karibugive.onrender.com (or http://localhost:3000 locally)

πŸ”— M-Pesa Service: https://mpesa-service-3s2d.onrender.com β†’ POST /stkpush

Karibu Give ❀ β€” USSD Micro-Donation Platform

DEV.to "generosity" weekend challenge β€” anyone with a basic phone (no smartphone, no data) can donate small amounts via mobile money to causes, using Africa's Talking USSD + M-Pesa.

Quick start

cp .env.example .env   # fill AT_API_KEY, GOOGLE_AI_API_KEY, Snowflake etc.
npm install
npm start              # http://localhost:3000
Enter fullscreen mode Exit fullscreen mode

Admin: http://localhost:3000/admin (default admin / karibu123 β€” set ADMIN_USER/PASSWORD in .env).

Endpoints

Route Purpose
GET / Landing page β€” total raised, causes + progress bars, dial code
POST /ussd AT USSD callback β€” session state machine
POST /payment-callback AT payment webhook — marks pending→completed/failed, syncs to Snowflake
POST /payment-callback/simulate Dev helper to simulate a callback
GET /admin Dashboard (HTTP Basic Auth)
POST /admin/generate-impact Google Gemini donor transparency summary
POST /admin/generate-cortex Snowflake Cortex analytics summary
POST /admin/sync-snowflake Batch-sync unsynced rows

USSD flow

*384*6120# β†’
  1. Donate  2. Check total raised  0. Exit
  β†’ pick cause (1..3)
…

60-second tour:

  1. Visit / β€” see live KES 845 raised, 3 active cause cards with progress bars, and the Dial *384*6120# instruction. No donate button β€” intentional.
  2. Simulate USSD (no AT SIM needed):
   curl -X POST http://localhost:3000/ussd -d "sessionId=TEST123&phoneNumber=%2B254711082000&text="
   # CON Welcome … 1. Donate  2. Check total raised
   # text=1      β†’ CON Choose a cause: 1. Clean Water …
   # text=1*1    β†’ CON You chose: Clean Water / Enter amount
   # text=1*1*50 β†’ CON Donate KES 50 to Clean Water? 1. Confirm
   # text=1*1*50*1 β†’ END Thank you! Check your phone…  (STK push fired)
Enter fullscreen mode Exit fullscreen mode

Or browser: Open the ussd simulator at

and enter code *384*6120# to try out the sandbox ussd flow.

  1. Go to /admin (admin / karibu123) β†’ Manage Charity Causes β€” see 3 of 3 active, try Activate on a 4th β†’ 302 Cannot activate β€” already 3 active…. Add Girls Education πŸŽ“ / 900,000 as inactive, deactivate one, then activate it β€” landing + USSD now show the new cause instantly.
  2. Trigger payment:
   curl -X POST http://localhost:3000/payment-callback -H "Content-Type: application/json" \
     -d '{"checkoutRequestId":"ws_CO_...","status":"Success"}'
   # or Daraja shape
   curl -X POST http://localhost:3000/mpesa-callback \
     -d '{"Body":{"stkCallback":{"CheckoutRequestID":"ws_CO_...","ResultCode":0}}}'
Enter fullscreen mode Exit fullscreen mode

β†’ donation flips to completed, Snowflake sync fires, total on / increments.

  1. Click ✨ Generate summary (Gemini) and ❄️ Refresh analytics (Cortex) β€” two independent cards, two providers, two buttons.
  2. Try deleting a cause with donations β†’ Cannot delete β€” 2 donation(s) reference… Deactivate instead.

The flow:

   [ Feature phone β€” no data ]
              β”‚
              β–Ό  *384*6120#  (Africa's Talking USSD)
   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
   β”‚   POST /ussd         β”‚ ──► sessionId state machine
   β”‚  (active causes ≀3)  β”‚ ──► pending donation row
   β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
              β”‚
              β–Ό  1*1*50*1 Confirm
   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
   β”‚  M-Pesa Service      β”‚ ──► POST https://mpesa-service-3s2d.onrender.com/stkpush
   β”‚  POST {phone,amount} β”‚ ──► ws_CO_...  (Daraja)
   β”‚  AT PAYMENT fallback β”‚ ──► STK Push prompt on phone
   β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
              β”‚
              β–Ό  M-Pesa PIN
   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
   β”‚ POST /payment-callbackβ”‚ ──► pending β†’ completed/failed
   β”‚  (AT + Daraja shapes) β”‚ ──► Snowflake sync (hashed phone)
   β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
              β”‚
      β”Œβ”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”
      β–Ό                β–Ό
  Gemini 1.5 Flash   Cortex COMPLETE('llama3-8b')
  Impact Summary      Trend Summary
  (donor-facing)      (operator-facing)
Enter fullscreen mode Exit fullscreen mode

Code

GitHub: https://github.com/Charlo-tech/karibugive

server.js                         # Express + EJS +dotenv, mounts ussd/payment/admin/public
src/db/
  schema.sql                      # donations + causes (is_active) + indexes
  db.js                           # better-sqlite3 with node:sqlite fallback, WAL, seed 3 causes
  causes.js                       # DB-backed CRUD with MAX_ACTIVE=3 enforcement
src/services/
  donations.js                    # create/get/update, getRaisedByCause({activeOnly})
  atClient.js                     # africastalking SDK wrapper with MOCK
  mpesa.js                        # fetch β†’ POST https://mpesa-service-3s2d.onrender.com/stkpush {phone,amount}
  googleAI.js                     # Gemini generateImpactSummary() with heuristic mock
  snowflake.js                    # snowflake-sdk: createConnection→connect→execute with binds, Cortex
src/routes/
  ussd.js                         # POST+GET /ussd, Text handling, amount validation 10-70000, STK trigger
  payment.js                      # POST /payment-callback + /mpesa-callback + /daraja-callback (AT + Daraja shapes)
  admin.js                        # Basic Auth, cause CRUD, generate-impact/cortex, sync-snowflake
  public.js                       # GET / (active causes only), GET /health
src/views/
  index.ejs + admin.ejs + partials/header,footer
  public/css/style.css            # no framework, minimal
.env.example                      # AT_USERNAME, MPESA_SERVICE_URL, GOOGLE_AI_API_KEY, SNOWFLAKE_*, ADMIN_*
Enter fullscreen mode Exit fullscreen mode

Node 24, Express 4, EJS, better-sqlite3@13, snowflake-sdk@1.13, africastalking@0.7, @google/generative-ai@0.21. Clone + npm install + npm start.

How I Built It

M-Pesa as the real money rail

M-pesa is a huge financial enabler in the Kenyan scene and I wanted to give donors a payment experience they are familiar with and one that is seamless to use.

// POST https://mpesa-service-3s2d.onrender.com/stkpush
// {phone: "254712345678", amount: 1} β†’ 200
// {MerchantRequestID, CheckoutRequestID:"ws_CO_...", ResponseCode:"0"}
// {phoneNumber:...} β†’ 400 {error:"Phone and amount are required"}
// {phone:"+254..."} β†’ 500 Invalid PhoneNumber
Enter fullscreen mode Exit fullscreen mode

So mpesa.js normalizes +254711β†’254711, 0711β†’254711, and posts:

await fetch(`${MPESA_SERVICE_URL}/stkpush`, {
  method: 'POST',
  headers: { 'Content-Type':'application/json', 'User-Agent':'KaribuGive/1.0' },
  body: JSON.stringify({ phone: normalized, amount })
});
Enter fullscreen mode Exit fullscreen mode

Success returns ws_CO_… which becomes checkout_request_id. Failure returns MOCK-MPESA-… so USSD never blocks.

Daraja callbacks are not AT callbacks

AT sends {checkoutRequestId, status}. Daraja sends {Body:{stkCallback:{CheckoutRequestID, ResultCode, CallbackMetadata:{Item:[...]}}}}. payment.js unwraps both:

let cb = body.Body?.stkCallback || body.stkCallback || body;
if (cb?.ResultCode !== undefined) {
  body.checkoutRequestId = cb.CheckoutRequestID;
  body.status = cb.ResultCode===0 ? 'Success' : 'Failed';
}
Enter fullscreen mode Exit fullscreen mode

Mounted at three aliases so the M-Pesa service can be pointed at any of them: /payment-callback, /mpesa-callback, /daraja-callback. All mark status=completed/failed, set completed_at, then syncDonationToSnowflake().

Snowflake Node.js driver β€” by the docs

Implemented strictly per https://docs.snowflake.com/en/developer-guide/node-js/nodejs-driver:

const conn = snowflake.createConnection({ account, username, password, warehouse, database, schema, role, application:'KaribuGive' });
conn.connect((err, conn) => { /* isValidAsync() β†’ ensureTable() */ });
conn.execute({ sqlText, binds, complete:(err,stmt,rows)=>{} });
Enter fullscreen mode Exit fullscreen mode

executeSql() wraps that pattern. Inserts use binds: [id, phone_hash, cause_id, …] β€” no string concat. Cortex uses binds: ['llama3-8b', prompt] β†’ SELECT SNOWFLAKE.CORTEX.COMPLETE(?,?) . If creds missing, mockMode=true, SQLite aggregates produce the same summary with (Generated locally…) β€” same code path in tests, no deploy secrets needed. destroy() on SIGINT.

Gemini vs Cortex β€” why two AIs?

  • Gemini: donor-facing plain language. Prompt built from getRaisedByCause({activeOnly:false}) + last 20 donations, temperature default, gemini-1.5-flash. No key β†’ heuristic Together, N gifts totaling KES X…
  • Cortex: operator-facing trends. Aggregates GROUP BY cause_id, TO_DATE(completed_at), ORDER BY amount DESC LIMIT 1 β†’ JSON β†’ prompt β†’ CORTEX.COMPLETE('llama3-8b', ?).

Both are POST …/generate-* on demand, not on page load, to control cost/latency.

The 3-active constraint

I made it admin-configurable but capped at 3 to keep the USSD menu usable on a 160-char screen. DB has is_active INTEGER, countActive() guards createCause and setActive. UI disables Activate/is_active at 3/3 and shows Full (3/3). Landing + USSD query WHERE is_active=1 ORDER BY rowid β€” creation order, not alphabetical, so menu numbers stay stable. Delete blocked if COUNT(*) FROM donations WHERE cause_id=? >0 β€” deactivate instead.

Prize Categories

Best Use of Google AI

Gemini is the trust layer donors see. The Impact Summary isn't a chatbot β€” it's a transparency report generated from live completed donations, shown on the admin dashboard next to Snowflake's card but visually distinct (purple vs blue). Remove the key and it degrades to a deterministic heuristic, so the challenge can be judged without my API key.

Best Use of Snowflake

Snowflake is the audit layer operators see. DONATIONS_ANALYTICS mirrors completed donations with hashed PII, synced synchronously on payment-callback + batch POST /admin/sync-snowflake. Cortex COMPLETE('llama3-8b', ?) runs over that table's aggregates β€” not over SQLite β€” and is executed via the official Node.js driver with binds. Without Snowflake creds the same query runs against SQLite so the demo never 500s.

πŸ’œ Generosity, Why Karibu Give.

Generosity scales when you remove the need for generosity to be tech-savvy. Karibu Give doesn't ask a mama mboga in Turkana to install an app β€” it asks her to dial a code she already knows. Causes are curated (3 active max) so small gifts feel focused, progress is live, and AI makes the impact visible in plain language. That's generosity by design, not by donation form.


Karibu means welcome. Give small, give often.

Stack: Node.js + Express + EJS, Africa's Talking USSD, M-Pesa Daraja via mpesa-service-3s2d.onrender.com, SQLite (better-sqlite3 + node:sqlite fallback), Snowflake (snowflake-sdk), Gemini (@google/generative-ai), dotenv.

Run: npm install && cp .env.example .env && npm start β†’ http://localhost:3000 | POST /ussd | GET /admin (admin/karibu123)

Top comments (0)