Skip to content
Return to Projects
Case Study

Payday Chat

A real-time, members-only network for online business founders.

The Problem

Founders needed a private, high-signal place to network, build teams, and scale revenue — not another noisy public feed. Payday Chat is invite-only and built around real-time collaboration.

My Role

Built solo with AI-assisted development — Flutter client, NestJS API, data model, real-time layer, and deployment.

Highlights

  • Real-time messaging with presence and live events over WebSockets
  • One NestJS API powering both a Flutter mobile app and a web client
  • PostgreSQL domain model with Redis for caching and pub/sub fan-out
  • NGINX gateway, Stripe payments, and OAuth authentication

Stack

FlutterDartNestJSPostgreSQLRedisNGINXStripe

Constraints

  • Invite-only product — auth and access control had to be correct from day one.
  • One backend, two clients (Flutter + web) — schema and contracts had to be shared, not duplicated.
  • Realtime is a feature, not a nice-to-have — presence and live events must survive reconnects.

System Architecture

Clients
Flutter Mobile App
Web App
Gateway
NGINX
API
NestJS REST
WebSocket Gateway
Data
PostgreSQL
Redis (cache + pub/sub)
External
Stripe
OAuth

Key Trade-offs

The decisions worth defending — what I chose, what I turned down, and why.

Realtime transport

Chose

WebSockets with Redis pub/sub fan-out

Rejected

Long-polling or third-party realtime SaaS

Predictable latency, no per-message vendor cost, and Redis already in the stack for caching — one fewer moving part.

Mobile client framework

Chose

Flutter (single codebase for iOS + Android)

Rejected

Native Swift + Kotlin clients

Solo build — two native clients would have doubled the surface area and slowed iteration on the API.

API style

Chose

REST + dedicated WebSocket gateway

Rejected

GraphQL subscriptions

Simpler operational story, easier to cache at the NGINX layer, and the realtime channel stays an explicit, observable component.

What I'd Do Differently

An honest retrospective — the stuff I'd change with more time, more users, or a second pass.

  1. 1Introduce contract tests between the NestJS API and the Flutter client earlier — a few breakages were caught only at runtime.
  2. 2Move long-lived sockets to a dedicated process so API deploys don't drop client connections.
  3. 3Add structured event versioning from day one instead of retrofitting it once the schema started moving.

Technical Deep-Dive

Architecture, specifications, and implementation details.

Live Event Streaming — Architecture

#Overview

Payday supports live streaming for virtual events (webinars, masterminds, Q&As). The spec covers two options: a free self-hosted approach (default) and a managed service (upgrade).


#Option A — Self-Hosted RTMP (Free)

Uses a free RTMP container to receive a host's stream and distribute to attendees.

##Extra Docker Service

# Add to docker-compose.yml for production
live-stream:
  image: tiangolo/nginx-rtmp
  container_name: payday-stream
  restart: unless-stopped
  ports:
    - "1935:1935"   # RTMP ingest (host streams to this)
    - "8080:80"     # HLS output (attendees watch via HTTP)
  volumes:
    - ./nginx/rtmp.conf:/etc/nginx/nginx.conf:ro
    - stream_data:/tmp/hls
  networks:
    payday-network:
      ipv4_address: 172.20.0.50

##nginx/rtmp.conf

rtmp {
  server {
    listen 1935;
    chunk_size 4096;

    application live {
      live on;
      record off;

      # Verify stream key before allowing broadcast
      on_publish http://api:3000/api/v1/events/verify-stream-key;

      # HLS output for browser playback
      hls on;
      hls_path /tmp/hls;
      hls_fragment 3s;
      hls_playlist_length 60s;
    }
  }
}

http {
  server {
    listen 80;
    location /hls/ {
      types { application/vnd.apple.mpegurl m3u8; video/mp2t ts; }
      root /tmp;
      add_header Cache-Control no-cache;
      add_header Access-Control-Allow-Origin *;
    }
  }
}

##Stream Key Verification

