Projects & Prospects

Manage real estate transaction projects and track investor prospects through your sales pipeline.

Projects represent real estate transactions (single assets or portfolios), and prospects are the investors you're targeting for each project. Use these endpoints to sync deal pipelines between Propstreet and your CRM.

Quick Reference

Feature Details
Projects CRUD for real estate transactions
Prospects Investors associated with a project — added by you or matched by Propstreet
Capabilities Actionable flags: chat (messaging available).
Classifications Pipeline stages: not_contacted, contacted, interested, not_interested, bidder
External Refs Link to external CRM systems (HubSpot, Salesforce, etc.) with lookup by external ID
Batch Operations Add up to 100 prospects in a single request
Webhooks Real-time events: project.created/updated/deleted, prospect.created/updated/deleted
Pagination Cursor-based, up to 500 per page
Delta Sync updated_since + include_deleted for incremental sync
Idempotency Idempotency-Key header on POST endpoints
Concurrency ETag-based optimistic locking on PATCH endpoints
Linked Properties GET /api/v1/projects/{id}/properties returns attached property profiles when present

How Projects & Prospects Work

A project represents a real estate transaction. Prospects are the investors associated with that project. Prospects come from two sources, but the API returns them in a single unified list:

Source How they appear teaser field capabilities
Added by you via API or UI You add contacts/companies from your network Not present []
Matched by Propstreet when the teaser is communicated Platform investors whose investment strategy matches the project Present with deadlineUtc and acceptedUtc May include chat

How to tell them apart: If a prospect has a teaser object, the investor was communicated the teaser. If teaser is absent, the prospect was added directly (via API, import, or the Propstreet UI).

┌──────────────────────────────────────────────────────────────────────┐
│                            PROJECT                                    │
│  (Real estate transaction — single asset or portfolio)                │
│                                                                       │
│  PROSPECTS (unified list via API)                                     │
│  ┌────────────────────────────────────────────────────────────────┐   │
│  │                                                                │   │
│  │  Added by you              Matched by Propstreet               │   │
│  │  ─────────────             ─────────────────────               │   │
│  │  • No teaser object        • teaser.deadlineUtc (response by)  │   │
│  │  • classification          • teaser.acceptedUtc (if accepted)  │   │
│  │  • tags from your network  • chat capability (when established)│   │
│  │                                                                │   │
│  │  All prospects share: classification, externalRefs, etag       │   │
│  └────────────────────────────────────────────────────────────────┘   │
└──────────────────────────────────────────────────────────────────────┘

Typical workflow

  1. Create a project — Set up a new real estate transaction
  2. Add prospects — Add contacts or companies from your network
  3. Classify prospects — Track them through your pipeline stages (not_contactedcontactedinterestedbidder)
  4. Communicate the teaser — When the teaser is sent via Propstreet, matching investors appear as new prospects with a teaser object
  5. Monitor responses — Check teaser.acceptedUtc to see who accepted; use capabilities: ["chat"] to know when messaging is available
  6. Sync with CRM — Use delta sync to keep your systems aligned

Query Parameter Naming Convention

Multi-word query parameters use snake_case (e.g., page_size, updated_since, teaser_stage, include_deleted). Single-word parameters use lowercase (e.g., cursor, status, classification).

Optimistic Concurrency (ETags)

Projects and prospects support optimistic concurrency control using ETags to prevent lost updates when multiple clients modify the same resource.

How It Works

Response: ETag is provided in both the HTTP ETag header and the etag body field (identical values):

HTTP/1.1 200 OK
ETag: W/"abc123"
Content-Type: application/json

{
  "id": "123",
  "name": "Stockholm Office Portfolio",
  "etag": "W/\"abc123\"",
  ...
}

Request: Use the If-Match header with the ETag value when updating:

curl -X PATCH "https://app.propstreet.com/api/v1/projects/123" \
  -H "If-Match: W/\"abc123\"" \
  -d '{"name": "Updated Name"}'

Why Both Header and Body?

  • Header (ETag)—Standard HTTP mechanism; works with caching infrastructure and conditional requests
  • Body (etag)—Convenience for clients that process JSON but don't easily access response headers

