Guide

Real Estate CRM Architecture: From Property Enquiry to Lead Management

How to design a real estate CRM: enquiry capture, attribution, deduplication, lead routing, an event timeline, consent records and integration boundaries.

Cover: an enquiry on a listing followed by a four-step lead timeline.

What has to be true one minute after an enquiry

A buyer fills in “Ask about this property” on a listing page at nine in the evening. A minute later, a well-built system can answer six questions without anyone opening a spreadsheet: who is this person, and have we seen them before; which listing and which search brought them here; what did they agree to be contacted about, and how; which agent owns the enquiry; what has been sent so far; and what happens if nobody responds.

The failures to design against are those six answers going missing between systems: the website knows the listing but the CRM only receives a name and a phone number; the same buyer exists three times under three email addresses; a portal lead lands in a shared inbox; a text goes to someone who opted out by replying to a different agent. This article is about the architecture that keeps those answers attached to the lead from capture to follow-up. It applies whether you build the CRM, or keep a commercial one and build around it (the trade-off is in custom SaaS vs an off-the-shelf CRM).

The flow, on one contact's timeline

A lead timeline from property interactions through enquiry and assignment to follow-upA vertical timeline of eight events for one contact, each labeled with the system that owns the step. The website records a listing view and a save against an anonymous visitor. An enquiry form on the listing reaches the CRM capture API. The CRM resolves the contact and joins the earlier views and saves, then routing creates an assignment. Follow-up continues with a message sent through an email or SMS provider, a call logged by the agent with a stage change, and a listing shared by the agent that the contact views. Property interactions are drawn as highlighted cards.OWNING SYSTEMEVENT ON THE CONTACT'S TIMELINEtimeBEFORE THE ENQUIRYWebsitelisting.viewedListing A, reached from a search · anonymous visitor idWebsitelisting.savedListing A saved · same visitor idENQUIRYWebsite → CRM capture APIenquiry.createdForm on listing A · source, campaign, listing, search and consent recordedCRM: identitycontact.resolvedMatched by email or phone, or created · earlier views and saves joinedASSIGNMENTCRM: routingassignment.createdExisting owner, else listing agent, area, round robin · response timer startsFOLLOW-UPEmail or SMS providermessage.sentSent through the provider · delivery and opt-out return by webhookAgent, in the CRMcall.logged · stage.changedOutcome recorded · pipeline stage New → ContactedCRM and websitelisting.shared · listing.viewedAgent shares listing B · the view joins the same contactproperty interaction
Illustrative timeline for one contact. Event names are examples; the left column is the system that owns each step and writes the event.

The same timeline as a list:

  1. listing.viewed (Website), a property interaction: Listing A, reached from a search; anonymous visitor id.
  2. listing.saved (Website), a property interaction: Listing A saved; same visitor id.
  3. enquiry.created (Website → CRM capture API): Form on listing A; source, campaign, listing, search and consent recorded.
  4. contact.resolved (CRM: identity): Matched by email or phone, or created; earlier views and saves joined.
  5. assignment.created (CRM: routing): Existing owner, else listing agent, area, round robin; response timer starts.
  6. message.sent (Email or SMS provider): Sent through the provider; delivery and opt-out return by webhook.
  7. call.logged · stage.changed (Agent, in the CRM): Outcome recorded; pipeline stage New → Contacted.
  8. listing.shared · listing.viewed (CRM and website), a property interaction: Agent shares listing B; the view joins the same contact.

Two things in that picture drive the rest of the design. The record of what happened is a single ordered log per contact, written by whichever system did the work. And property interactions, the views, saves and shares, are first-class events on that log, because in real estate the listings a person looked at are the main thing an agent needs to know before calling.

Capture: every channel into one front door

Leads arrive in different shapes. The design goal is one capture endpoint with one schema, and an adapter per channel that fills it, so that routing and deduplication never depend on where a lead came from.

ChannelHow it arrivesRecord at capture
Enquiry on a listingWebsite form posted to the capture APIListing key and source MLS, the page URL, the search that led there, message text, consent choices as shown
General forms (contact, valuation, newsletter, showing request)Website form posted to the capture APIForm type, the page, requested time for showings, consent choices
Phone callsCall-tracking or telephony provider webhook, or an agent logging the callCaller number, number dialed (which maps to a source or listing), time, duration, outcome; recording only where disclosed and lawful
Portal leadsThe portal's lead API or webhook where one is offered, otherwise parsed notification emailPortal name, the portal's lead id, listing reference, raw payload kept as received
Chat or assistantConversation service posting a summary to the capture APITranscript reference, answers given (budget, timeline, area), the listing in view
Manual and importAgent entry, open-house sign-in, spreadsheet importWho entered it, the stated origin, and how consent was obtained (often: unknown)

