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:
- 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.
- 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 viasessionId(Africa's Talking USSD callback atPOST /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/failedtransition viaPOST /payment-callback(Body.stkCallback.CheckoutRequestIDDaraja shape + AT shape) is synced toDONATIONS_ANALYTICSin Snowflake withphone_hash = SHA256(phone).slice(0,16), never raw PII.POST /admin/sync-snowflakebatch-retries unsynced rows.Only 3 causes can be active at a time β admin (
/adminbehindADMIN_USER/PASSWORDBasic 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 (Activatedisables 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, purpleGenerate summarybutton. -
βοΈ Snowflake AI Analytics (Cortex
COMPLETE('llama3-8b', ?)) β trend analytics over live aggregates fromDONATIONS_ANALYTICS(totals by cause, daily trend, largest gift, average). Prompt is bound viabinds: ['llama3-8b', prompt]per Node.js driver docs, no string concat. Blue card, blueRefresh analyticsbutton β on-demand, not on every page load.
-
β¨ Google AI Impact Summary (Gemini 1.5 Flash via
Mock-everything gracefully β no
AT_API_KEY? MOCK checkout. NoGOOGLE_AI_API_KEY? Heuristic summary. NoSNOWFLAKE_*? SQLite aggregates with(Generated locally β Snowflake Cortex mock mode).npm install && npm startdemos 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
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:
-
Visit
/β see liveKES 845 raised, 3 active cause cards with progress bars, and the Dial*384*6120#instruction. No donate button β intentional. - 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)
Or browser: Open the ussd simulator at
and enter code *384*6120# to try out the sandbox ussd flow.
-
Go to
/admin(admin / karibu123) β Manage Charity Causes β see3 of 3 active, tryActivateon a 4th β302 Cannot activate β already 3 activeβ¦. AddGirls Education π / 900,000as inactive, deactivate one, then activate it β landing + USSD now show the new cause instantly. - 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}}}'
β donation flips to completed, Snowflake sync fires, total on / increments.
- Click β¨ Generate summary (Gemini) and βοΈ Refresh analytics (Cortex) β two independent cards, two providers, two buttons.
-
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)
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_*
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
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 })
});
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';
}
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)=>{} });
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,temperaturedefault,gemini-1.5-flash. No key β heuristicTogether, 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)