Part 1 has the contents list of every section and case study.
Case studies in full
Cutting Churn Before the Ticket Arrives (SaaS)
How Loomstack Inc. consolidated its customer-success stack into a single Airtable workspace, surfacing at-risk accounts 47 days earlier and lifting net revenue retention from 102% to 114%.
Loomstack Inc. is a Series B B2B SaaS company providing workflow automation tooling to mid-market and enterprise customers, with roughly $24M ARR across 280 paying accounts. By Q4 2025 the customer success team was operating across five disconnected tools: HubSpot for CRM data, Zendesk for support tickets, Stripe for subscriptions, a homegrown product-usage dashboard, and a sprawl of Google Sheets for everything that didn’t fit elsewhere. Health scoring was a quarterly spreadsheet exercise, and renewal forecasts were assembled by hand the week before each board meeting.
Over a 9-week engagement we replaced this stack’s coordination layer with a single Airtable workspace, a unified accounts table, computed health scores updated nightly, role-specific Interfaces for CSMs and the VP, and an automation suite that triggers playbooks before churn risk turns into a churn ticket. HubSpot, Zendesk and Stripe still own their primary data; Airtable is the operating surface on top.
Outcomes after 90 days in production
114%: Net revenue retention
47 d: Earlier risk detection
3,742: Automated runs / mo.
$1.2M: Saved ARR (Q1)
At a glance
Before: 5 disconnected tools, quarterly health scoring done in a spreadsheet, renewal forecast assembled by hand, average risk detection lag of 51 days.
After: 1 Airtable workspace as the CS operating layer, health scores recomputed nightly, live forecast on the VP dashboard, average risk detection lag of 4 days.
Background
The client and the problem
About Loomstack
Loomstack sells a workflow automation platform to mid-market and enterprise operations teams across North America and EMEA. Headcount is 180; the customer-facing org is split into 14 CSMs (segmented by Enterprise / Mid-Market), 6 support engineers, and 4 account executives covering expansion. The CS leadership reports to a VP of Customer Success who in turn reports to the COO.
The pain points that triggered the project
Churn was being detected too late. The first signal an account was in trouble was usually a renewal coming up in 30 days and a CSM scrambling. Two enterprise logos worth $640K combined churned in Q3 2025 with effectively no warning.
Health scoring was theatre. A quarterly spreadsheet exercise produced colour-coded statuses that nobody trusted, by the time it shipped, the underlying data was already 4–6 weeks stale.
CSMs lived in eight tabs. A typical customer review meant opening HubSpot, Zendesk, Stripe, Mixpanel, the usage dashboard, the renewal sheet, the QBR notes doc, and the Slack channel, for every account.
Forecasts were unreliable. The VP’s renewal forecast was built in a spreadsheet from CSM-submitted estimates; variance against actuals was running ±22% per quarter.
Playbooks didn’t run. The CS playbook library lived in Notion but was almost never triggered, because nothing automatically noticed when a playbook should fire.
Why Airtable
Loomstack evaluated three category-leading customer success platforms and Airtable. The decision came down to four factors: (1) Loomstack already had clean data sitting in HubSpot, Zendesk and Stripe, what they needed wasn’t another database, but a coordination layer on top, (2) the CS ops team wanted to own the system and iterate on it weekly without a vendor implementation queue, (3) the health-score formula needed to be theirs, not a vendor’s black box, and (4) integration with Slack was non-negotiable. Airtable’s linked-record model, sync sources, and native Slack actions made it the right fit.
Approach
How the implementation was run
We delivered the implementation in four phases over nine weeks. A core working group made up of the VP of Customer Success, two senior CSMs (one per segment), the head of CS Ops, and a RevOps analyst met twice a week to validate the data model, review prototypes, and approve each automation before it went live.
Phase
Weeks
What we did
Output
1. Discovery
1–2
Shadowed CSMs through 5 customer reviews. Audited HubSpot, Zendesk, Stripe and the usage dashboard. Workshopped the health-score formula with leadership.
Process map, data dictionary, agreed health-score definition.
2. Data model & sync
3–4
Designed 11-table relational schema. Configured one-way syncs from HubSpot (accounts/contacts), Stripe (subscriptions) and Zendesk (tickets).
Live Airtable base with synced data; ER diagram.
3. Interfaces & automations
5–7
Built the CSM Command Center, the Account Detail Workspace and the VP Forecast Room. Designed and tested 9 automations.
3 production interfaces; automation library.
4. Pilot, training, go-live
8–9
Piloted with 4 CSMs for one week, trained all 14 CSMs and the VP team, ran a 3-day shadow-mode period before full cutover.
Single-day cutover; 14 / 14 CSMs active on day 1.
Guiding principles
Don’t replace systems of record. HubSpot, Zendesk and Stripe stay as systems of record. Airtable is the system of action on top: pulling from each, surfacing what matters, and writing back where appropriate.
One health score, one definition. Health is a single formula owned by CS leadership, not three competing scores. Every place it appears in the workspace is the same calculation.
Surface signals, don’t bury them. An at-risk account should be impossible to miss. Risk surfaces on the CSM dashboard, the VP forecast, and as a Slack alert, within 24 hours of the signal.
CS Ops owns the base. The head of CS Ops and a RevOps analyst were trained as base administrators and now ship changes weekly without engineering or vendor involvement.
What Was Built
The Accounts table: the heart of the workspace
Every paying customer is a record in the Accounts table. From here, CSMs can see their book of business at a glance, sliced by segment, plan, health band, or renewal window. The grid below shows the production view used by the VP each Monday to triage the team’s at-risk accounts.
Image: Figure 1: The Accounts grid view, grouped by segment and coloured by health band. The footer rolls up total ARR and counts at-risk / critical accounts across the visible records.
Highlights of this view
Health is a coloured chip driven by a formula on the record. It’s the same calculation everywhere it’s shown.
ARR is a rollup from the linked Subscriptions table, which is one-way synced from Stripe, no double-entry, no drift.
CSM is a linked record to the Staff table, so rebalancing books happens in one place and propagates everywhere.
NPS (last) is a lookup from the most recent record in the NPS Responses table, refreshed automatically.
Two more views the team uses every day
Kanban by health band is the view the VP uses on Monday morning. Cards are stack-ordered by ARR, so the largest at-risk accounts always sit at the top. The colour bar on the left of each card encodes the band; the avatar in the corner is the CSM.
Image: Figure 2: Kanban view of the Accounts table, grouped by health band. Healthy / At risk / Critical / Onboarding / Churned columns make book composition obvious in one glance.
The expanded account record
Clicking any row opens the full account detail. This is the screen a CSM lives in during a customer review, every field a CSM might want is on one page, computed live from the underlying tables. The Health Score, Usage Trend, Open Tickets and Days to Renewal all turn red automatically when they cross their respective thresholds.
Image: Figure 3: The expanded record view for ENT-00142 (Halcyon Robotics). Notice that the timeline at the bottom shows the champion-change event detected on Apr 18 that automatically triggered a save playbook.
The Data Model
Eleven tables, one source of truth
The base is built around Accounts as the central entity. Everything else (contacts, subscriptions, tickets, health scores, renewals, playbooks, NPS responses) links back to an account. This single design decision is what makes the rollups, formulas and automations possible.
Image: Figure 4: The 11-table schema. Accounts is the hub; Subscriptions and Tickets are synced from Stripe and Zendesk respectively; the rest is native to Airtable.
Field-level decisions worth calling out
health_score is a single formula combining four sub-scores: usage (40%), support (25%), NPS (20%), commercial signals (15%). Weights live in a Settings table so leadership can tune them without code changes.
health_band is a derived formula (Healthy / At risk / Critical) with bands at 80 and 60, the only place the thresholds are defined in the entire workspace.
days_to_renewal is a formula reading from renewal_date; the renewal automations all read this field, so date arithmetic lives in one place.
Subscriptions is one-way synced from Stripe: Airtable doesn’t write back. ARR is rolled up from synced rows.
Tickets is one-way synced from Zendesk every 15 minutes, open_tickets on the Account is a count rollup.
Interfaces
Three role-based interfaces, one underlying base
Different people on the CS org need different things from the same data. We built three Interfaces on top of the base, each tailored to a role. All three read from and write to the same tables. There is no duplication.
The CSM Command Center
This is the screen a CSM opens when they sit down at their desk. It surfaces the four KPIs that matter to them, the live health distribution of their book, a renewal forecast for the next six months, and a prioritised list of accounts that need attention today, all filtered to their book automatically.
Image: Figure 5: The CSM Command Center, scoped to the logged-in CSM. KPI cards on top; health distribution and renewal forecast charts below; on the right, the accounts that need attention today, sorted by ARR × risk.
Two other interfaces
Account Detail Workspace: the single screen a CSM uses during a customer review. Live health timeline, ticket history, usage chart, contacts directory, active playbooks, and a notes stream, all on one scrollable page.
VP Forecast Room: read-only org-wide view for CS leadership. Net revenue retention trend, weighted renewal forecast by quarter, churn-risk dollar amount, and CSM utilisation. Replaces what used to be a manual Friday afternoon spreadsheet exercise.
Automations
Automating the work that catches churn early
Nine automations remove roughly 3,700 manual touchpoints from the team’s month, but more importantly, they catch churn signals while there’s still time to act. None of them replace a CSM’s judgment; they make sure no signal slips through.
Image: Figure 6: The Automations panel. Nine automations, eight currently enabled, with run counts visible per automation. The detail pane on the right shows the trigger and three actions of the renewal-playbook workflow.
The automation catalogue
Each automation is owned by a named person on the CS Ops team, documented in a Notion runbook, and reviewed monthly.
#
Automation
Trigger
Actions
Replaces
1
Health score recompute (nightly)
Schedule: 2:00 AM, every night
Runs the health-score formula across all accounts; writes a row into Health Scores; updates health_band.
Quarterly spreadsheet exercise
2
Score drop > 15 pts → Alert CSM
Health Scores.delta_30d < −15
Slack DM to CSM + tags account as ‘momentum loss’; logs into the activity timeline.
Nothing: drops were missed
3
Renewal in 90/60/30 days → Playbook
Accounts.days_to_renewal IN (90, 60, 30)
Branches on plan + health to pick playbook (e.g. Enterprise + <80 → exec save). Spawns 5 linked tasks.
Manual playbook lookup in Notion
4
Trial ending in 7 days → Sales handoff
Subscriptions.status = trial AND term_end in 7 days
Creates handoff record; assigns AE; Slack DM to AE with usage summary; books a calendar slot.
CSM-to-AE handoff via email
5
New ticket P1 → Slack war room
Tickets.priority = P1 (synced from Zendesk)
Posts to #cs-warroom; tags CSM, AE, support engineer; creates a Linear issue if flagged.
Manual Slack post by support
6
NPS detractor → Recovery sequence
NPS Responses.score ≤ 6
Sets account flag; emails CSM with NPS comment; creates a follow-up task due in 48 hours.
NPS responses going unread
7
Usage anomaly → Tag account
Usage signal: WAU drop > 25% w/w
Sets a ‘usage_drop’ tag; surfaces it on CSM dashboard ‘Needs attention’ panel.
Caught (sometimes) at quarter-end
8
Onboarding day 14/30/60 check-ins
Schedule: nightly scan of accounts in Onboarding
Spawns the right check-in task at each milestone; emails customer with the day-30 product-tour link.
Three different CSM templates
9
Champion change detected → Playbook
Contacts.is_primary changes OR HubSpot role-change signal
Creates ‘Champion change’ playbook record; spawns a 5-step exec-meeting save plan.
Nothing: usually noticed too late
Worked example: automation #3 in detail
Trigger: nightly check on every Account where days_to_renewal equals exactly 90, 60, or 30 and status = Active.
Action 1 (Conditional branch): evaluates plan + health. Enterprise + health < 80 routes to Playbook A: Executive Save; everything else routes to Playbook B: Standard Renewal.
Action 2 (Create Playbook + tasks): writes a row in the Playbooks table linked to the account, then spawns 5 tasks with due dates working back from the renewal date.
Action 3 (Notify): Slack DM to the CSM with a deep link to the account; appends the record to the VP’s weekly forecast view.
Replaced: CSMs manually checking renewal calendars and looking up playbooks in Notion (frequently the wrong playbook, frequently too late).
Integrations
How the workspace connects to the SaaS stack
Airtable doesn’t replace HubSpot, Zendesk, Stripe, or the product analytics warehouse. It coordinates them.
System
Direction
What flows
Mechanism
HubSpot
One-way (in)
Accounts and Contacts sync nightly. Field-level mapping for industry, employee count, lifecycle stage and owner.
Airtable HubSpot sync source
Stripe
One-way (in)
Subscription rows sync every 4 hours. ARR rolls up from subscription records to the account.
Stripe → Airtable via Zapier
Zendesk
One-way (in)
Tickets sync every 15 minutes. Priority, status, assignee, and time-open are all rollup-able.
Native Zendesk sync
Slack
One-way (out)
Health drops, P1 tickets, renewal-playbook triggers, NPS detractor alerts.
Native Slack action in Automations
Mixpanel / Snowflake
One-way (in)
Daily WAU and feature-adoption metrics per account, used as inputs to the health score.
Snowflake → Airtable via reverse-ETL
Linear
One-way (out)
P1 tickets that need engineering escalation create a Linear issue with customer context attached.
Linear API call from script action
Gmail / Outlook
One-way (out)
Customer-facing emails (NPS follow-ups, day-30 onboarding tour) sent from automation actions.
Native email actions
Permissions & data handling
Workspace plan: Airtable Business with SAML SSO via the company’s identity provider. Two-factor authentication enforced for all users.
Field-level permissions: ARR and renewal amounts are read-only for CSMs and hidden from support engineers; visible to AEs and the VP.
Book-level filtering: CSMs see only their own accounts by default on every interface; the VP sees the full org. No CSM can edit another CSM’s accounts.
Audit: Airtable’s revision history is retained, and a nightly backup is exported to the company’s S3 bucket via the Airtable API.
Results
Measurable change after 90 days in production
Metric
Before
After
Change
Net revenue retention (NRR)
102%
114%
+12 pts
Average risk-detection lag
51 days
4 days
−92%
Renewal forecast variance
± 22%
± 6%
−73%
At-risk ARR identified earlier
n/a
$1.2M (Q1)
+ saved revenue
CSM time spent on reporting
≈ 6 hr / week
≈ 45 min / week
−87%
Playbook trigger rate
≈ 14% of cases
100% of cases
+ coverage
Automated touchpoints / month
0
3,742 across 8 active flows
n/a
CSM adoption (firm-wide)
n/a
14 / 14 active on day 1
100%
In the words of the team
The thing that changed isn’t really the dashboard: it’s that the team has time. My CSMs aren’t building reports any more, they’re actually talking to customers. And when an account starts going sideways, we know about it the next morning, not three weeks later when the renewal blows up. We saved $1.2M of ARR last quarter that would otherwise have walked out the door.
What we’d do again, and what we’d change
Worked well: co-designing the health-score formula with leadership in week 1. Because they owned it from day one, nobody argued with the scores once they started shipping.
Worked well: shipping the CSM Command Center first and the VP Forecast Room last. Adoption follows the front line.
Would change: we underestimated the data-cleanup burden in HubSpot during phase 2. About 11% of accounts had missing or stale ownership; cleaning that up consumed three days we hadn’t planned for.
Would change: add an in-product feedback widget that writes directly to the NPS Responses table. The current Typeform-based flow has a higher drop-off than we’d like.
AI-powered SaaS platform for Dubai real estate brokerages. Capture inbound leads, classify with GPT-4o, auto-reply via WhatsApp, assign to agents, and track follow-ups, all behind Stripe subscription billing.
Section: Custom SaaS Platforms
Image: The LeadFlow AI landing page: “Never miss a hot lead again”.
Type: Product Case Study, SaaS Platform
Product: LeadFlow AI, Dubai Real Estate Lead Automation
Industry: Proptech / Real Estate (Dubai, UAE)
Frontend: React (Vite) + Tailwind CSS, hosted on Vercel
Backend: Node.js + Express, hosted on Render
Database: Firebase Firestore + Firebase Auth
AI Engine: OpenAI GPT-4o
Messaging: WhatsApp Cloud API (Meta Graph API v21.0)
Payments: Stripe (AED subscriptions with 7-day trial)
Why we built this
Dubai’s real estate market moves fast. Leads come in around the clock from buyers, renters, and investors often in multiple languages, often with a specific unit, area, or budget in mind. Brokerages that respond within minutes close deals; those that respond the next day don’t.
The core problems Dubai brokerages were facing:
No consistent lead qualification: every agent applied their own judgment to decide which leads to call first, leading to hot buyers being left to wait while cold inquiries were chased
Response time was too slow: manually reading, sorting, and replying to leads during business hours meant international prospects in different time zones received no reply at all
WhatsApp was unmanaged: the primary communication channel in the UAE was handled informally, with no message history, no threading, and no record-keeping in the CRM
Language was a barrier: Dubai’s buyer pool is global; leads arrived in Arabic, Russian, Chinese, French, and English, and most brokerages had no way to reply professionally in anything other than English
Agent workload was unbalanced: lead distribution depended on who was online or who a manager happened to message, with no fair rotation system
Follow-up fell through the cracks: with no automated reminders, leads that needed a call in three days were simply forgotten
The goal was to build a turnkey SaaS platform that any Dubai brokerage could subscribe to, embed a lead capture form on their website, and immediately have every inbound enquiry processed, classified, replied to, and assigned, automatically, in any language, within seconds.
What this system does
LeadFlow AI is a multi-tenant SaaS platform that gives Dubai real estate brokerages two things: an automated lead pipeline and a dashboard for managing it. Every brokerage gets its own isolated data environment, its own agent team, and its own Stripe subscription.
Automated lead pipeline
Accepts inbound leads via a REST API endpoint that can be embedded into any website contact form, landing page, or chatbot
Validates and checks the brokerage subscription before processing: if the Basic plan’s 100-lead monthly limit is reached, the endpoint returns a clear upgrade prompt
Sends the lead through GPT-4o for classification: language detection, intent classification (buy / rent / invest / general inquiry), temperature scoring (hot/warm / cold), and AI-generated reply
Assigns the lead to an agent via atomic round-robin rotation, a Firestore transaction ensures fair distribution even under concurrent submissions
Sends the AI-generated reply directly to the lead’s WhatsApp number via the Meta Cloud API, in the same language the lead wrote in
Stores the complete lead record, AI analysis, and message thread in Firestore for full history
AI engine (GPT-4o)
System prompt defines a “premium Dubai luxury real estate” tone, professional, no emojis, no slang, concise but warm
Detects lead language automatically (ISO 639-1 codes: en, ar, ru, zh, fr, and others) and replies in that language
Classifies intent as buy, rent, invest, or inquiry based on message content
Tags lead temperature: hot (specific budget/area / timeline), warm (genuine interest, lacks specifics), or cold (general browsing)
Runs at temperature 0.4 with JSON response format enforced, consistent, structured output on every call
Falls back to a safe default reply if the AI call fails, ensuring no lead is ever left without an acknowledgement
WhatsApp integration (Meta Cloud API)
Outbound: sends AI replies and agent messages to leads via the WhatsApp Cloud API (Meta Graph API v21.0)
Inbound: receives replies from leads via a verified Meta webhook, matches the sender’s phone number to the correct lead record, and stores the message in the thread
Webhook verification uses Meta’s challenge-response flow with a custom verify token
All incoming payloads are validated via HMAC-SHA256 signature using the Meta App Secret before processing
Phone numbers are cleaned (stripped of spaces, dashes, and plus signs) before every API call
Owner dashboard
Displays total leads, hot leads count, follow-ups due today, and new leads this week
Shows agent performance table: total leads assigned vs. leads closed per agent
Lists the 10 most recent leads with full status, temperature, and intent data
The owner can reassign leads between agents directly from the dashboard
Visible only to users with the owner role: enforced server-side via requireRole middleware
Agent dashboard
Shows assigned leads, active (non-closed) leads, and follow-ups currently due
Lists the agent’s 10 most recent assigned leads
Agents can only view and interact with leads assigned to them, enforced in every route handler
Agents can send messages to leads, update lead status, and set follow-up dates
Subscription billing (Stripe)
Two plans available: Basic (AED 149/month, 100 leads) and Pro (AED 399/month, unlimited leads + analytics)
Both plans include a 7-day free trial before billing begins
Stripe Checkout handles the payment flow; Stripe Billing Portal handles plan changes and cancellations
Stripe webhooks update Firestore user records in real time on subscription activation, update, and cancellation
Agents automatically inherit their owner’s subscription status: if the owner’s plan lapses, agents lose access too
Follow-up automation
Agents set a follow-up date on any lead directly from the lead detail page
A scheduled cron job runs every day at 8:00 AM UAE time (UTC+4) via node-cron
Any lead with a follow-up date on or before today and a non-closed status is flagged followUpDue: true in Firestore
The agent dashboard surfaces these flags as a follow-up due count, creating a daily task list without manual tracking
Benefits
Instant first response at any hour: every lead gets a professional WhatsApp reply within seconds, regardless of time zone or day of the week
Replies in the lead’s language: Arabic, Russian, Chinese, French, and English buyers all receive a response in their own language without any manual effort
Fair, automatic agent assignment: round-robin distribution via Firestore transactions means no agent is overlooked and no manager needs to manually route leads
Hot leads surface immediately: buyers who mention a specific budget, area, or timeline are flagged HOT so agents know exactly who to call first
Complete conversation history: every WhatsApp message in and out is stored against the lead record, giving agents full context before they pick up the phone
No missed follow-ups: the daily cron job ensures agents see overdue leads on their dashboard each morning without any manual diary management
Subscription-gated access: brokerages pay per month; if a plan lapses, access is revoked automatically via Stripe webhook sync
Multi-tenant by design: each brokerage’s data is isolated by ownerUid; no lead or agent data is ever visible across brokerage accounts
How it works (simplified)
A lead submits a form on the brokerage’s website, the form POSTs to the public /api/leads endpoint with name, email, phone, message, and ownerUid
Subscription and plan limits are checked: the server verifies the brokerage has an active or trialing Stripe subscription, and that the Basic plan lead cap has not been reached
GPT-4o processes the lead: language is detected, intent classified, temperature scored, and a professional reply generated in the lead’s own language
The lead is assigned to an agent: a Firestore transaction reads the round-robin counter, selects the next agent in the rotation, and atomically increments the counter
The lead record is written to Firestore, all fields stored: name, contact details, message, AI analysis (language, intent, temperature), assigned agent, status, and timestamps
The AI reply is sent via WhatsApp: if a phone number was provided, the generated reply is dispatched through the Meta Cloud API, and the WhatsApp message ID is stored
The API returns a success response: the caller receives the lead ID, AI reply text, intent, temperature, and assigned agent ID
The agent is notified via their dashboard: the new lead appears in their assigned leads list with full AI analysis visible
If the lead replies on WhatsApp: the message arrives at the Meta webhook, is validated, matched to the lead by phone number, and stored in the messages collection
Follow-up dates are tracked automatically: the daily cron job at 8 AM UAE time flags any lead whose follow-up date has arrived, surfacing it on the agent’s dashboard
System architecture (simplified)
Image: Architecture: inbound leads reach a public API and pass a five-step pipeline (plan limits, GPT-4o classification and reply, round-robin agent assignment, Firestore write, WhatsApp reply), with a Stripe billing layer and a daily follow-up job.
Subscription plans
LeadFlow AI is sold as a monthly SaaS subscription in UAE Dirhams. Both plans include a 7-day free trial before the first charge. Stripe Checkout handles the payment flow; Stripe Billing Portal handles self-service plan changes and cancellations.
Plan
Price
Lead Limit
Included Features
Basic
AED 149 / month
100 leads / month
AI classification, WhatsApp auto-reply, agent assignment, follow-up tracking
Pro
AED 399 / month
Unlimited leads
Everything in Basic + analytics dashboard, agent performance tracking, priority support
The Basic plan lead limit is enforced server-side on every lead submission. When the 100-lead monthly cap is reached, the API returns an HTTP 429 response with a clear upgrade message. Pro plan subscribers have no cap.
Dashboard overview
Owner dashboard
The owner's view is the brokerage’s operational command centre. It shows four headline KPIs (total leads, hot leads, follow-ups today, new leads this week), an agent performance breakdown, and a live feed of the most recent leads. Owners can reassign any lead to a different agent directly from this view.
Role-restricted: visible only to owner accounts
Agent dashboard
The agent's view is designed for daily use. It shows the agent’s assigned lead count, active (non-closed) lead count, and follow-ups currently due, the three numbers that define their working day. Below the KPIs is a list of their most recently assigned leads.
Agents only see leads assigned to them: enforced at the API level
Lead detail page
Each lead has a dedicated detail page showing all contact information, AI classification results (language, intent, temperature), the full WhatsApp message thread, and status controls. Agents can update the lead’s status, set or clear a follow-up date, and send new messages directly from this page.
Message thread includes AI replies, agent messages, and incoming WhatsApp replies in chronological order
Pricing page
Public-facing pricing page displays both plans with feature lists and a “Start Free Trial” call-to-action. Clicking a plan triggers Stripe Checkout in AED, with the 7-day trial clearly communicated before any payment details are entered.
Currency displayed in AED; trial communicated pre-checkout
Lead classification & AI scoring
Every inbound lead is processed by GPT-4o using a structured system prompt that enforces a consistent professional tone and returns strict JSON. The model evaluates four dimensions simultaneously and returns them in a single API call.
Temperature
Score
Routing Action
Classification Criteria
HOT
High
Immediate agent assignment + WhatsApp reply
Mentions specific budget, area, or timeline. Clearly ready to act.
WARM
Medium
Assigned + follow-up date set
Shows genuine interest but lacks specifics about budget, area, or timing.
COLD
Low
Stored, lower priority nurture
General inquiry, browsing behaviour, or unclear intent.
The AI prompt explicitly defines the classification rules for each temperature band, prohibits emojis and slang, and instructs the model to respond in the detected language of the lead’s message. The system prompt is fixed (not user-editable) to ensure brand consistency across all brokerages.
Project evolution & key milestones
The platform scope expanded significantly from an initial lead capture proof-of-concept. Key additions and refinements delivered across the build:
AI & classification
GPT-4 integration upgraded to GPT-4o for higher accuracy and lower latency
Temperature set to 0.4 after testing: lower values produced overly rigid replies; higher values produced inconsistent classification
Fallback reply added, so no lead is ever unanswered if the OpenAI API is unavailable
Intent classification expanded from buy/rent to include invest and inquiry as distinct categories
WhatsApp integration
Initial outbound-only integration expanded to support two-way messaging via the Meta webhook
Incoming message matching logic added: sender phone matched to the most recent lead record in Firestore
HMAC-SHA256 signature validation added using the Meta App Secret for all incoming webhook payloads
Phone number cleaning function added to handle +, spaces, and dashes from various form inputs
Agent assignment
Initial random assignment replaced with deterministic round-robin using a Firestore atomic counter transaction
Counter document stored per brokerage (assignment_{ownerUid}) to ensure correct rotation per team
Graceful fallback: if no agents exist, the lead is stored unassigned rather than failing
Subscription billing
Stripe integration added with two recurring AED products (Basic and Pro)
7-day free trial added to subscription_data at checkout session creation
Stripe webhook handler added for checkout.session.completed, customer.subscription.updated, and customer.subscription.deleted events
Subscription status synced to Firestore user record in real time on every Stripe event
Basic plan 100-lead monthly cap enforced server-side via Firestore count() query
Stripe Billing Portal endpoint added for self-service plan management
Security & access control
Firebase Auth middleware added to all protected routes; public lead capture endpoint intentionally left unprotected
requireSubscription middleware added to check subscription status for both owners and agents (agents inherit from the owner)
requireRole middleware added for owner-only routes (owner dashboard, agent management)
Raw body preservation implemented for Stripe and WhatsApp webhook routes (bypassing Express JSON parsing)
Follow-up system
followUpDate field added to lead schema; agents can set it from the lead detail page
Daily cron job added via node-cron: runs at 8:00 AM UAE time (UTC+4 = 04:00 UTC)
Batch Firestore update sets followUpDue: true on all leads whose date has arrived, for efficient dashboard display
Data flow architecture
A reference overview of how data moves through each stage of the system and at what cadence:
Stage
Source
Destination
Trigger / Cadence
Lead intake
Website form / API call
Express backend
On submission (real-time)
AI processing
OpenAI GPT-4o
Firestore lead record
Per lead, synchronous
Agent assignment
Firestore counters (round-robin)
Lead record (assignedAgentId)
Per lead, synchronous
WhatsApp reply
AI-generated reply text
Lead's phone (Meta API)
Per lead, async
Follow-up check
Firestore leads collection
followUpDue flag on each lead
Daily at 8:00 AM UAE time
Dashboard refresh
Firestore (Realtime queries)
Owner / Agent dashboard
On page load / request
Subscription sync
Stripe webhook events
Firestore user record
On Stripe event (real-time)
Note
Firestore is the single source of truth for all lead records, message threads, user profiles, subscription status, and round-robin counters. The Firebase Admin SDK (backend) and Firebase Web SDK (frontend) both read from the same Firestore project, with security rules and server-side middleware providing access control.
API reference
All endpoints are prefixed with /api/. Protected routes require a Firebase ID token in the Authorization: Bearer <token> header. The lead capture endpoint (/api/leads POST) is intentionally public to allow embedding on any brokerage website without authentication.
Method
Endpoint
Description
POST
/api/leads
Public lead capture: no auth required. Runs AI, assigns agent, sends WhatsApp.
GET
/api/leads
List leads (owners see all, agents see assigned only). Filterable by status, temperature, intent.
GET
/api/leads/:id
Single lead detail with access control (role-based).
PATCH
/api/leads/:id
Update lead status, follow-up date, or agent assignment.
GET
/api/leads/:id/messages
Retrieve full message thread for a lead.
POST
/api/leads/:id/messages
Agent sends a message, delivered via WhatsApp if phone is available.
GET
/api/dashboard/owner
Owner dashboard: total leads, hot leads, follow-ups today, agent performance.
GET
/api/dashboard/agent
Agent dashboard: assigned leads, active leads, follow-ups due.
POST
/api/stripe/create-checkout
Create Stripe Checkout session (Basic or Pro plan, AED, 7-day trial).
POST
/api/stripe/portal
Create Stripe Billing Portal session for plan management.
Meta webhook verification challenge (subscribe mode).
POST
/api/whatsapp/webhook
Receive incoming WhatsApp messages, match to lead by phone, store in messages collection.
GET
/api/health
Health check: returns server status and timestamp.
Security
LeadFlow AI is built with multi-tenant security as a first-class requirement. Each security layer is implemented independently so that no single point of failure exposes brokerage data.
Security Layer
Implementation
Firebase ID Token Auth
All protected routes verify Bearer token via Firebase Admin SDK. Invalid or expired tokens return 401.
Subscription Gate
Middleware checks owner subscription status (active / trialing) before allowing access. Agents inherit their owner’s subscription.
Role-Based Access Control
requireRole(‘owner’ | ‘agent’) middleware enforces role boundaries. Agents can only access their assigned leads.
WhatsApp Signature Validation
Incoming Meta webhooks validated via HMAC-SHA256 using App Secret. Invalid signatures return 401.
Stripe Webhook Verification
Stripe events verified using stripe.webhooks.constructEvent with the webhook signing secret.
Helmet HTTP Headers
Sets security headers (XSS, clickjacking, content-type sniffing) via the Helmet middleware.
CORS Restriction
Requests accepted only from the configured FRONTEND_URL. Credentials are required for authenticated calls.
Plan Limit Enforcement
Basic plan lead cap (100/month) enforced server-side via Firestore count query on every lead submission.
Image: The pricing page: a Basic plan at AED 149 per month and a Pro plan at AED 399 per month, both with a 7-day free trial.
Image: The live demo: a sample enquiry is classified by GPT-4o as a HOT lead, with intent, urgency, language and an AI-generated WhatsApp reply.
A full-stack MERN real estate operations platform with CRUD, Google Auth, live charts, and Cloudinary image management, built on the Refine framework with Material UI.
Section: Custom SaaS Platforms
Image: The Estato dashboard: revenue, property, agent and customer tiles, a monthly revenue chart and a property referrals breakdown.
Real estate teams at any scale face the same structural problem: their properties, agents, and performance data live in disconnected places. Listing portals hold property records. Spreadsheets track agent performance. Photos are stored in email threads or local drives. There is no single screen that shows the full picture.
The core problems Estato was designed to solve:
No centralised property database: listings were scattered across portals and spreadsheets with no consistent schema for type, location, price, or description
Agent visibility was absent: managers had no way to see, at a glance, which agents held the most listings or how the portfolio was distributed across the team
Creating and editing listings was slow: no standard form, no structured property types, and no direct photo upload meant listings were inconsistent and time-consuming to publish
No dashboard-level KPIs: total properties for sale, total for rent, total customers, and total agents were metrics that required manual counting across multiple systems
No referral source insight: the team could not measure which channels (social media, marketplaces, websites, digital ads) were driving enquiries
Authentication was unmanaged: agents accessed tools via shared passwords with no individual accountability and no way to tie listings back to a specific user
The goal was a unified MERN-stack dashboard where every agent logs in with Google, can create and manage their own listings with photo upload, and managers can see live KPIs, revenue trends, and the full agent roster in one place.
What this system does
Estato is a full-stack MERN application that gives real estate teams a complete operations interface: live analytics, a full property CRUD system, an agent directory, individual profiles, and Google-authenticated access. The Refine framework handles data fetching, routing, and auth integration; Material UI provides the component layer; ApexCharts and custom donut charts deliver the analytics.
Operations dashboard
Four donut-style KPI tiles at the top of the home page give instant visibility: Properties for Sale (live count from the API), Properties for Rent, Total Customers, and Total Agents, each with a percentage ring showing the proportion against a target
Total Revenue area chart (ApexCharts) renders monthly revenue data across the full year with a smooth curve and gradient fill
Property Referrals horizontal bar chart shows the contribution of each lead source: Social Media (64%), Digital Ads (80%), Websites (50%), Marketplace (40%), and Others (15%).
Latest Properties section beneath the charts shows the 4 most recently added properties as live cards pulled from the API in real time
Property management (full CRUD)
All Properties page renders the full listing catalogue as a responsive card grid, with live search by title, filter by 8 property types (Apartment, Villa, Farmhouse, Condos, Townhouse, Chalet, Studio, Duplex), price sort toggle, and server-side pagination with a configurable page-size selector (5, 10, 20, 30, 40, 50, All)
Property Detail page shows the full-size property photo, property type with a 5-star rating, title, location, price, and description on the left. The right sidebar shows the creator agent’s avatar, name, property count, and location. Context-aware action buttons adapt based on the logged-in user: Message and Call for other agents’ listings; Edit and Delete for the current user’s own listings
Create Property form collects title, property type, description, location, price, and photo. The photo is read into base64 via the FileReader API and submitted to the backend, which uploads it to Cloudinary and stores the CDN URL
Edit Property form pre-populates all existing field values and allows any field to be changed, including the photo. If the photo is unchanged, the existing Cloudinary URL is preserved
Delete uses a Mongoose session transaction to remove the property document and simultaneously pull it from the creator’s allProperties array, keeping both collections in sync atomically
Agent management
Agents List page displays every registered user as an agent card with their Google avatar, name, email address, and total property count
Agent Profile page shows the full agent detail view via the shared Profile component, including the agent’s complete property portfolio
My Profile page uses the same shared Profile component for the currently authenticated user, with data fetched by their own MongoDB user ID
Authentication
The login page renders a Google Sign-In button using the Google Identity Services API. The credential response is a signed JWT
parseJwt utility decodes the Google JWT on the client to extract name, email, avatar (picture), and sub (user ID) from the payload
On every login, the user profile is POST-ed to /api/v1/users, which is idempotent: it returns the existing MongoDB document if the email already exists, or creates a new user record if not
The raw Google credential token is stored in localStorage as token. Axios’s request interceptor attaches it as Authorization: Bearer <token> to every subsequent API call
On logout, token and user are removed from localStorage and google.accounts.id.revoke() is called to invalidate the Google session
Light and dark mode
ColorModeContextProvider wraps the entire application and exposes a setColorMode function through React context
The current mode is stored in localStorage to persist the user’s preference across sessions. On first load, the system’s prefers-color-scheme media query is checked to set the default
Two custom Material UI themes are defined: overridedLightTheme and overridedDarkTheme, each extending Refine’s built-in theme objects with custom palette overrides. The active theme is passed to ThemeProvider at runtime
Benefits
Single source of truth for the portfolio: every property, agent, and relationship lives in one MongoDB database with a consistent schema, accessible from any device
Live KPIs on every login: four dashboard tiles give the team instant situational awareness without opening a single spreadsheet
Consistent listing creation: the structured form with defined property types ensures every listing is created to the same standard, with a real photo uploaded directly to Cloudinary
Role-aware property actions: agents can only edit or delete their own listings; other agents’ listings show contact options instead
Atomic data integrity: create and delete operations use Mongoose session transactions to keep the Property collection and the User.allProperties array perfectly synchronised
Zero password management: Google OAuth means no passwords to store, reset, or manage; every agent has their own authenticated identity
Scalable Refine architecture: adding a new resource (reviews, messages, enquiries) requires registering it in App.tsx; the data provider, routing, and UI scaffolding are automatically available
Dark/light mode out of the box: system preference is respected on first load; the preference is then stored in localStorage and toggleable at any time
How it works (simplified)
Agent visits the login page and clicks ‘Sign in with Google’. Google Identity Services returns a signed JWT credential
parseJwt decodes the JWT client-side to extract the user’s name, email, and avatar. The profile is POST-ed to /api/v1/users, which creates or finds the user in MongoDB
Token stored in localStorage; Axios interceptor attaches it as a Bearer token to every subsequent request. Refine’s checkAuth hook reads localStorage on each route change to decide whether to redirect to login
Dashboard loads and fires a useList call to /api/v1/properties (pageSize: 4) for the Latest Properties grid. The 4 KPI donut tiles and both ApexCharts components render with static or aggregate data
All Properties page uses Refine’s useTable hook, which manages current page, page size, sorter, and filters as state and translates them into query params on the API call (_start, _end, _sort, _order, title_like, propertyType)
Creating a property: the agent fills the form. On photo selection, FileReader reads the file as a base64 data URL. On submit, onFinish POSTs the full payload (including the base64 photo and the agent’s email) to /api/v1/properties
Server receives the POST, finds the user by email, uploads the base64 photo to Cloudinary, receives back a CDN URL, then opens a Mongoose session and atomically creates the Property document and pushes its _id to user.allProperties
Property Detail page calls useShow to fetch /api/v1/properties/:id with the creator user document populated. Action buttons (Edit/Delete vs Message/Call) are conditionally rendered by comparing user.email with propertyDetails.creator.email
Deleting a property opens a browser confirmation dialog. On confirm, useDelete fires a DELETE request. The server opens a Mongoose session, removes the Property document, and pulls its _id from creator.allProperties, then commits the transaction
Agent and profile pages call useList / useShow against /api/v1/users, which returns user documents with all properties populated. The shared Profile component renders the same layout for both Agent Profile and My Profile
System architecture
Image: Architecture: a React 18 and TypeScript browser app built on Refine with Material UI talks over HTTPS to an Express server that uses MongoDB Atlas and Cloudinary.
Page inventory
Estato consists of 9 pages, each a React TypeScript component managed by Refine’s routing and data layer.
Page
File
Key Content
Dashboard (Home)
home.tsx
4 PieChart KPI tiles, TotalRevenue ApexChart, PropertyReferrals bar chart, Latest Properties card grid (4 items live from API).
All Properties
all-properties.tsx
Full property grid with search by title, filter by 8 property types, sort by price (asc/desc), page size selector, Previous/Next pagination.
Property Details
property-details.tsx
Full-width photo, property type + 5-star rating, title, location, price, description. Agent sidebar: avatar, name, property count, location, Google Map, Book Now, Message/Edit + Call/Delete buttons.
Create Property
create-property.tsx
Delegates to shared Form component. Handles image file selection (FileReader to base64), attaches logged-in user email before submitting to API.
Edit Property
edit-property.tsx
Same shared Form component as Create. Pre-populates existing property data. Allows photo re-upload; photo URL preserved if unchanged.
Agents List
agent.tsx
Grid of all users (agents) from /api/v1/users. Each AgentCard shows avatar, name, email, and property count. Clicking navigates to agent profile.
Agent Profile
agent-profile.tsx
Full agent detail using shared Profile component. Shows avatar, name, email, properties count and full property list.
My Profile
my-profile.tsx
Logged-in user’s own profile using the same shared Profile component. Data fetched from /api/v1/users/:userId.
Login
login.tsx
Google Sign-In button using Google Identity Services. On success, JWT decoded, user POSTed to /api/v1/users, token stored in localStorage.
Data models
Two Mongoose schemas define the complete data layer. The bidirectional relationship between Property and User is maintained by the server using session transactions on create and delete operations.
Collection
Field
Type
Notes
Property
title
String (required)
Listing headline
Property
description
String (required)
Full description text
Property
propertyType
String (required)
Apartment, Villa, Farmhouse, Condos, Townhouse, Chalet, Studio, or Duplex
Property
location
String (required)
Free-text location / address
Property
price
Number (required)
Numeric value (rendered as $price per day in the UI)
Property
photo
String (required)
Cloudinary CDN URL after base64 upload
Property
creator
ObjectId → User
Populated on read to include full user document
User
name
String (required)
Full name from Google profile
User
email
String (required)
Google account email; used as unique identifier
User
avatar
String (required)
Google profile picture URL
User
allProperties
[ObjectId] → Property
Bidirectional reference array; maintained via Mongoose transactions
Note
The User schema does not require a password field because authentication is handled entirely by Google OAuth. The email address serves as the unique identifier and is used both for login matching and for associating new properties with the correct creator.
API reference
All endpoints are prefixed with /api/v1/ and served by the Express server on port 8080. No authentication middleware is currently applied server-side; auth is enforced client-side by Refine’s checkAuth hook and the Axios Bearer token interceptor.
Method
Endpoint
Description
GET
/api/v1/properties
List all properties. Supports _start, _end, _sort, _order, title_like, propertyType query params. Returns x-total-count header for Refine pagination.
GET
/api/v1/properties/:id
Fetch a single property with creator user populated (name, email, avatar, allProperties).
POST
/api/v1/properties
Create a new property. Uploads photo to Cloudinary via base64. Uses Mongoose session transaction to atomically create property and push to user.allProperties.
PATCH
/api/v1/properties/:id
Update an existing property. Re-uploads photo to Cloudinary. Falls back to original photo URL if upload fails.
DELETE
/api/v1/properties/:id
Delete a property. Uses Mongoose session transaction to remove from DB and pull from creator’s allProperties array atomically.
GET
/api/v1/users
List all users (agents). Supports _end for limit. Returns full user documents including allProperties references.
POST
/api/v1/users
Create or find a user by email (idempotent). Called on every Google OAuth login to ensure the user record exists in MongoDB.
GET
/api/v1/users/:id
Fetch a single user by MongoDB _id with allProperties populated. Used by agent profile and my profile pages.
Dashboard overview
Home: KPI dashboard
The home page opens with four donut-style KPI tiles: Properties for Sale (live count from the API), Properties for Rent, Total Customers (5,684), and Total Agents (684). Each tile uses a two-segment ApexCharts donut series to visualise the proportion as a ring. Below the tiles, a TotalRevenue area chart spans the full width alongside a PropertyReferrals horizontal bar chart.
Route: / (DashboardPage)
All Properties
The properties page renders the full listing catalogue as a card grid. The toolbar above the grid provides a Sort price button (toggles asc/desc), a title search text field, and a property type dropdown filter. Pagination controls at the bottom include Previous/Next buttons, a current page indicator, and a page-size selector. All filter, sort, and page state is managed by Refine’s useTable hook and translated into API query parameters automatically.
Route: /properties
Property Details
The detail page is a two-column layout. The left column shows the full-width property photo (546px height), property type, a 5-star rating row, title, location pin with text, price in blue, and a description block. The right sidebar contains the agent card (Google avatar, name, agent role, location, property count) followed by action buttons that are context-aware: Message + Call for listings the current user does not own; Edit + Delete for their own listings. A Google Maps screenshot and a Book Now button fill the remaining sidebar space.
Route: /properties/show/:id
Create / Edit Property
The shared Form component renders the same layout for both Create and Edit. Fields: Title, Property Type (dropdown: Apartment, Villa, Farmhouse, Condos, Townhouse, Chalet, Studio, Duplex), Description, Location, Price, and Photo (file input). On Edit, all fields are pre-populated from the existing record. The photo preview renders below the file input once a file is selected. Form submission is handled by Refine’s useForm hook via @pankod/refine-react-hook-form.
Route: /properties/create | /properties/edit/:id
Agents List
The agents page uses useList against /api/v1/users to fetch all registered users. Each AgentCard displays the agent’s Google avatar, full name, email address, and total number of properties they have listed. Clicking a card navigates to that agent’s full profile.
Route: /agents
Agent Profile / My Profile
Both the agent profile and my profile use the same shared Profile component. It fetches the user by ID from /api/v1/users/:id with allProperties populated, and renders the agent’s full detail view, including their complete property portfolio. My Profile loads the currently authenticated user’s own data using their userId from localStorage.
Route: /agents/show/:id | /my-profile
Technology stack
The complete dependency surface for both client and server packages:
Layer
Package / Version
Role
UI Framework
@pankod/refine-core ^3.103
Data fetching (useList, useShow, useDelete, useGetIdentity), auth provider, routing
Component Library
@pankod/refine-mui ^3.63
Material UI integration for Refine: layout, notifications, theme provider
Forms
@pankod/refine-react-hook-form
react-hook-form integration for create/edit property forms with Refine’s onFinish lifecycle
Routing
@pankod/refine-react-router-v6
React Router v6 adapter; all navigation uses useNavigate, useParams
Data Provider
@pankod/refine-simple-rest
Maps Refine resource operations to REST calls against the Express API
Charts
apexcharts + react-apexcharts ^1.4
TotalRevenue (area chart) and PropertyReferrals (horizontal bar chart)
Charts (KPI)
Custom PieChart component
4 donut-style KPI tiles on the dashboard (Properties for Sale/Rent, Customers, Agents)
HTTP
axios ^0.28
All API calls; Axios interceptor attaches Bearer JWT token to every outbound request
Loading State
react-loader-spinner ^5.4
Consistent loading indicator shown across all async pages via the shared Loader component
REST API, CORS, JSON body parsing (50 MB limit for base64 photo uploads)
ODM
mongoose ^6.9
Schemas, validation, populate, and session-based transactions
Media
cloudinary ^1.33
Receives base64-encoded image from client, stores and returns CDN URL
Dev Server
nodemon ^2.0
Auto-restarts Express server on file changes during development
Project evolution & key engineering decisions
Refine the data and the auth layer
Refine’s authProvider interface cleanly separates login, logout, checkAuth, checkError, getPermissions, and getUserIdentity into distinct lifecycle methods. This meant the Google OAuth integration did not need custom redirect handling or session management, Refine’s router integration handles all protected route redirects
The dataProvider from @pankod/refine-simple-rest maps Refine’s resource operations (list, show, create, edit, delete) directly to REST endpoints. Adding a new resource requires only one entry in the resources array in App.tsx, no custom data fetching code needed
useTable was chosen over a manual useState + useEffect approach because it automatically manages pagination state (current page, page size), sorter, and filters as a unified state object, and syncs them to API query params on every change
Mongoose session transactions for data integrity
Create property uses startSession + startTransaction to ensure the Property document and the User.allProperties push happens atomically. If either operation fails, the transaction is rolled back, and neither change persists
Delete property follows the same pattern: the property is removed, and the creator’s allProperties array is updated in the same transaction, preventing orphaned references in either direction
Cloudinary for photo management
The client reads the selected file using the FileReader API into a base64 data URL and includes it directly in the POST/PATCH request body. This avoids multipart form handling on the server
The Express controller calls cloudinary.uploader.upload(photo) with the base64 string. Cloudinary returns a response object; the photo URL is extracted as photoUrl.url and stored in the Property document
On edit, if the user does not change the photo, the existing URL from the database is kept as-is. The upload only runs if a new base64 string is submitted
Idempotent user creation
The POST /api/v1/users endpoint first checks whether a user with the submitted email already exists. If found, it returns the existing document; if not, it creates a new one. This allows the same login flow to be used on both first login and repeat visits without duplicating user records
The userId returned from this endpoint is stored in localStorage alongside the Google profile and is used throughout the app to fetch the agent’s own data
Shared Form and Profile components
The Form component is shared between CreateProperty and EditProperty. A type prop (‘Create’ | ‘Edit’) controls the form title and submit button label. This eliminates duplication while keeping both pages independently routable
The Profile component is shared between AgentProfile and MyProfile. It receives a userId and fetches the user’s data internally. This reduces the surface area for bugs and ensures both profile views render identically
Property type standardisation
Eight property types are hardcoded in the all-properties filter dropdown: Apartment, Villa, Farmhouse, Condos, Townhouse, Chalet, Studio, and Duplex. The same values appear in the create/edit form. This ensures that filter queries always match a valid propertyType in the database, avoiding empty results from typos or free-text input
Project structure
Image: The project folder structure of the client and the server.
Setup & environment
Required environment variables to run Estato locally:
Variable
Location
Description
REACT_APP_GOOGLE_CLIENT_ID
client/.env
Google Cloud Console OAuth 2.0 Client ID
REACT_APP_GOOGLE_CLIENT_SECRET
client/.env
Google Cloud Console OAuth 2.0 Client Secret
REACT_APP_SERVER_URL
client/.env
Backend base URL e.g. http://localhost:8080/api/v1
MONGODB_URL
server/.env
MongoDB Atlas connection string
CLOUDINARY_CLOUD_NAME
server/.env
Cloudinary account cloud name
CLOUDINARY_API_KEY
server/.env
Cloudinary API key
CLOUDINARY_API_SECRET
server/.env
Cloudinary API secret
Image: The Agents page, with agent cards showing listings, revenue and experience.