Whatever the channel, we would record the same core at capture, before any processing:

  • The raw submission, unmodified, so a parsing bug can be fixed and the enquiry reprocessed.
  • A capture id generated by the sender (the form, the adapter), so a retried submission is recognized as the same enquiry.
  • Server time, tenant and site. In a multi-tenant product the brokerage and agent site come from the request context, not the form body; see multi-tenant real estate SaaS.
  • Consent as evidence: the exact wording shown, which boxes were ticked, the form version, time and IP address. A boolean is not enough to answer a later complaint.

The capture endpoint does three things and nothing else: validates, stores, and returns. Spam scoring, deduplication, routing and notifications run afterwards from a queue, so a slow email provider can never lose an enquiry. That split is the subject of reliable webhook and event-driven integrations.

Attribution: source, campaign, listing and search

Attribution answers two different questions, and they need different fields.

Where did the visitor come from? Capture the landing URL, the referrer, and campaign parameters on the first page view, and keep them in first-party storage until a form is submitted. The common convention is Google's campaign parameters, such as utm_source for the referrer, utm_medium for the marketing medium and utm_campaign for the product, slogan or promo code. Store first touch and the touch at conversion separately; overwriting one with the other is how “all our leads come from direct” reports are made.

What did they want? This part is specific to real estate. For a listing enquiry, store the listing's key and its source system, not just an address string. The RESO Data Dictionary defines ListingKey as a unique identifier for this record from the immediate source, and warns that the human-facing ListingId in a multiple originating system or a merged system may not be unique. So the enquiry references source plus key. Alongside it, store the search that led to the listing as structured criteria (area, price range, beds, property type), because “enquired about a three-bedroom after searching one zip code under a price” is what lets an agent suggest alternatives when that listing goes under contract. Snapshot the price and status at the time of enquiry too: the listing will change, and the enquiry should still make sense.

Identity resolution and deduplication

An enquiry is an event; a contact is a person. Mixing them is the root of most duplicate problems. Each enquiry is stored as its own row and then resolved to a contact:

  1. Normalize identifiers. Lower-case and trim emails; convert phone numbers to one international format with country code. Keep the original as typed.
  2. Match on exact identifiers first. A contact can hold several emails and phones, so the match is against an identifier table, not columns on the contact.
  3. Treat everything else as a suggestion. Same name with a similar address, or a shared family phone, produces a “possible duplicate” for a person to confirm. Automatic merges on fuzzy evidence join spouses, and parents with adult children, into one record.
  4. Join the anonymous history. When the form is submitted, the visitor id from the browser is attached to the contact, and earlier listing views and saves under that id join the timeline.
  5. Merge by pointer, not by deletion. A merge marks one contact as merged into another and re-points its identifiers; the original rows and events stay, so a wrong merge can be undone.

Uniqueness has to be enforced by the database, since two submissions from the same person can arrive in the same second and a “look up, then insert” in application code will create two contacts. In PostgreSQL, the insert-or-update form is safe under that race: ON CONFLICT DO UPDATE guarantees an atomic INSERT or UPDATE outcome, even under high concurrency. In one transaction, insert a candidate contact and claim each identifier with ON CONFLICT; if a claim finds an existing owner, attach the enquiry to that contact and roll the candidate back. When the email and the phone number resolve to two different contacts, do not merge automatically: attach to one by a fixed rule and raise a possible duplicate.

An illustrative data model

Four tables carry the core: contact, enquiry, activity and assignment. Names and columns are examples to show the relationships, not a client schema.

-- Illustrative. In a multi-tenant product every table carries tenant_id and every
-- key and unique constraint starts with it; it is left out of this sketch for brevity.
CREATE TABLE contact (
  id            uuid PRIMARY KEY,
  display_name  text,
  stage         text NOT NULL DEFAULT 'new',     -- current pipeline stage (a projection)
  owner_agent   uuid,                            -- current assignee (a projection)
  merged_into   uuid REFERENCES contact(id),     -- set when merged; row is kept
  created_at    timestamptz NOT NULL DEFAULT now()
);

CREATE TABLE contact_identifier (
  contact_id    uuid NOT NULL REFERENCES contact(id),
  kind          text NOT NULL,                   -- email | phone | visitor_id | external_crm_id
  value_norm    text NOT NULL,                   -- normalized form used for matching
  value_raw     text,
  UNIQUE (kind, value_norm)                      -- one owner per identifier (per tenant)
);