Both contain identical values. Use whichever is easier for your integration. For updates, always use the If-Match header.

Conflict Resolution

If another client modified the resource since you fetched it, you'll receive 412 Precondition Failed:

{
  "type": "urn:propstreet:error:precondition-failed",
  "title": "Precondition failed",
  "detail": "ETag mismatch - resource was modified"
}

Resolution: Refetch the resource to get the latest ETag and data, then retry your update.

Linked Properties

Use GET /api/v1/projects/{id}/properties when you need the property profiles attached to a project.

  • Returns the same property schema as /api/v1/properties/{id}
  • Returns 200 OK with an empty data array when the project has no linked properties
  • Useful for CRM/deal integrations that sync project metadata and need the attached asset profile in one additional read

Projects

List Projects

Fetch all projects with cursor-based pagination:

curl "https://app.propstreet.com/api/v1/projects?page_size=100" \
  -H "Authorization: Bearer $TOKEN"

Response:

{
  "data": [
    {
      "id": "123",
      "name": "Stockholm Office Portfolio",
      "asset": { "type": "portfolio", "propertyCount": 5 },
      "status": "open",
      "classification": "active",
      "teaser": {
        "stage": "published",
        "publishedUtc": "2026-01-14T09:00:00Z"
      },
      "mandate": "exclusive",
      "price": { "value": 150, "currency": "SEK", "scale": "millions" },
      "externalRefs": [{ "namespace": "hubspot", "id": "deal-123456" }],
      "createdUtc": "2026-01-15T10:00:00Z",
      "updatedUtc": "2026-01-15T10:00:00Z",
      "etag": "W/\"abc123\""
    }
  ],
  "page": {
    "nextCursor": "eyJ1IjoiMjAyNi0wMS0xNVQxMDowMDowMFoiLCJpIjoxMjN9",
    "pageSize": 100,
    "hasMore": true
  }
}

Note: Null fields are omitted from responses, not sent as null. For example, a project with no teaser will not include a teaser key at all, rather than "teaser": null.

Query Parameters

Parameter Type Description
updated_since datetime Filter by update time (ISO 8601 UTC)
include_deleted boolean Include soft-deleted projects (default: false)
page_size integer Results per page, 1-500 (default: 500)
cursor string Pagination cursor from previous response
status string Filter by lifecycle status: open, closed, deleted
classification string Filter by work stage: draft, active, inactive
teaser_stage string Filter by teaser stage: property_added, drafting, published, verified, communicated

Project Fields

Field Type Description
id string Unique project identifier
name string Project name
asset object Asset info: type and property count (see below)
status string Lifecycle: open, closed, deleted
classification string Work stage: draft, active, inactive
teaser object Teaser info with stage and timestamps (see below)
mandate string Sales mandate: exclusive, non_exclusive, other
price object Asking price (see Price below)
priceRange object Custom price range override (see Price Range below)
transactions array Completed transactions (only when status=closed)
externalRefs array External CRM references (see External Refs)
createdUtc datetime When the project was created
updatedUtc datetime When the project was last modified
deletedUtc datetime When the project was deleted (only present for deleted projects)
etag string ETag for optimistic concurrency (see ETags)

Asset Object

{ "type": "portfolio", "propertyCount": 5 }
Field Type Description
type string single (one property) or portfolio (multiple properties)
propertyCount integer Number of properties (only present when type is portfolio)

For a non-portfolio project: { "type": "single" }.

Status Values

Status Description
open Active project (read-only, set by system)
closed Transaction completed (read-only, set by system)
deleted Soft-deleted

Classification Values

Classification Description
draft Work in progress, not yet active
active Actively being marketed
inactive Paused or on hold

Teaser Object

{
  "stage": "communicated",
  "publishedUtc": "2026-01-10T10:00:00Z",
  "verifiedUtc": "2026-01-12T14:00:00Z",
  "communicatedUtc": "2026-01-15T09:00:00Z"
}
Field Type Description
stage string Current stage (see Stage Values below)
publishedUtc datetime When teaser was published (omitted if not yet)
verifiedUtc datetime When teaser was verified (omitted if not yet)
communicatedUtc datetime When teaser was first sent to prospects (omitted if not yet)

