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.

Payday — API Contracts

Base URL: /api/v1/
Auth: Bearer JWT in Authorization header
Response envelope: { success, data, meta?, timestamp }
Error envelope: { success: false, statusCode, message, timestamp, path }


#Rate Limits

TierRequests / minute
Global (per IP)100
Auth endpoints10
Starter tier100
Pro tier500
Elite tier2,000

#Auth — /auth

MethodPathAuthDescription
POST/auth/registerPublicRegister new account
POST/auth/loginPublicLogin, returns access + refresh tokens
POST/auth/refreshPublicRefresh token rotation
POST/auth/logoutJWTRevoke current refresh token
POST/auth/logout-allJWTRevoke all refresh tokens (all devices)
GET/auth/verify-email?token=UUIDPublicVerify email address
POST/auth/forgot-passwordPublicSend password reset email
POST/auth/reset-passwordPublicSet new password via reset token
GET/auth/meJWTGet current user + profile
GET/auth/googlePublicRedirect to Google OAuth
GET/auth/google/callbackPublicGoogle OAuth callback
GET/auth/applePublicRedirect to Apple Sign-In
POST/auth/apple/callbackPublicApple Sign-In callback (POST)

##POST /auth/register

// Request
{ "email": "marcus@example.com", "password": "SecurePass123!", "displayName": "Marcus Webb" }

// Response 201
{ "user": { "id": "uuid", "email": "...", "membershipTier": "starter" },
  "profile": { "id": "uuid", "displayName": "Marcus Webb", "slug": "marcus-webb" },
  "accessToken": "eyJ...", "refreshToken": "eyJ..." }

// Errors: 409 email taken | 400 weak password / invalid email

##POST /auth/login

// Request
{ "email": "marcus@example.com", "password": "SecurePass123!" }

// Response 200 — same shape as register
// Errors: 401 invalid creds | 403 locked (5 fails → 15min lock) | 403 suspended/pending

##POST /auth/refresh

{ "refreshToken": "eyJ..." }
// Response: { "accessToken": "...", "refreshToken": "..." }
// Errors: 401 expired / invalid / revoked

#Members — /members

MethodPathTierDescription
GET/members/meJWTGet own full profile
PATCH/members/meJWTUpdate own profile
GET/members/discoverJWTPaginated directory with filters
GET/members/suggestedJWTAI match suggestions (10 max, cached 24h)
GET/members/connectionsJWTAccepted connections list
GET/members/leaderboardJWTTop members by reputation
GET/members/:idJWTAny member's public profile
POST/members/:id/connectJWTSend connection request
PATCH/members/connections/:idJWTAccept / reject connection

##GET /members/discover — Query Params

?page=1&limit=20&niche=E-commerce&skills=Paid+Ads,Copywriting
&revenueStage=100k_1m&availability=open_to_deals&isVerified=true&search=marcus

##PATCH /members/me — Body (all optional)

{ "displayName": "Marcus Webb", "tagline": "E-com operator | $500K/mo",
  "bio": "...", "businessNiche": "E-commerce", "revenueStage": "1m_plus",
  "teamSize": 8, "availabilityStatus": "open_to_deals",
  "skills": ["Paid Ads", "Shopify", "Brand Strategy"],
  "socialLinks": { "linkedin": "...", "twitter": "...", "website": "..." } }

#Feed — /feed

MethodPathDescription
GET/feedHome feed (cursor-based pagination)
GET/feed/channelsAll active channels
POST/feed/postsCreate post
PATCH/feed/posts/:idEdit own post (within 30 min)
DELETE/feed/posts/:idSoft-delete own post
POST/feed/posts/:id/reactToggle reaction
GET/feed/posts/:id/commentsPaginated comments
POST/feed/posts/:id/commentsAdd comment
POST/feed/posts/:id/saveToggle save
GET/feed/savedUser's saved posts

##GET /feed — Query Params

?cursor=lastPostId&limit=20&channelId=uuid

##POST /feed/posts — Body

{ "content": "Quick tip for agency owners...", "postType": "text",
  "channelId": "uuid", "mediaUrls": [], "tags": ["agency", "pricing"] }

##POST /feed/posts/:id/react — Body

{ "reactionType": "fire" }
// reactionType: fire | rocket | clap | money | heart | lightbulb
// Calling same type again = toggle off (remove reaction)

#Messaging — /conversations

