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
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
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.
- 1Introduce contract tests between the NestJS API and the Flutter client earlier — a few breakages were caught only at runtime.
- 2Move long-lived sockets to a dedicated process so API deploys don't drop client connections.
- 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
| Tier | Requests / minute |
|---|---|
| Global (per IP) | 100 |
| Auth endpoints | 10 |
| Starter tier | 100 |
| Pro tier | 500 |
| Elite tier | 2,000 |
#Auth — /auth
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | /auth/register | Public | Register new account |
| POST | /auth/login | Public | Login, returns access + refresh tokens |
| POST | /auth/refresh | Public | Refresh token rotation |
| POST | /auth/logout | JWT | Revoke current refresh token |
| POST | /auth/logout-all | JWT | Revoke all refresh tokens (all devices) |
| GET | /auth/verify-email?token=UUID | Public | Verify email address |
| POST | /auth/forgot-password | Public | Send password reset email |
| POST | /auth/reset-password | Public | Set new password via reset token |
| GET | /auth/me | JWT | Get current user + profile |
| GET | /auth/google | Public | Redirect to Google OAuth |
| GET | /auth/google/callback | Public | Google OAuth callback |
| GET | /auth/apple | Public | Redirect to Apple Sign-In |
| POST | /auth/apple/callback | Public | Apple 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
| Method | Path | Tier | Description |
|---|---|---|---|
| GET | /members/me | JWT | Get own full profile |
| PATCH | /members/me | JWT | Update own profile |
| GET | /members/discover | JWT | Paginated directory with filters |
| GET | /members/suggested | JWT | AI match suggestions (10 max, cached 24h) |
| GET | /members/connections | JWT | Accepted connections list |
| GET | /members/leaderboard | JWT | Top members by reputation |
| GET | /members/:id | JWT | Any member's public profile |
| POST | /members/:id/connect | JWT | Send connection request |
| PATCH | /members/connections/:id | JWT | Accept / 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
| Method | Path | Description |
|---|---|---|
| GET | /feed | Home feed (cursor-based pagination) |
| GET | /feed/channels | All active channels |
| POST | /feed/posts | Create post |
| PATCH | /feed/posts/:id | Edit own post (within 30 min) |
| DELETE | /feed/posts/:id | Soft-delete own post |
| POST | /feed/posts/:id/react | Toggle reaction |
| GET | /feed/posts/:id/comments | Paginated comments |
| POST | /feed/posts/:id/comments | Add comment |
| POST | /feed/posts/:id/save | Toggle save |
| GET | /feed/saved | User'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
| Method | Path | Tier | Description |
|---|---|---|---|
| GET | /conversations | JWT | Inbox, ordered by last_message_at |
| POST | /conversations | JWT | Create DM or group chat |
| POST | /conversations/deal-room | Elite | Create Deal Room |
| GET | /conversations/:id/messages | JWT | Message history (cursor pagination) |
| POST | /conversations/:id/messages | JWT | Send message |
| POST | /conversations/:id/messages/file | JWT | Upload + send file (multipart, max 50MB) |
| PATCH | /conversations/:id/read | JWT | Mark all messages read |
| DELETE | /conversations/:id/messages/:msgId | JWT | Soft-delete own message |
##POST /conversations — Body
// DM
{ "type": "dm", "participantIds": ["uuid"] }
// Group chat
{ "type": "group", "name": "Agency Mastermind", "participantIds": ["uuid1", "uuid2"] }
#Opportunities — /opportunities
| Method | Path | Tier | Description |
|---|---|---|---|
| GET | /opportunities | JWT | Paginated open opportunities |
| POST | /opportunities | Pro+ | Create opportunity |
| GET | /opportunities/:id | JWT | Opportunity detail |
| PATCH | /opportunities/:id | Pro+ | Update own opportunity |
| DELETE | /opportunities/:id | Pro+ | Soft-delete own opportunity |
| POST | /opportunities/:id/apply | JWT | Submit application |
| GET | /opportunities/:id/applications | Pro+ | List applications (poster only) |
| PATCH | /opportunities/:id/applications/:appId | Pro+ | Update application status |
| GET | /opportunities/my-applications | JWT | Own 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
| Method | Path | Tier | Description |
|---|---|---|---|
| GET | /events | JWT | Upcoming + past events |
| POST | /events | Pro+ | Create event |
| GET | /events/:id | JWT | Event detail + RSVP status |
| PATCH | /events/:id | Pro+ | Update own event |
| POST | /events/:id/rsvp | JWT | Toggle RSVP |
| PATCH | /events/:id/go-live | Pro+ | Mark event live + emit Socket.io |
| PATCH | /events/:id/complete | Pro+ | Mark complete + save recording URL |
| POST | /events/verify-stream-key | Public | RTMP stream key verification (nginx callback) |
#Notifications — /notifications
| Method | Path | Description |
|---|---|---|
| GET | /notifications | Paginated inbox (includes unreadCount in meta) |
| PATCH | /notifications/:id/read | Mark single notification read |
| POST | /notifications/read-all | Mark all read |
| GET | /notifications/preferences | Get delivery preferences |
| PATCH | /notifications/preferences | Update delivery preferences |
#Payments — /payments
| Method | Path | Description |
|---|---|---|
| GET | /payments/plans | List all tier plans with Stripe price IDs |
| POST | /payments/checkout | Create Stripe Checkout session → returns { checkoutUrl } |
| GET | /payments/billing-portal | Get Stripe billing portal URL |
| GET | /payments/subscription | Current user's subscription status |
| POST | /payments/webhook | Public — 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')
| Method | Path | Description |
|---|---|---|
| GET | /admin/applications | Pending membership applications |
| PATCH | /admin/applications/:id | Approve / reject / waitlist |
| GET | /admin/members | Full member list (admin view) |
| PATCH | /admin/members/:id | Update member status (suspend, ban, activate) |
| GET | /admin/moderation-queue | Flagged content |
| POST | /admin/moderation/:type/:id | Take moderation action |
| POST | /admin/broadcast | Send announcement (in-app + email) |
| GET | /admin/analytics | Platform 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
| Method | Path | Tier | Description |
|---|---|---|---|
| GET | /resources | JWT | Paginated library |
| POST | /resources | Pro+ | Upload resource |
| GET | /resources/:id | JWT | Resource detail |
| POST | /resources/:id/rate | JWT | Submit 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" }
}
}