The teaser object is omitted entirely if the teaser workflow has not started.

Teaser Stage Values

Stage Description
property_added Property details added
drafting Teaser being drafted
published Teaser published internally
verified Teaser verified and ready
communicated Teaser sent to at least one prospect

Price Object

{ "value": 150, "currency": "SEK", "scale": "millions" }
Field Type Description
value decimal Price value (multiply by scale)
currency string ISO 4217 currency code (SEK, EUR, USD, etc.)
scale string none (1), thousands, millions, billions

Price Range Object

{ "min": 100, "max": 200, "currency": "SEK", "scale": "millions" }
Field Type Description
min decimal Minimum price (optional)
max decimal Maximum price (optional)
currency string ISO 4217 currency code
scale string none, thousands, millions, billions

Get a Project

curl "https://app.propstreet.com/api/v1/projects/123" \
  -H "Authorization: Bearer $TOKEN"

Response includes both the ETag header and etag body field. See Optimistic Concurrency for details.

Create a Project

curl -X POST "https://app.propstreet.com/api/v1/projects" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "name": "Stockholm Office Portfolio",
    "asset": { "type": "portfolio", "propertyCount": 5 },
    "mandate": "exclusive",
    "price": { "value": 150, "currency": "SEK", "scale": "millions" }
  }'
Field Type Required Description
name string Yes Project name (1-200 characters)
asset object No Asset info: { "type": "single" } or { "type": "portfolio", "propertyCount": 5 }. Defaults to single property if omitted.
mandate string No Sales mandate: exclusive, non_exclusive, other
price Price No Asking price (see Price Object above)
priceRange PriceRange No Custom price range override (see Price Range Object above)
externalRefs ExternalRef[] No Link to external CRM systems (see External Refs)

[!important] Use the Idempotency-Key header to safely retry failed requests. See Idempotency Guide for details.

Update a Project

curl -X PATCH "https://app.propstreet.com/api/v1/projects/123" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "If-Match: W/\"abc123\"" \
  -d '{
    "name": "Updated Project Name",
    "asset": { "type": "portfolio", "propertyCount": 3 }
  }'

When providing asset in a PATCH request, the full asset object is replaced. The type field is always required when asset is provided (e.g., { "type": "single" } to change from portfolio to single).

The If-Match header is recommended for concurrent update safety. See Optimistic Concurrency.

Delete a Project

curl -X DELETE "https://app.propstreet.com/api/v1/projects/123" \
  -H "Authorization: Bearer $TOKEN"

Projects are soft-deleted. Use include_deleted=true to retrieve deleted projects.

Prospects

Prospects are investors (contacts or companies) associated with a project. There are two types:

  • Prospects you add — contacts or companies from your network, added via the API or Propstreet UI. These have no teaser object.
  • Prospects matched by Propstreet — investors whose investment strategy matches the project. These appear automatically when the teaser is communicated and include a teaser object with response timestamps.

The API returns both types in a single list. Use the presence of the teaser field to distinguish them.

List Prospects

curl "https://app.propstreet.com/api/v1/projects/123/prospects?page_size=100" \
  -H "Authorization: Bearer $TOKEN"

Response:

{
  "data": [
    {
      "id": "456",
      "projectId": "123",
      "contactId": "789",
      "companyId": "321",
      "classification": "interested",
      "capabilities": ["chat"],
      "tags": ["tier-1", "repeat-buyer"],
      "externalRefs": [{ "namespace": "salesforce", "id": "006XXXXXXXXXXXX" }],
      "teaser": {
        "acceptedUtc": "2026-01-20T14:30:00Z",
        "deadlineUtc": "2026-02-15T23:59:59Z"
      },
      "createdUtc": "2026-01-15T10:00:00Z",
      "updatedUtc": "2026-01-20T14:30:00Z",
      "etag": "W/\"def456\""
    }
  ],
  "page": {
    "nextCursor": "...",
    "pageSize": 100,
    "hasMore": false
  }
}