// In EventsController:
@Post('verify-stream-key')
@Public()
async verifyStreamKey(@Body() body: { name: string; tcurl: string }) {
  // 'name' is the stream key sent by the broadcaster's OBS/software
  const event = await this.eventsService.findByStreamKey(body.name);
  if (!event || event.status !== 'published') {
    throw new ForbiddenException('Invalid stream key');
  }
  return { allowed: true };
}

##How the Host Streams

  1. Host uses OBS Studio (free) or Streamyard
  2. Sets stream destination: rtmp://yourdomain.com:1935/live/{streamKey}
  3. streamKey is auto-generated when event is created: stored in events.stream_key

##How Attendees Watch

// Frontend receives this URL after joining event room via Socket.io:
const hlsUrl = `http://yourdomain.com:8080/hls/${streamKey}/index.m3u8`;

// Use HLS.js in the browser:
const hls = new Hls();
hls.loadSource(hlsUrl);
hls.attachMedia(videoElement);

##Latency: ~5–10 seconds (acceptable for webinars/Q&As)


#Option B — Managed Service (Upgrade)

For production at scale (10K+ concurrent viewers), switch to a managed service.

ServiceFree TierPricing at ScaleIntegration
Mux10k minutes/mo free~$0.015/min viewedBest API, auto transcoding
Cloudflare StreamNone$5/1000 min storedGood for VOD too
LivepeerFree (decentralized)Pay-as-goWeb3-based, very cheap

##Mux Integration (when ready)

// Add mux-node to package.json
// In EventsService.createEvent():
const stream = await muxClient.Video.LiveStreams.create({
  playback_policy: ['signed'],  // requires JWT to view
  new_asset_settings: {
    playback_policy: ['public'],  // recording is public
  },
});

// Store in event record:
event.streamKey = stream.stream_key;        // Host uses this for OBS
event.streamPlaybackId = stream.playback_ids[0].id;  // Attendees use this URL

// Attendee stream URL:
const streamUrl = `https://stream.mux.com/${event.streamPlaybackId}.m3u8`;

#Live Event Flow (Full)

1. Host creates event via POST /events
   → stream_key auto-generated (UUID) and stored in events table

2. Attendee RSVPs via POST /events/:id/rsvp
   → Confirmation email queued
   → Reminder notification queued for 1hr before starts_at

3. Host starts streaming (OBS → RTMP)
   → nginx-rtmp verifies stream key via API
   → HLS segments begin generating in /tmp/hls/

4. Host marks event live: PATCH /events/:id/go-live
   → events.status = 'live'
   → Socket.io emits event_started to all in event:{eventId} room
   → All attendees receive HLS stream URL in the event

5. Attendees watch via HLS.js in browser
   → ~5-10s latency (self-hosted RTMP)

6. Chat during event: uses existing Socket.io messaging
   → All attendees in event:{eventId} room can send messages
   → Messages displayed in real-time overlay

7. Host ends stream → OBS stops
   → HLS segments stop generating
   → Host calls PATCH /events/:id/complete
   → status = 'completed'
   → Optional: upload recording URL

8. Recording added:
   → PATCH /events/:id/complete { recordingUrl: "..." }
   → events.recording_url stored
   → All RSVPed attendees notified via email

#Socket.io Events for Live Streaming

// Server → Client (all attendees in event room)
event_started    → { eventId, streamUrl, startedAt }
event_ended      → { eventId, endedAt, recordingUrl? }
viewer_count     → { eventId, count }   // emit every 30s

// Client → Server
join_event       → { eventId }    // verified by RSVP check, joins room
leave_event      → { eventId }
send_event_chat  → { eventId, message }

// Server → Client (chat)
event_chat       → { userId, displayName, avatarUrl, message, sentAt }

#Recording Storage

After an event ends, the HLS segments can be combined into an MP4.

# FFmpeg command to combine HLS → MP4 (run in media container or post-processing job)
ffmpeg -i /tmp/hls/{streamKey}/index.m3u8 -c copy /app/uploads/recordings/{eventId}.mp4

Then upload to S3 (production) or keep in the uploads volume (dev). The recording URL is stored in events.recording_url and shown in the replay library.

~ End of Document ~