Every tech opportunity across Ghana & West Africa. Sorted by deadline, straight to your inbox.
TechPulse is a deadline-first platform for students and early-career builders in Ghana & West Africa. It gathers the opportunities they keep missing — hackathons, internships, scholarships, bootcamps, workshops, conferences, meetups and webinars — and keeps subscribers updated by email:
- 📡 Auto-import sources — register iCal or JSON event feeds and the platform syncs them daily, dedupes, and keeps changed events up to date on its own.
- 📣 Instant announcements — when a new program is published, every subscriber whose interests match its category gets an email (imports are batched into one email per subscriber).
- 🗓️ Weekly digest — a personalised summary of programs starting in the next two weeks.
- ⏳ Deadline reminders — when a program's registration deadline is within a few days, matching subscribers get a "closing soon" email (once per program). Browse deadline-first, and see prize pools at a glance.
- 🎛️ Self-service preferences — every email carries a personal link to change interests or unsubscribe.
Built with Node.js, Express, and SQLite (via Node's built-in node:sqlite) — no external database or build step required.
Requires Node.js 22.5+.
npm install
npm run seed # load sample events (optional)
npm start # http://localhost:3000Then open:
| Page | URL |
|---|---|
| Public site (browse + subscribe) | http://localhost:3000/ |
| Admin console (publish programs) | http://localhost:3000/admin.html |
| Email preferences (linked from emails) | http://localhost:3000/preferences.html?token=… |
The admin console asks for a token — in development it defaults to dev-admin-token.
| Mode | When | What happens |
|---|---|---|
| Outbox (default) | SMTP_HOST not set |
Every email is written to ./outbox/*.html so you can open and inspect it. Perfect for development. |
| SMTP | SMTP_HOST set |
Email is delivered via nodemailer using your SMTP credentials. |
Emails sent:
- Welcome — on subscribe / resubscribe.
- New program announcement — to matching subscribers when an admin publishes an event.
- Weekly digest — personalised upcoming-events roundup. Sent automatically by the in-server scheduler (Mondays 09:00 by default), or on demand via the admin console /
npm run digest(handy for cron:0 9 * * 1 cd /path/to/app && npm run digest). - Deadline reminder — when a program's
registration_deadlinefalls withinDEADLINE_REMINDER_DAYS(default 3), matching subscribers get one "closing soon" email per program. Sent by the in-server scheduler (hourly check) or on demand from the admin console.
Four ways in, from hands-on to fully automatic:
-
Capture inbox — forward an opportunity from WhatsApp, LinkedIn or anywhere else and it lands in a review queue as a pre-filled draft; one click publishes it and emails subscribers. See Capture inbox below.
-
Admin console — publish programs by hand at
/admin.html. Good for curated one-offs; subscribers are emailed instantly. -
Auto-import sources (recommended) — register feeds in the admin console ("Auto-import sources" panel) and the platform keeps itself fresh:
- iCal (.ics) feeds — almost every tech community publishes one: Google Calendar (public calendar → "Public address in iCal format"), Luma calendars (
icalsubscribe link), Meetup group calendar exports, university event calendars, GDG chapter calendars. - JSON feeds — any endpoint you or a partner controls, shaped like:
{ "events": [ { "id": "unique-id", "title": "CodeStorm 2026", "category": "hackathon", "starts_at": "2026-08-01T09:00:00Z", "ends_at": "2026-08-03T18:00:00Z", "location": "Kumasi, Ghana", "url": "https://…", "organizer": "…", "description": "…", "tags": ["fintech"] } ] }
The sync engine dedupes by feed UID, updates events that changed at the source (reschedules, renames), skips events that already ended, and infers a category from the title ("…Hackathon Night" → hackathon, "Study Jam" → workshop) with the source's fallback category as a safety net.
- iCal (.ics) feeds — almost every tech community publishes one: Google Calendar (public calendar → "Public address in iCal format"), Luma calendars (
-
The API — anything that can make an HTTP request can push events: a scraper you run elsewhere, a GitHub Action, Zapier/Make, a partner's backend.
POST /api/eventswith the admin token (see example below).
How "regularly" works: the running server checks every 30 minutes and syncs all sources once per SYNC_INTERVAL_HOURS (default: daily). Not running the server 24/7? Put npm run sync on cron instead: 0 7 * * * cd /path/to/app && npm run sync. After each sync, subscribers get one batched email covering the new programs that match their interests — a feed with 30 new events never means 30 emails.
For sources without feeds (Devpost, Eventbrite, MLH and friends): Eventbrite has an official API (needs an API key), Luma exposes iCal, Meetup's API needs OAuth. Wire any of them to POST /api/events, or transform their API responses into the JSON feed format above and register that endpoint as a source. Check each site's terms before scraping pages that don't offer an API or feed.
This repo ships a Render blueprint, so a deployment is a few clicks:
- Go to dashboard.render.com → sign in with GitHub.
- New + → Blueprint → pick the
feedback-formrepo → choose the branch that has this code → Apply. - Wait ~2 minutes for the first build. Render gives you a URL like
https://techpulse.onrender.com.
The first boot fills the site automatically — sample opportunities plus the live osman-scraper feed — so the page is never empty.
To sign in to the admin console: in Render open your service → Environment → copy the generated
ADMIN_TOKEN, then paste it at https://<your-url>/admin.html.
Good to know about the free plan:
- The service sleeps after ~15 minutes idle; the next visit takes ~50s to wake.
- There is no persistent disk, so the database resets on restart (samples and the feed reload
automatically, but subscribers are lost). To keep data permanently, upgrade the plan, then
uncomment the
disk:block inrender.yamland setDB_PATH=/var/data/techpulse.db. - Email is written to files inside the container until you add SMTP settings. To send real
email, add
SMTP_HOST,SMTP_PORT,SMTP_USER,SMTP_PASSandMAIL_FROMin the Render dashboard (a free Resend or Brevo account, or a Gmail app password, works).
Most opportunities in Ghana are announced in WhatsApp groups, on LinkedIn, or as flyers — places no scraper can reach. The capture inbox turns that into a one-tap flow:
forward a message → draft extracted → review queue → publish → subscribers emailed
Three ways to capture:
- Paste — the "Capture inbox" panel in the admin console takes any pasted message.
- Telegram bot — create a bot with @BotFather (free, 2 minutes), set
TELEGRAM_WEBHOOK_SECRET, then point the bot at your server:Now forwarding any message to the bot queues it for review.curl "https://api.telegram.org/bot<BOT_TOKEN>/setWebhook?url=https://your-host/api/hooks/telegram/$TELEGRAM_WEBHOOK_SECRET" - WhatsApp — with a WhatsApp Business Cloud API number (inbound messages are free), set
WHATSAPP_VERIFY_TOKENand registerhttps://your-host/api/hooks/whatsappas the webhook. Anyone can then forward events to that number — print it on a flyer and your community becomes the collection network.
Extraction: with ANTHROPIC_API_KEY set, Claude reads the message (and flyer captions) and fills in title, category, dates, deadline, location and link. Without a key it still works — a lightweight parser pulls out the title, link and category, and the reviewer fills the rest. Nothing publishes without review.
Copy .env.example to .env (loaded automatically via Node's --env-file-if-exists):
| Variable | Default | Purpose |
|---|---|---|
PORT |
3000 |
HTTP port |
BASE_URL |
http://localhost:3000 |
Base for links inside emails |
ADMIN_TOKEN |
dev-admin-token |
Auth for admin API/console — change in production |
DB_PATH |
./data/techpulse.db |
SQLite database location |
SMTP_HOST/PORT/USER/PASS |
– | SMTP delivery (unset ⇒ outbox mode) |
MAIL_FROM |
TechPulse <updates@techpulse.local> |
From header |
DIGEST_AUTO |
true |
Enable in-server weekly digest scheduler |
DIGEST_DAY / DIGEST_HOUR |
1 / 9 |
When the weekly digest goes out (server time) |
SYNC_AUTO |
true |
Enable in-server source auto-sync |
SYNC_INTERVAL_HOURS |
24 |
How often sources are synced |
DEADLINE_REMINDER_AUTO |
true |
Enable in-server deadline-reminder scheduler |
DEADLINE_REMINDER_DAYS |
3 |
Remind when a registration deadline is within N days |
Public:
| Method | Route | Description |
|---|---|---|
GET |
/api/events?category=&q=&upcoming=true&closing=true&sort=deadline |
List events (filter by category, search, upcoming; closing=true = registration still open; sort=deadline = closing-soonest first) |
GET |
/api/events/:id |
Single event |
GET |
/api/categories |
Available categories |
POST |
/api/subscribe |
{ email, name?, interests? } — subscribe or update (empty interests = all) |
GET |
/api/preferences?token= |
Read a subscription (token from email links) |
POST |
/api/preferences |
{ token, interests?, name? } — update a subscription |
POST |
/api/unsubscribe |
{ token } — deactivate a subscription |
GET |
/api/health |
Health + mail mode |
Admin (send header x-admin-token: <ADMIN_TOKEN>):
| Method | Route | Description |
|---|---|---|
POST |
/api/events |
Publish a program → emails matching subscribers, returns notified count |
PUT |
/api/events/:id |
Update a program |
DELETE |
/api/events/:id |
Remove a program |
GET |
/api/subscribers |
Subscriber overview |
POST |
/api/digest/send |
Send the digest immediately |
POST |
/api/reminders/send |
Send deadline reminders now ({ days? } to widen the window) |
GET |
/api/captures?status=pending|all |
Review queue |
POST |
/api/captures |
Queue a pasted message ({ text, image?, from? }) |
POST |
/api/captures/:id/approve |
Publish a capture (body fields override the draft) → emails subscribers |
POST |
/api/captures/:id/reject |
Discard a capture |
Webhooks (no admin token — secured by their own secret): POST /api/hooks/telegram/:secret, GET|POST /api/hooks/whatsapp.
| GET | /api/sources | List auto-import sources with last sync status |
| POST | /api/sources | Register a feed: { name, type: "ical"\|"json", url, default_category } |
| POST | /api/sources/sync | Sync all sources now (returns per-source results + subscribers notified) |
| POST | /api/sources/:id/sync | Sync one source now |
| DELETE | /api/sources/:id | Remove a source (imported events stay) |
Example — publish a program:
curl -X POST http://localhost:3000/api/events \
-H 'Content-Type: application/json' \
-H 'x-admin-token: dev-admin-token' \
-d '{
"title": "CodeStorm Hackathon 2026",
"category": "hackathon",
"starts_at": "2026-08-01T09:00:00Z",
"ends_at": "2026-08-03T18:00:00Z",
"location": "Kumasi, Ghana",
"url": "https://example.com/codestorm",
"tags": ["fintech", "students"],
"description": "48 hours of building."
}'npm testCovers the subscribe flow (welcome email, dedupe, validation), event CRUD + auth, interest-based announcement targeting, unsubscribe behaviour, digest sending, and filtering.
server.js entry point + digest & source-sync schedulers
src/
app.js express app factory
auth.js admin-token middleware
db.js SQLite schema & helpers (node:sqlite)
mailer.js SMTP or outbox-file mail transport
notify.js welcome / announcement / batch / digest emails
sources.js feed parsers (iCal, JSON) + sync engine
validate.js input validation helpers
routes/
events.js event listing & admin CRUD
sources.js auto-import source management & sync
subscribers.js subscribe, preferences, unsubscribe, digest
public/ static frontend (home, admin console, preferences)
scripts/
seed.js sample data (fictional events)
send-digest.js cron-friendly digest sender
sync.js cron-friendly source sync
test/ end-to-end API + sync tests
Note: the seed data is fictional sample content (example.com links) for demo purposes.
- Public event submission form with moderation queue
- Double opt-in confirmation emails
- iCal feed / "add to calendar" buttons
- API-key connectors for sources without feeds (Eventbrite, Devpost, MLH)