Note on contactId/companyId: Create requires exactly one of contactId or companyId (mutually exclusive), but responses may return both when a contact has an associated company.

Best practice: Prefer contactId — contacts have email and phone for direct outreach and support more capabilities (activity logging, chat). Use companyId only as a placeholder when the specific person at the company is not yet known.

Prospect Fields

Field Type Description
id string Unique prospect identifier
projectId string Parent project ID
contactId string Contact ID (if linked to a contact)
companyId string Company ID (if linked to a company)
classification string Pipeline stage (see Classifications below)
capabilities array Capability flags (see Capabilities below)
tags array Tags from the associated contact or company
teaser object Teaser info with acceptedUtc and deadlineUtc (omitted if not communicated)
externalRefs array External CRM references (see External Refs)
createdUtc datetime When the prospect was created
updatedUtc datetime When the prospect was last modified
deletedUtc datetime When the prospect was deleted (only present for deleted prospects)
etag string ETag for optimistic concurrency (see ETags)

Query Parameters

Parameter Type Description
updated_since datetime Filter by update time (ISO 8601 UTC). Includes created, modified, and deleted prospects.
include_deleted boolean Include removed prospects (default: false)
page_size integer Results per page, 1-500 (default: 500)
cursor string Pagination cursor from previous response
capability string Filter by capability: chat
classification string Filter by classification (see Classifications below)
tags array Filter by tags with OR semantics. Repeat the query parameter, e.g. ?tags=vip&tags=nordic

Capabilities

Capabilities indicate what actions are available for a prospect:

Capability Description
chat Chat/messaging is available (only for prospects who have accepted the teaser)

Teaser Object (prospect-level)

Present only for prospects who were communicated the teaser. Omitted entirely for prospects you added directly.

Field Type Description
teaser.acceptedUtc datetime When the investor accepted the teaser (null if not yet)
teaser.deadlineUtc datetime Deadline for the investor to respond

Classifications

Track prospects through your sales pipeline:

Classification Description
not_contacted Not yet reached out (default)
contacted Reached out, awaiting response
interested Expressed interest in the project
not_interested Declined or not interested
bidder Active bidder in the process

Get a Prospect

curl "https://app.propstreet.com/api/v1/projects/123/prospects/456" \
  -H "Authorization: Bearer $TOKEN"

Add a Prospect

Add a contact or company from your network to a project. Prefer contacts — they have email/phone for direct outreach. Use a company only as a placeholder when the specific person is unknown:

# Add a contact
curl -X POST "https://app.propstreet.com/api/v1/projects/123/prospects" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{"contactId": "789"}'

# Add a company
curl -X POST "https://app.propstreet.com/api/v1/projects/123/prospects" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{"companyId": "456"}'
Field Type Description
contactId string Contact ID to add — preferred (mutually exclusive with companyId)
companyId string Company ID to add — use when person unknown (mutually exclusive with contactId)
externalRefs ExternalRef[] Link to external CRM systems (see External Refs)

[!warning] Provide exactly one of contactId or companyId, not both. Responses may include both fields when a contact has an associated company.

Update a Prospect

Update a prospect's classification or external references:

# Update classification
curl -X PATCH "https://app.propstreet.com/api/v1/projects/123/prospects/456" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "If-Match: W/\"def456\"" \
  -d '{"classification": "bidder"}'

# Update external refs
curl -X PATCH "https://app.propstreet.com/api/v1/projects/123/prospects/456" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "If-Match: W/\"def456\"" \
  -d '{"externalRefs": [{"namespace": "salesforce", "id": "006XXXXXXXXXXXX"}]}'

The If-Match header is recommended for concurrent update safety. See Optimistic Concurrency.

Remove a Prospect

curl -X DELETE "https://app.propstreet.com/api/v1/projects/123/prospects/456" \
  -H "Authorization: Bearer $TOKEN"

Associated Projects

Find which projects a contact or company appears in as a prospect. Useful for answering "where is this investor active?" across your deal pipeline.

List Projects for a Contact

curl "https://app.propstreet.com/api/v1/network/contacts/789/projects?page_size=100" \
  -H "Authorization: Bearer $TOKEN"

