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).
Interest Your judgement of a prospect: interested, maybe, not_interested (or none)
Stage How far a prospect has come, derived from events: not_contacted, teaser, im, data_room, bid
Classifications Legacy single-value view of interest + stage: 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. Judge and track prospects — Record your interest judgement (interested / maybe / not_interested) and follow the process stage (not_contactedteaserimdata_roombid), which Propstreet derives from what has happened
  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 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 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 submitted to Propstreet for review (omitted if not yet)
verifiedUtc datetime When Propstreet approved the teaser (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 Submitted to Propstreet for review
verified Reviewed and approved by Propstreet; ready to communicate
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 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",
      "interest": "interested",
      "stage": "teaser",
      "nextTask": {
        "id": "9012",
        "type": "call",
        "dueDateUtc": "2026-02-01T00:00:00Z",
        "assignee": { "id": "u-77", "name": "Anna Berg" },
        "overdue": false
      },
      "openTaskCount": 1,
      "classification": "interested",
      "capabilities": ["chat"],
      "tags": ["tier-1", "repeat-buyer"],
      "projectTags": ["Single tenant", "Follow up Q4"],
      "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)
interest enum Your judgement: interested, maybe, not_interested; null when none recorded (see Interest and Stage below)
stage enum Process stage derived from events: not_contacted, teaser, im, data_room, bid
stageOverride object Latest manual stage correction { stage, setBy, setAtUtc, reason }, where setBy is { id, name } (omitted when never overridden)
nextTask object Nearest-due open follow-up task { id, type, dueDateUtc, assignee, overdue } (omitted when there is none; see Follow-up Tasks below)
openTaskCount integer Number of open follow-up tasks on the prospect
classification string The original single-value classification you last wrote (see Interest and Stage below)
capabilities array Capability flags (see Capabilities below)
tags array Tags from the associated contact or company — network-wide, they follow the contact into every project
projectTags array Your labels on this prospect within this project only (see Two tag scopes below); replace via PATCH projectTags
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 Narrow to a single capability: chat. Omit for listing or ranking — see Capabilities.
interest string Filter by judgement: interested, maybe, not_interested, or none for prospects without one
stage string Filter by process stage: not_contacted, teaser, im, data_room, bid
classification string Filter by the classification you last wrote: not_contacted, contacted, interested, not_interested, bidder (see Interest and Stage below)
tags array Filter by the contact's or company's network-wide tags with OR semantics. Repeat the query parameter, e.g. ?tags=vip&tags=nordic
project_tags array Filter by your project-level projectTags with OR semantics, case-insensitive. Repeat the query parameter, e.g. ?project_tags=Single%20tenant&project_tags=Hot
task_due string Filter to prospects with an open follow-up task due in a window: overdue (before today), today, this_week (today through the next six days)

Two tag scopes

A prospect carries two independent tag lists:

Field Lives on Visible where Write
tags the contact or company every project the contact is a prospect on, and the network endpoints PATCH /api/v1/network/contacts/{id} / .../companies/{id} (tags)
projectTags the prospect in this project this project only; never shown to the investor PATCH .../prospects/{id} with projectTags (replaces the whole list)

Use tags for what is true of the investor everywhere ("Nordic", "Family office") and projectTags for what is true of them in this deal only ("Single tenant", "Follow up Q4", "Wants IM first"). Values are trimmed and de-duplicated (case-sensitive); a multi-word value stays one label. Filter the list with tags= and project_tags= respectively.

Capabilities

Each prospect row returns a capabilities array indicating what actions are available for it:

Capability Description
chat Messaging is available — the prospect has an established Case Room on a Professional-plan project

The capability query parameter is a narrow actionability filter, not a way to list a shortlist. Most shortlists have few or zero chat-capable prospects, and a shortlist with no Case Room established yet returns an empty list. To browse or rank the full shortlist, omit capability and read each prospect's capabilities array. Pairing capability=chat with stage=not_contacted (or classification=not_contacted) returns an empty list, since an established Case Room means the prospect is past "not contacted". (Pairing with any later stage is fine — a prospect who has been sent the teaser can have an established room.)

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

Interest and Stage

interest, stage and a task's type are closed vocabularies in the schema, so a generated client carries the allowed words and rejects anything else before the request leaves. A value outside the vocabulary is a 400. classification stays a plain string — it is the original single-value vocabulary, and its words overlap the two axes rather than forming an axis of their own.

A prospect carries two independent axes:

interest — your judgement of the prospect's appetite. Set or cleared via PATCH; null until you record one.

Interest Description
interested Wants to proceed
maybe Undecided, worth a follow-up
not_interested Declined or not a fit

stage — how far the process has come. Propstreet derives it from what has actually happened, and it is never lowered by later events. Correct it via PATCH (stage + optional stageReason) when reality differs from the log — the correction is returned as stageOverride with who set it, when, and why, and later events can still raise the stage. To take a correction back, withdraw it (see Withdraw a Stage Correction) and the stage is derived again.

Stage Reached when
not_contacted Nothing has happened yet (default)
teaser The teaser was sent, or outreach was logged
im The NDA was signed (IM available)
data_room Case Room access was granted
bid A bid was registered

classification is the original single-value vocabulary and is unchanged: it reads back exactly what you last wrote (not_contacted until you write one), and classification= filters on that value. The two axes are additive beside it. Writing classification (via PATCH or the batch classify endpoint) records the word and touches the axes only where the word implies one: interested / not_interested count as that interest, contacted and bidder count as reaching teaser / bid, and bidder also records interested (a bid is interest). A classification write never lowers a derived stage and never clears an interest signal — write stage / interest for that. When a PATCH sends classification together with interest / stage, the two axes win. That mapping runs one way only: writing interest or stage never updates classification, so a prospect classified interested and later marked not_interested still answers classification=interested. Prefer reading and writing interest and stage; classification keeps its historical meaning and is not kept in sync with them.

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 interest, stage, owner, room-access expiry, project tags, or external references:

# Record your judgement (send null to clear it)
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 '{"interest": "maybe"}'

# Correct the process stage, with the reason
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 '{"stage": "data_room", "stageReason": "Access granted by mail"}'

# Legacy single-value classification (still accepted; prefer interest / stage)
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"}]}'

# Replace the project-level tags (send [] to clear; the contact's own tags are untouched)
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 '{"projectTags": ["Single tenant", "Follow up Q4"]}'

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

Validation runs before the first write, so a malformed body changes nothing; the fields are then applied one at a time and are not transactional — a concurrent change to the project's team or room state can leave earlier fields applied when a later one is refused.

Follow-up Tasks

A prospect carries any number of open follow-up tasks — a call, a meeting or an email with a due date and an optional assignee. The prospect serves the nearest-due open task as nextTask and the number of open tasks as openTaskCount; a task is overdue once its date is before today (UTC, date-level).

# Add a task (type: call | meeting | email; dueDate: "today", "tomorrow", "7d", "1w", or an ISO date — a past date is accepted and the task is already overdue)
curl -X POST "https://app.propstreet.com/api/v1/projects/123/prospects/456/tasks" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"type": "call", "dueDate": "tomorrow", "assignee": "me", "note": "Follow up on the IM"}'

