Founder · 2024 → present · Solo build
InstaEscrow — payments-grade escrow for Kenya's social commerce market
A platform that protects buyers and sellers by holding M-Pesa and card payments until delivery is confirmed. Designed and built solo: payment engine, IAM, real-time event delivery, multi-channel notifications, AI analytics, vendor dashboard, mobile app, and the production infrastructure that runs all of it.
- Role
- Founder & Principal Engineer
- Years
- 2024 → present
- Team
- Solo
- Stack
- Elixir · Phoenix · NestJS · Python · Postgres · Redis
Context — why this exists
Kenya runs much of its commerce on social platforms. WhatsApp, Instagram, Facebook groups. Payment is M-Pesa, card, sometimes bank transfer. Trust is the bottleneck. Buyers send money first and hope, sellers ship first and hope. Disputes go to screenshots and shouting in DMs.
InstaEscrow is the missing escrow layer. Buyer pays into a held balance; seller ships; buyer confirms; funds release. If something goes wrong, a dispute opens against documented evidence rather than DM screenshots.
Architecture overview
Six independently-deployable services, each with one clear responsibility. Service-to-service auth via a shared Elixir SDK. Cross-service state changes propagate through Redis Streams (for eventing) and Oban (for workflow fan-out). Frontend talks to two Phoenix services and an SSE service.
- Payment & escrow engine — Elixir/Phoenix. Wallet management, hold-and-release flows, dispute resolution, seller verification, Paystack and M-Pesa integration with idempotent webhooks and Oban-backed retry guarantees.
- IAM service — Elixir/Phoenix. Phone/email OTP, Google OAuth, JWT tokens, multi-account-type (vendors, buyers), KYC document uploads, Oban fan-out workers syncing to downstream services.
- Real-time event delivery — Elixir/Phoenix. Redis Streams with per-user XREAD BLOCK, missed-event replay via Last-Event-ID, presence tracking with TTL heartbeats, per-user connection caps. Serves SSE to web and mobile.
- Notification platform — NestJS/BullMQ. Push, SMS, WhatsApp, email with campaign scheduling and delivery tracking.
- AI analytics engine — Python/FastAPI. Natural language → SQL across multiple service databases using Ollama/Qwen, with role-based schema access. Results streamed to clients via SSE.
- AI support chat — Python/FastAPI. RAG-based knowledge retrieval, multi-tenant conversation management, escalation workflows. Embedded as a widget in the platform.
Decision: Redis Streams over Phoenix PubSub
Phoenix PubSub is the obvious default for Elixir-native real-time. I ruled it out for cross-service eventing for three reasons:
- Persistence. PubSub is in-memory. If a consumer is down when an event fires, the event is gone. Redis Streams persists events with consumer groups and acknowledgement, so a service that restarts can replay from where it left off.
- Replay for clients. SSE clients reconnecting need missed events between disconnect and reconnect. Streams +
Last-Event-IDgives me this for free; PubSub doesn't. - Operability. Streams shows up in Redis tooling:
XLEN, consumer lag, idle pending entries. PubSub is opaque.
The trade is one extra hop (Phoenix → Redis → Phoenix) and the operational responsibility of running Redis well. Both were already handled by my infrastructure.
The live event widget
The widget below is the same kind of SSE stream that powers the InstaEscrow web app and Flutter mobile app. Phase 1 of this portfolio renders simulated events client-side; Phase 2 will swap the data source for a live Phoenix endpoint at live.erick.africa. The protocol semantics are identical either way — EventSource, per-event IDs, automatic reconnect with replay.
- 11:09:12instaescroworder.createdBuyer placed order #IE-4486 — KES 850
- 11:09:12autopaypayment.authorizedM-Pesa STK push confirmed — KES 1,250
- 11:09:12instaescrowescrow.heldFunds held in escrow #ESC-5104
- 11:09:12logisticsdelivery.dispatchedOrder #IE-0941 dispatched to courier
- 11:09:12instaescrowdelivery.confirmedBuyer confirmed receipt — auto-release in 24h
- 11:09:12autopaypayment.releasedReleased KES 18,000 to seller wallet
- 11:09:12autopaywallet.payoutSeller withdrew KES 32,500 via Paystack transfer
- 11:09:12arbiterdispute.openedDispute #DSP-7195 opened — buyer claims item not received
- 11:09:12arbiterdispute.resolvedDispute #DSP-9027 resolved in buyer's favor
Webhook idempotency — the M-Pesa wrinkle
Paystack and M-Pesa both retry webhooks aggressively if your endpoint is slow. The naive implementation double-credits wallets. The defensive implementation:
- Every webhook carries a provider-issued reference. We store the reference in a uniqueness-constrained
processed_webhookstable inside the same database transaction that mutates wallet balance. - If we crash after persisting but before responding 200, the next retry hits the unique constraint, the second transaction rolls back, we still respond 200. Net effect: exactly-once.
- An Oban worker reconciles pending payments hourly against the Paystack API, repairing any divergence from missed webhooks.
What I'd do differently next time
Two things. First, I'd build the analytics engine before launch rather than after — running blind on traffic for the first month cost me visibility into the funnel. Second, I'd factor the IAM service to share the same Postgres cluster as the escrow engine from day one rather than splitting it later — the cross-cluster joins cost me three weeks of refactoring.
Where it runs
Bare-metal VPS in Frankfurt. Caddy v2 reverse proxy with Let's Encrypt. Docker Compose orchestration. Zero-downtime blue-green deploys via Caddy Admin API. pgBackRest for continuous WAL archiving to Backblaze B2. Grafana + Loki + Promtail for logs and metrics. The same infrastructure pattern this site is deployed on.
Phase 2 of this case study — embedded interactive escrow FSM. Click as buyer or seller, watch the state machine transition through created → funded → shipped → released | disputed → resolved with real Phoenix Channel WebSocket frames in your DevTools.