List Projects for a Company

curl "https://app.propstreet.com/api/v1/network/companies/321/projects?page_size=100" \
  -H "Authorization: Bearer $TOKEN"

Response:

{
  "data": [
    {
      "id": "123",
      "prospectId": "456",
      "name": "Stockholm Office Portfolio",
      "status": "open",
      "classification": "active",
      "prospectClassification": "interested",
      "prospectCount": 12,
      "createdUtc": "2026-01-15T10:00:00Z",
      "updatedUtc": "2026-01-20T14:30:00Z",
      "etag": "W/\"abc123\""
    }
  ],
  "page": {
    "nextCursor": "eyJ1IjoiMjAyNi0wMS0xNVQxMDowMDowMFoiLCJpIjoxMjN9",
    "pageSize": 100,
    "hasMore": true
  }
}

Query Parameters

Parameter Type Description
page_size integer Results per page, 1-500 (default: 100)
cursor string Pagination cursor from previous response
status string Filter by project status: open or closed
classification string Filter by prospect stage: not_contacted, contacted, interested, not_interested, bidder

Associated Project Fields

Field Type Description
id string Project identifier
prospectId string Prospect identifier for this contact/company on the project
name string Project name
status string Project lifecycle: open or closed
classification string Project work stage: draft, active, inactive
prospectClassification string Prospect pipeline stage on this project
prospectCount integer Total number of prospects on this project
createdUtc datetime When the project was created
updatedUtc datetime When the project was last modified
deletedUtc datetime When the project was deleted (only present for deleted)
etag string ETag for the project

External References

External references let you link projects and prospects to records in external systems (CRMs, ERPs, etc.). This enables bidirectional sync by looking up Propstreet resources using your existing IDs.

External Ref Structure

Each external ref has a namespace (system identifier) and an id (the record ID in that system):

{
  "externalRefs": [
    { "namespace": "hubspot", "id": "deal-123456" },
    { "namespace": "salesforce", "id": "006XXXXXXXXXXXX" }
  ]
}

Rules:

  • Namespace is case-insensitive (HubSpot = hubspot = HUBSPOT)
  • Each namespace can only appear once per resource (no duplicate namespaces)
  • Project external refs must be unique within your tenant (same namespace+id can't exist on multiple projects)
  • Prospect external refs must be unique within a project (same namespace+id can't exist on multiple prospects in the same project, but can be reused across different projects)

Create with External Refs

curl -X POST "https://app.propstreet.com/api/v1/projects" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "name": "Stockholm Office Portfolio",
    "externalRefs": [
      { "namespace": "hubspot", "id": "deal-123456" }
    ]
  }'

Update External Refs

External refs are replaced entirely on update (not merged):

curl -X PATCH "https://app.propstreet.com/api/v1/projects/123" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "externalRefs": [
      { "namespace": "salesforce", "id": "006XXXXXXXXXXXX" },
      { "namespace": "pipedrive", "id": "12345" }
    ]
  }'

To remove all external refs, send an empty array:

curl -X PATCH "https://app.propstreet.com/api/v1/projects/123" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"externalRefs": []}'

Lookup by External Ref

Find a project by its external reference instead of Propstreet ID:

# Find project by HubSpot deal ID
curl "https://app.propstreet.com/api/v1/projects/external/hubspot/deal-123456" \
  -H "Authorization: Bearer $TOKEN"

Find a prospect within a project:

# Find prospect by Salesforce opportunity ID
curl "https://app.propstreet.com/api/v1/projects/123/prospects/external/salesforce/006XXXXXXXXXXXX" \
  -H "Authorization: Bearer $TOKEN"

Returns 404 Not Found if no resource matches the external reference.

CRM Sync with External Refs

External refs enable efficient bidirectional sync:

