~/projects/whatsapp-payment-bot

WhatsApp Payment Bot + AI Persona

Self-hosted bot tracking badminton court fees and automating payment reminders

status
production · running
role
Solo engineer — design, build, deploy, operate
timeframe
Late 2025 – June 2026
  • 31/33 scenarios passing Test coverage
  • 3 (private, public, escalating) Reminder modes
  • Live and in daily use Production uptime

n8n · WAHA (Baileys) · Google Sheets · Google Gemini · Docker Compose · ntfy

Problem

In my badminton friend group, after each court booking one person pays the full fee, and the rest owe their share. Without automation, the payer has to remember the amount, chase everyone manually, and track who’s paid—a tedious overhead that breaks down as the group grows. I wanted a bot that sits in the group chat, records each game’s split on a single command, tracks unpaid debts automatically, and escalates reminders so nobody forgets.

Constraints

WhatsApp has no official bot API for this use case. Meta’s official API cannot read an existing friend group (it only works with API-created groups under 8 people, locked behind business verification). I needed an unofficial library—but reliability and maturity mattered since this runs unsupervised on my home server.

The home server is low-spec (HP laptop with HDD), so I couldn’t afford a headless browser library like Selenium or Puppeteer. I needed something lightweight.

Group chats on WhatsApp’s backend use a different addressing model (LID/lid numbers instead of phone numbers for mentions), which most documentation skips—I had to validate the exact webhook payload shape and @-mention mechanics before committing to an approach.

Architecture & decisions

I evaluated OpenWA (wrapper around whatsapp-web.js) but found it lacked two critical features: it omits the sender ID in incoming webhook messages, and it doesn’t support outbound @-mentions that actually ping. Fixable via fork, but that meant owning an immature single-maintainer project.

I chose WAHA (devlikeapro/waha) instead. It’s mature, actively maintained, and offers a NOWEB engine (Baileys under the hood—no Chromium, just WebSocket emulation). This runs in ~40MB RAM instead of hundreds, which matters on a home server shared with other services. WAHA exposes a clean HTTP API; I containerize it on my internal Docker bridge network alongside n8n, so they resolve by container hostname.

n8n orchestrates all business logic. Two workflows handle everything:

  1. Ingest workflow (webhook-triggered): parses /game, /paid, and /status commands from the group, splits the math, writes Games and Debts rows to a Google Sheet, and replies with @-mention pings.
  2. Reminder workflow (daily 20:00 IST cron): reads overdue debts from the sheet, sends nudges in three configurable modes—silent DMs for gentle reminders, public group @-mentions for repeat offenders, or escalating (mix both)—then increments a counter so I can track follow-up cadence.

Google Sheets is the ledger. It’s transparent (the group can see the running tally), integrates cleanly with n8n’s native nodes, and survives any redeployment. The schema is a contract: Members (by LID), Games (per event), Debts (unpaid and paid records), Settings (tunable reminder parameters, no redeploy needed).

Build notes

The trickiest part was WhatsApp LID addressing. Group messages arrive with the sender as <lid>@lid (not a phone), and mentions come as LID-only arrays. To make an @-mention actually ping on send, I have to send both the mentions JID array and literal @<lid-number> tokens in the message body—mentions-only sends silently. I validated this against the live WAHA NOWEB payload before building the reply logic.

Reminder escalation uses a counter strategy. If a debt has never been reminded (reminder_count==0), it’s due after N days from the game date. After that, it’s due every M days. In escalating mode, gentle DMs go out while the count is low; once it hits 2+, I switch to a firm group post to apply social pressure.

I added an AI persona “Baddy” that responds to @-mentions in the group. It runs a lightweight Gemini-powered chat with per-group conversational memory (stored in the sheet) and an automated fact-extraction pipeline using structured-output parsing and category whitelisting. A silent learner runs in the background on every message, categorizing facts (scores, participants, injuries) and deduping them at a per-member cap to avoid noise.

Outcome

The bot went live in production on a spare phone number (not my personal one—a critical risk mitigation for unofficial WhatsApp automation, which carries a small ban risk). Phase 0–4 testing is complete: 31 of 33 test scenarios pass, covering command parsing, split math, reminder modes, edge cases (empty ledger, non-members, decimal amounts), and the AI pipeline. The two gaps are minor (msg-id dedup for idempotency and handling of large LID strings in Sheets). In daily use now, handling payment reminders for seven players with zero manual intervention.

What I’d do differently

I’d have validated the LID addressing model earlier—I wasted time exploring OpenWA before realizing the payload shape was the blocker, not the feature set. In retrospect, reading the WAHA and Baileys source code first would have saved two days.

I’d also bake message deduplication into the ingest workflow from the start. The current version leans on n8n’s webhook onReceived mode to eat duplicates, but a double webhook fire within 1 second can still slip through and silently overwrite rows in Sheets.

The home server ban risk is low but real for unofficial automation. I’d document that more prominently from day one, and I’d have a faster rollback runbook (currently manual; should be automated). The instant soft-off toggle (flip ai_enabled=FALSE in the Settings sheet) is good, but pairing the spare number back after an unlink is still a 5-minute CLI dance.