CREATE TABLE enquiry (
  id              uuid PRIMARY KEY,
  capture_id      text NOT NULL UNIQUE,          -- sender-generated; INSERT ... ON CONFLICT DO NOTHING
  contact_id      uuid REFERENCES contact(id),   -- null until resolved
  channel         text NOT NULL,                 -- listing_form | call | portal | chat | import
  listing_source  text,                          -- originating MLS system
  listing_key     text,
  listing_snapshot jsonb,                        -- price and status at the time
  search_criteria jsonb,                         -- the search that led here
  first_touch     jsonb,                         -- source, medium, campaign, referrer, landing URL
  last_touch      jsonb,
  consent         jsonb,                         -- wording shown, choices, form version, IP
  raw_payload     jsonb NOT NULL,
  received_at     timestamptz NOT NULL DEFAULT now()
);

CREATE TABLE activity (                          -- the append-only timeline
  id            uuid PRIMARY KEY,                -- event id; also the idempotency key
  contact_id    uuid NOT NULL REFERENCES contact(id),
  type          text NOT NULL,                   -- e.g. enquiry.created, listing.viewed, message.sent
  occurred_at   timestamptz NOT NULL,            -- when it happened, per the source
  recorded_at   timestamptz NOT NULL DEFAULT now(),
  actor         text NOT NULL,                   -- system or user that produced it
  payload       jsonb NOT NULL
);

CREATE TABLE assignment (
  id            uuid PRIMARY KEY,
  enquiry_id    uuid NOT NULL REFERENCES enquiry(id),
  agent_id      uuid NOT NULL,
  rule          text NOT NULL,                   -- which routing rule decided
  assigned_at   timestamptz NOT NULL DEFAULT now(),
  respond_by    timestamptz,                     -- from the tenant's response target
  responded_at  timestamptz,
  ended_at      timestamptz,                     -- reassigned or closed
  end_reason    text
);

-- "Exactly one owner" is a constraint, not a convention:
CREATE UNIQUE INDEX one_open_assignment ON assignment (enquiry_id) WHERE ended_at IS NULL;

The timeline as an append-only event log

The activity table is never updated or deleted in normal operation; stage and owner on the contact row are projections computed from it. That costs some storage and buys four things: an audit trail for “who reassigned this lead and when”; the ability to rebuild a projection after a logic change; safe ingestion of late and duplicate events, because the insert is ON CONFLICT DO NOTHING on the event id; and one stream to feed reporting, notifications and outbound integrations.

Illustrative event, as it might be stored and published:

{
  "id": "0192f7c4-5b1e-7a3c-9d42-6f0e8a1b2c3d",
  "type": "enquiry.created",
  "version": 1,
  "occurred_at": "2026-10-12T21:04:11Z",
  "recorded_at": "2026-10-12T21:04:12Z",
  "actor": "website:oak-realty",
  "contact_id": "7d3a9e10-2f4b-4c8e-b1a6-0c5d9e7f8a21",
  "payload": {
    "enquiry_id": "c1b2a3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
    "channel": "listing_form",
    "listing": { "source": "example_mls", "key": "L-1042",
                 "price_at_enquiry": 525000, "status_at_enquiry": "Active" },
    "search": { "area": "78704", "max_price": 550000, "min_beds": 3 },
    "first_touch": { "source": "google", "medium": "cpc", "campaign": "south-austin" },
    "consent": { "email": true, "sms": false, "form_version": "2026-09" }
  }
}

Two timestamps are deliberate. Events arrive out of order (a call recording posted minutes after the call, a portal lead delayed by an email relay), so the timeline is ordered by when things happened and the log is processed in the order it was recorded. Consumers should follow the log by a sequence position or through an outbox, not by recorded_at alone: a transaction that commits late carries an earlier timestamp, and a reader that has already moved past it would skip the row.

Assignment and routing

Routing is a small rules engine with a strict requirement: every enquiry ends with exactly one owner, and the reason is recorded. The usual rules, in the order we would evaluate them:

  1. Existing relationship. If the contact already has an active owner, the enquiry goes to that agent. Skipping this rule is how two agents end up calling the same buyer.
  2. Listing agent. An enquiry on the brokerage's own listing goes to the listing agent or their team, if the brokerage's policy says so.
  3. Area or specialty. Zip code, price band, property type or language, matched against agent profiles.
  4. Round robin within the eligible pool, optionally weighted, skipping agents who are marked away or over a cap.
  5. Fallback. A named queue or person when no rule matches or the pool is empty. A lead with no owner should be an alert, not a quiet state.