class PropstreetCRMSync:
    def sync_from_crm(self, crm_deal):
        """Sync CRM deal to Propstreet, creating or updating as needed."""
        # Check if project already exists using CRM ID
        response = requests.get(
            f"{self.base_url}/projects/external/hubspot/{crm_deal.id}",
            headers=self.headers
        )

        if response.status_code == 200:
            # Update existing project
            project = response.json()
            requests.patch(
                f"{self.base_url}/projects/{project['id']}",
                headers=self.headers,
                json={"name": crm_deal.name}
            )
        elif response.status_code == 404:
            # Create new project with external ref
            requests.post(
                f"{self.base_url}/projects",
                headers=self.headers,
                json={
                    "name": crm_deal.name,
                    "externalRefs": [
                        {"namespace": "hubspot", "id": crm_deal.id}
                    ]
                }
            )

Batch Operations

Batch Add Prospects

Add multiple prospects to a project in a single request (max 100 per request):

curl -X POST "https://app.propstreet.com/api/v1/projects/123/prospects:batch" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "items": [
      { "contactId": "101" },
      { "contactId": "102", "externalRefs": [{ "namespace": "hubspot", "id": "deal-789" }] },
      { "companyId": "201" },
      { "contactId": "103" }
    ]
  }'

Each item in the batch accepts the same fields as POST /prospects (contactId/companyId + optional externalRefs). External refs are validated per-item and across the batch before any prospects are created.

Response includes both successful and failed items:

{
  "created": [
    { "id": "456", "contactId": "101" },
    {
      "id": "457",
      "contactId": "102",
      "externalRefs": [{ "namespace": "hubspot", "id": "deal-789" }]
    },
    { "id": "458", "companyId": "201" }
  ],
  "failed": [
    {
      "index": 3,
      "contactId": "103",
      "error": "duplicate",
      "message": "Contact already exists as prospect in this project"
    }
  ]
}

Error codes:

Error Description
validation_error Missing or invalid contactId/companyId
invalid_id ID format is invalid
duplicate Duplicate entry in batch request
not_added Could not add (already exists, blocked, not found)

Webhooks

Receive real-time notifications when projects or prospects change. See the Webhooks Guide for setup instructions.

Project Events

Event Triggered When
project.created New project created
project.updated Project fields change (name, classification, teaser, price, etc.)
project.deleted Project soft-deleted

Prospect Events

Event Triggered When
prospect.created Prospect added to project
prospect.updated Classification or tags change
prospect.deleted Prospect deleted from project

Webhook Payload Example

{
  "event": "project.updated",
  "timestamp": "2026-01-15T10:30:00Z",
  "data": {
    "id": "123",
    "name": "Stockholm Office Portfolio",
    "asset": { "type": "portfolio", "propertyCount": 5 },
    "status": "open",
    "classification": "active",
    "teaser": {
      "stage": "communicated",
      "publishedUtc": "2026-01-10T10:00:00Z",
      "verifiedUtc": "2026-01-12T14:00:00Z",
      "communicatedUtc": "2026-01-15T09:00:00Z"
    },
    "changeOrigin": "<opaque>",
    "externalRefs": [{ "namespace": "hubspot", "id": "deal-123456" }],
    "changedFields": ["teaser.stage"]
  }
}

Filter by External Ref

Subscribe to changes for specific CRM records:

# Webhook subscription with external ref filter
curl -X POST "https://app.propstreet.com/api/v1/webhooks" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://your-system.com/webhooks/propstreet",
    "events": ["project.updated", "prospect.updated"],
    "filter": {
      "externalRef.namespace": "hubspot"
    }
  }'

Sync Patterns

Initial Sync

Fetch all projects and their prospects:

async function initialSync(token) {
  // 1. Fetch all projects
  let cursor = null;
  const projects = [];

  do {
    const url = new URL("https://app.propstreet.com/api/v1/projects");
    url.searchParams.set("page_size", "500");
    if (cursor) url.searchParams.set("cursor", cursor);

    const response = await fetch(url, {
      headers: { Authorization: `Bearer ${token}` },
    });
    const { data, page } = await response.json();

    projects.push(...data);
    cursor = page.hasMore ? page.nextCursor : null;
  } while (cursor);

  // 2. Fetch prospects for each project
  for (const project of projects) {
    const prospects = await fetchAllProspects(token, project.id);
    await saveToDatabase(project, prospects);
  }

  return projects.length;
}