MethodPathTierDescription
GET/conversationsJWTInbox, ordered by last_message_at
POST/conversationsJWTCreate DM or group chat
POST/conversations/deal-roomEliteCreate Deal Room
GET/conversations/:id/messagesJWTMessage history (cursor pagination)
POST/conversations/:id/messagesJWTSend message
POST/conversations/:id/messages/fileJWTUpload + send file (multipart, max 50MB)
PATCH/conversations/:id/readJWTMark all messages read
DELETE/conversations/:id/messages/:msgIdJWTSoft-delete own message

##POST /conversations — Body

// DM
{ "type": "dm", "participantIds": ["uuid"] }

// Group chat
{ "type": "group", "name": "Agency Mastermind", "participantIds": ["uuid1", "uuid2"] }

#Opportunities — /opportunities

MethodPathTierDescription
GET/opportunitiesJWTPaginated open opportunities
POST/opportunitiesPro+Create opportunity
GET/opportunities/:idJWTOpportunity detail
PATCH/opportunities/:idPro+Update own opportunity
DELETE/opportunities/:idPro+Soft-delete own opportunity
POST/opportunities/:id/applyJWTSubmit application
GET/opportunities/:id/applicationsPro+List applications (poster only)
PATCH/opportunities/:id/applications/:appIdPro+Update application status
GET/opportunities/my-applicationsJWTOwn submitted applications

##POST /opportunities — Body

{ "title": "Paid Ads Manager — Rev Share Deal",
  "description": "...", "oppType": "revenue_share",
  "niche": "Agency", "skillsRequired": ["Facebook Ads", "Google Ads"],
  "compensation": { "type": "revenue_share", "percentage": 25, "currency": "USD" },
  "isRemote": true, "expiresAt": "2025-06-01T00:00:00Z" }
// oppType: full_time | freelance | partnership | revenue_share | jv

#Events — /events

MethodPathTierDescription
GET/eventsJWTUpcoming + past events
POST/eventsPro+Create event
GET/events/:idJWTEvent detail + RSVP status
PATCH/events/:idPro+Update own event
POST/events/:id/rsvpJWTToggle RSVP
PATCH/events/:id/go-livePro+Mark event live + emit Socket.io
PATCH/events/:id/completePro+Mark complete + save recording URL
POST/events/verify-stream-keyPublicRTMP stream key verification (nginx callback)

#Notifications — /notifications

MethodPathDescription
GET/notificationsPaginated inbox (includes unreadCount in meta)
PATCH/notifications/:id/readMark single notification read
POST/notifications/read-allMark all read
GET/notifications/preferencesGet delivery preferences
PATCH/notifications/preferencesUpdate delivery preferences

#Payments — /payments

MethodPathDescription
GET/payments/plansList all tier plans with Stripe price IDs
POST/payments/checkoutCreate Stripe Checkout session → returns { checkoutUrl }
GET/payments/billing-portalGet Stripe billing portal URL
GET/payments/subscriptionCurrent user's subscription status
POST/payments/webhookPublic — Stripe webhook (signature verified)

##Stripe Webhook Events Handled

customer.subscription.created  → activate membership, update users.membership_tier
customer.subscription.updated  → sync tier and status
customer.subscription.deleted  → downgrade to starter, notify user
invoice.payment_failed         → set status=past_due, send email

#Admin — /admin

All require @Roles('admin') or @Roles('moderator')

MethodPathDescription
GET/admin/applicationsPending membership applications
PATCH/admin/applications/:idApprove / reject / waitlist
GET/admin/membersFull member list (admin view)
PATCH/admin/members/:idUpdate member status (suspend, ban, activate)
GET/admin/moderation-queueFlagged content
POST/admin/moderation/:type/:idTake moderation action
POST/admin/broadcastSend announcement (in-app + email)
GET/admin/analyticsPlatform metrics summary

##GET /admin/analytics — Response

{ "dau": 412, "mau": 2840, "totalMembers": 3200,
  "newMembersThisWeek": 47, "postsToday": 183,
  "messagesThisWeek": 2841, "revenueThisMonth": 184300, "churnRate": 0.023 }

#Resources — /resources

MethodPathTierDescription
GET/resourcesJWTPaginated library
POST/resourcesPro+Upload resource
GET/resources/:idJWTResource detail
POST/resources/:id/rateJWTSubmit rating + review

#Health — /health

Public — used by Docker healthcheck + uptime monitors

// GET /health → 200
{
  "status": "ok",
  "info": {
    "database": { "status": "up" },
    "redis": { "status": "up" },
    "diskStorage": { "status": "up" }
  }
}
~ End of Document ~