Details that decide whether this works in production:

  • The round-robin pointer is shared state. Two enquiries arriving together must not both read “next agent = 4”. Advance the pointer in the same transaction that writes the assignment, or with an atomic counter.
  • Rules are tenant data with versions, and each assignment stores which rule and version decided, so “why did I not get this lead” has an answer.
  • Response targets are a design input, not a promise. If a brokerage wants a first response within some number of minutes, that becomes a respond_by time on the assignment and a delayed job that checks it. What the timer does on expiry (remind, escalate to a manager, reassign) is the brokerage's policy. The platform's job is to make the timer reliable and the outcome visible, not to guarantee that a person answers.
  • Reassignment appends. The old assignment gets an end time and reason and a new row is created, in one transaction, so the index above never sees two open owners.

Timers and escalations are delayed jobs; the mechanics, including what happens to them in a restart, are in reliable background jobs with Redis and BullMQ. Our W3|re case study describes a brokerage whose inbound leads had been routed to a shared inbox, and a lead-qualification chatbot that qualifies by budget, timeline, location and pre-approval status; in this model, such an assistant is one more actor writing events to the timeline before a person takes over.

Communication records, consent and opt-out

Emails, texts and calls are sent through providers, but the record of them belongs on the timeline: direction, channel, the provider's message id, the template version or body, the sender, and each status the provider reports as its own event. The provider's message id is what makes status webhooks idempotent.

Consent is modeled per contact, per channel and per purpose, as its own append-only record with the evidence captured at the form. Before every send, one function checks it; there is no second path that skips the check for “just one quick text”. A few published rules shape that function. They are quoted here as design inputs, not legal advice:

Engineering consequences that follow from those rules:

  • An inbound reply is parsed for opt-out words before anything else, and the opt-out is written as an event that takes effect immediately, well inside any deadline.
  • Opt-out is stored against the identifier (the phone number or email address) at brokerage level, so it holds when the lead is reassigned to another agent or the contact is merged.
  • Suppression survives deletion: a minimal record of “do not contact this address” is kept so an import next month does not re-add it. How that sits with deletion requests is one of the privacy questions below.
  • Unsubscribe links work without a login and keep working for old messages.
  • One-to-one messages typed by an agent and automated campaigns are distinguished in the data, because the rules that apply to them can differ.

Property interactions joined to the contact

Views, saves, shares, saved searches and alert clicks are high-volume and low-value individually, and decisive in aggregate. The architecture question is where the join to a person happens.

  • The website records interactions against a first-party visitor id, with the listing's source and key. Known contacts get the same events with their contact id.
  • Raw interactions go to a high-volume store; the timeline gets a digest. Fifty views in one session become one “viewed 12 listings, mostly three-bedroom, one zip code” (illustrative) activity, with the detail one click away.
  • Listing data is referenced, not copied. The CRM stores source and key, plus a small snapshot for display. Price and status are read from the listing store at view time, so the agent sees that a saved listing has dropped in price or gone pending.
  • Listing changes fan out to people. A price change on a listing is a reason to contact everyone who saved it; that is a query from listing to contacts, which the interaction store must support. The alert side of this is covered in the guide to saved searches and listing alerts.
  • MLS display rules still apply. Whether a listing that leaves the feed may still be shown inside the CRM is set by the MLS license; the interaction record stays either way.

Pipeline stages

Stages are a projection of the event log with a small, explicit state machine. A workable default for buyers is New, Attempted contact, Contacted, Qualified, Showing, Offer, Under contract, Closed, with Lost and Nurture as exits that record a reason. Two rules keep reports honest: every stage change is an event with an actor and a time, and stages are per opportunity, not per person, because one contact can be selling one home and buying another. Let each brokerage rename stages; keep a fixed internal category behind each name so cross-tenant product analytics and integrations do not depend on labels.

Integration boundaries: who owns what

A CRM sits between four or five other systems. Each fact should have one owner; everyone else holds a reference or a copy they know is a copy.

SystemOwnsGives the CRMGets from the CRM
WebsiteSessions, visitor ids, forms, property interactionsEnquiries and interaction events through the capture APIWho is signed in, their saved listings and assigned agent
MLS data pipelineListings, media, status and price history, display rulesListing lookups by source and key; change eventsNothing. Lead data does not flow back into listing data
Email and SMS providersDelivery, bounces, complaints, carrier-level opt-outsStatus and inbound-reply webhooksSend requests that already passed the consent check
CRMContacts, identifiers, consent, enquiries, timeline, assignments, stagesThe system of record for the person and the relationship
A third-party CRM (when one is kept)Whatever the brokerage decides: often the agent's working view and tasksStage and note changes by webhook or pollingNew leads and events as idempotent upserts keyed by an external id