Delta Sync

Keep your CRM in sync with incremental updates:

async function deltaSync(token, lastSyncTime) {
  const url = new URL("https://app.propstreet.com/api/v1/projects");
  url.searchParams.set("updated_since", lastSyncTime.toISOString());
  url.searchParams.set("include_deleted", "true");

  const response = await fetch(url, {
    headers: { Authorization: `Bearer ${token}` },
  });
  const { data } = await response.json();

  for (const project of data) {
    if (project.deletedUtc) {
      await markAsDeleted(project.id);
    } else {
      await upsertProject(project);
      // Also sync prospects for updated projects
      const prospects = await fetchAllProspects(
        token,
        project.id,
        lastSyncTime
      );
      await syncProspects(project.id, prospects);
    }
  }
}

Filtering Prospects

Use filters to fetch specific subsets of prospects:

# Get only prospects with chat enabled
curl "https://app.propstreet.com/api/v1/projects/123/prospects?capability=chat" \
  -H "Authorization: Bearer $TOKEN"

# Get only interested prospects
curl "https://app.propstreet.com/api/v1/projects/123/prospects?classification=interested" \
  -H "Authorization: Bearer $TOKEN"

# Get prospects with a specific tag
curl "https://app.propstreet.com/api/v1/projects/123/prospects?tags=tier-1" \
  -H "Authorization: Bearer $TOKEN"

CRM Integration Example

Sync Propstreet projects with your CRM deals/opportunities:

import requests
from datetime import datetime, timezone

class PropstreetSync:
    def __init__(self, token):
        self.base_url = "https://app.propstreet.com/api/v1"
        self.headers = {"Authorization": f"Bearer {token}"}

    def sync_projects_to_crm(self, last_sync: datetime):
        """Sync Propstreet projects to CRM deals."""
        params = {
            "updated_since": last_sync.isoformat(),
            "include_deleted": "true"
        }

        response = requests.get(
            f"{self.base_url}/projects",
            headers=self.headers,
            params=params
        )
        projects = response.json()["data"]

        for project in projects:
            if project.get("deletedUtc"):
                self.crm.archive_deal(project["id"])
            else:
                asset = project["asset"]
                self.crm.upsert_deal({
                    "external_id": project["id"],
                    "name": project["name"],
                    "stage": self.map_to_crm_stage(project),
                    "is_portfolio": asset["type"] == "portfolio",
                    "property_count": asset.get("propertyCount")
                })

                # Sync prospects as deal contacts
                self.sync_prospects(project["id"])

    def sync_prospects(self, project_id: str):
        """Sync prospects to CRM deal contacts."""
        response = requests.get(
            f"{self.base_url}/projects/{project_id}/prospects",
            headers=self.headers
        )
        prospects = response.json()["data"]

        for prospect in prospects:
            contact_id = prospect.get("contactId") or prospect.get("companyId")
            teaser = prospect.get("teaser")
            self.crm.link_contact_to_deal(
                deal_id=project_id,
                contact_id=contact_id,
                role=prospect.get("classification"),
                is_communicated=teaser is not None and teaser.get("acceptedUtc") is not None
            )

    def map_to_crm_stage(self, project: dict) -> str:
        """Map project fields to CRM pipeline stage.

        Uses teaser.stage for progress tracking since status is lifecycle only.
        """
        if project["status"] == "closed":
            return "Closed Won"

        teaser = project.get("teaser")
        teaser_stage = teaser.get("stage") if teaser else None
        return {
            None: "Qualification",
            "property_added": "Qualification",
            "drafting": "Qualification",
            "published": "Proposal",
            "verified": "Proposal",
            "communicated": "Negotiation"
        }.get(teaser_stage, "Qualification")

Error Handling

Status Meaning Action
400 Invalid request (validation failed) Check request body/parameters
401 Unauthorized Check API credentials
404 Project or prospect not found Verify IDs exist
409 Conflict (duplicate prospect) Investor already exists as a prospect in this project
412 Precondition failed (ETag mismatch) Refetch and retry with new ETag
422 Unprocessable entity (data integrity) Contact support