# Complete a task by its id (see "Task ids" below)
curl -X POST "https://app.propstreet.com/api/v1/projects/123/prospects/456/tasks/789/complete" \
  -H "Authorization: Bearer $TOKEN"

# Cancel a task, for example one created by mistake
curl -X POST "https://app.propstreet.com/api/v1/projects/123/prospects/456/tasks/789/cancel" \
  -H "Authorization: Bearer $TOKEN"

# Undo a completion: the task is open again
curl -X POST "https://app.propstreet.com/api/v1/projects/123/prospects/456/tasks/789/reopen" \
  -H "Authorization: Bearer $TOKEN"
Field Type Description
type enum call, meeting, email
dueDate string Relative (7d, 1w), named (today, tomorrow, next_week), ISO date or ISO datetime. A past date records a task that is already overdue
assignee string A team member's user id, or me. Omit to leave the task unassigned (it sits with the prospect owner)
note string Optional words kept on the task

Adding returns 201 Created with the task ({ id, type, dueDateUtc, assignee, overdue }). Send an Idempotency-Key header to make a retry safe: the same key returns the task that was created instead of adding a second one.

Task ids. Wherever a {taskId} is expected you can send either the task's id (returned when it was added, and served as nextTask.id) or the id of its prospect.task_added row on the prospect's activity timeline. Either is looked up on the prospect in the URL only, never on another prospect.

  • Complete returns 204 No Content. A task can be completed once; completing it again, or completing a task that was canceled, is refused with 400. A task that is not on this prospect returns 404.
  • Cancel returns 204 No Content; the task no longer counts in nextTask / openTaskCount and can no longer be completed. Canceling it again is harmless (204). A completed task is refused with 400; reopen it first. A task that is not on this prospect returns 404.
  • Reopen undoes the task's completion and returns 204 No Content; the task is open again and can be completed again. A task that is not completed, or one that was canceled, is refused with 400; a task that is not on this prospect returns 404.

These verbs take no body and need neither If-Match nor Idempotency-Key. Canceling and reopening change what the prospect answers, so they advance its updatedUtc and ETag: a delta sync on updated_since returns the prospect, and a stale If-Match on a later PATCH is refused. Adding and completing appear on the prospect's activity timeline as prospect.task_added and prospect.task_completed events (type=tasks lists just those rows). Canceling a task hides its row, and reopening hides the undone completion's row, from the default timeline; include_archived=true still returns them, with details.archivedAt set to when they were archived (active rows carry none). Use task_due= on the list endpoint to find prospects whose open task is overdue, due today, or due this week.

Withdraw a Stage Correction

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

Withdraws the manual stage correction, so stage is derived from what has happened again and stageOverride is omitted. Every correction on the prospect is withdrawn, not only the latest, so the stage never falls back to an earlier correction. Returns 204 No Content, also when there is nothing to withdraw. A withdrawal changes what the prospect answers, so it advances the prospect's updatedUtc and ETag. The withdrawn prospect.stage_overridden rows leave the default timeline; include_archived=true still returns them, each with details.archivedAt set to when it was withdrawn. To correct the stage to another value instead, send a new stage via PATCH.

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 The legacy single-value classification recorded on this project; read the prospect itself for interest and stage
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 deleted

Prospect Events

Event Triggered When
prospect.created Prospect added to project
prospect.updated Interest, stage, classification, project tags or tags change (changed_fields names project_tags for the project-level list)
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 the prospects you judged interested
curl "https://app.propstreet.com/api/v1/projects/123/prospects?interest=interested" \
  -H "Authorization: Bearer $TOKEN"

# Get the prospects in the Case Room you have not judged yet
curl "https://app.propstreet.com/api/v1/projects/123/prospects?stage=data_room&interest=none" \
  -H "Authorization: Bearer $TOKEN"

# Get prospects whose contact or company carries a network tag
curl "https://app.propstreet.com/api/v1/projects/123/prospects?tags=tier-1" \
  -H "Authorization: Bearer $TOKEN"

# Get prospects you labelled in this project (case-insensitive, any of the listed tags)
curl "https://app.propstreet.com/api/v1/projects/123/prospects?project_tags=Single%20tenant" \
  -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