When a third-party CRM stays in place, decide field by field which side wins, and write it down. A sound default is that the system where agents work owns stage and notes, and the platform owns capture, attribution, consent and property interactions. Sync is then two one-way flows, not one two-way merge: outbound events delivered from an outbox as upserts keyed by an external id, and inbound webhooks verified, queued and applied idempotently, with a scheduled reconciliation for what both miss. Those mechanics are in reliable webhook and event-driven integrations. One point is specific to two-way CRM sync: a change you pushed comes back as their webhook. Tag each write with its origin and drop the echo, or the two systems will update each other indefinitely.

Privacy and data-subject requests: questions to plan for

A CRM is a database of personal information, and requests about it will come. California's Attorney General lists, among the rights the CCPA gives residents, the right to know about the personal information a business collects about them, the right to delete personal information collected from them (with some exceptions) and the right to correct inaccurate personal information, and notes that a deletion request extends to telling service providers to do the same. Whether a particular brokerage is covered by that law or by others is for its counsel. The architecture should be able to answer these whatever the answer is:

  • Can we produce everything held about one person, across contact, enquiries, timeline, raw interactions, message bodies and call recordings, from one identifier?
  • Can we delete or anonymize a person in every store, including the search index, analytics, backups after a stated period, and the third-party CRM we synced them to?
  • Which records must be kept despite a deletion request (for example suppression entries, transaction records), and who decided?
  • When a contact is merged, do requests against either original still find the data?
  • In a multi-tenant platform, is the brokerage the party that decides and the platform the processor acting on its instruction? Is that in the contract?
  • Who can export contacts, is every export logged, and what happens to an agent's access and their leads the day they leave?
  • How long are raw payloads, IP addresses and recordings kept, and is that enforced by a job or by intention?

Frequently Asked Questions

How do you stop duplicate contacts in a CRM?

Normalize emails and phone numbers, keep them in an identifier table with a unique constraint, and match on exact identifiers only. Offer fuzzy matches as suggestions for a person to confirm. Claim identifiers with an atomic upsert so two simultaneous submissions cannot create two contacts: in PostgreSQL, ON CONFLICT DO UPDATE guarantees an atomic INSERT or UPDATE outcome. Merge by pointer so a wrong merge can be reversed.

How should lead routing handle an agent who does not respond?

Record a respond-by time on the assignment from the brokerage’s own response target and run a delayed job that checks it. On expiry, apply the brokerage’s policy: remind, escalate or reassign, appending a new assignment and keeping the old one with its end reason. The target is an input to the design; the platform makes the timer reliable but cannot promise a person answers.

How quickly must an SMS or email opt-out take effect?

Build it to take effect immediately. The published outer limits are longer: the FTC says you must honor a recipient’s opt-out request within 10 business days for commercial email, and the FCC rule says consent revocations must be honored within a reasonable time not to exceed ten business days. Which rules apply to your messages is a question for counsel.

Regulatory and documentation statements were read from the linked FTC, FCC, eCFR, Government of Canada, California Attorney General, PostgreSQL, Google and RESO pages on October 12, 2026 (the eCFR page showed Title 47 as up to date as of October 8, 2026). They are quoted as inputs to system design and are not legal advice; rules differ by jurisdiction and change. Schemas, event names, identifiers and figures marked illustrative are examples, not measurements or descriptions of a client system.

Building a CRM, or connecting your website and MLS data to the one you have? See our SaaS development service. Every project starts with a 30-minute technical discovery call with an engineer.

About the examples. Every code sample here is illustrative. The data model was created in PostgreSQL 16 on October 13, 2026 and the event example is valid JSON. Neither was run inside a CRM.

Technical consultation

Designing Lead Capture, Routing or a CRM Integration?

Tell us where your enquiries come from, which CRM your agents use and where leads get lost or duplicated. An engineer will walk through capture, routing and sync for your setup.

  • A 30-minute technical discovery call with an engineer, not a salesperson.
  • Fixed-scope projects, monthly retainers, or a combination, agreed after the call.
  • Small, well-defined tasks get a scoped estimate. We reply within 1 business day.

Prefer to pick a time? Book a call on Calendly

Tell us about your project

We'll review your details and reach out within 1 business day.

Book Strategy CallCase Studies