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 Deprecated single-value view of interest + stage: not_contacted, contacted, interested, not_interested, bidder; prefer interest and stage
External Refs Link to external CRM systems (HubSpot, Salesforce, etc.) with lookup by external ID
Batch Operations Add, update, remove or task up to 100 prospects in a single request
Follow-up Tasks Add, complete, cancel, reopen, move and replace tasks; GET /api/v1/tasks lists them across contacts, companies and prospects
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)  │   │
│  │  • interest you set        • teaser.acceptedUtc (if accepted)  │   │
│  │  • tags from your network  • chat capability (when established)│   │
│  │                                                                │   │
│  │  All prospects share: interest, stage, 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_contacted → teaser → im → data_room → bid), 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 (an unclassified project counts as active once its teaser is published, else draft)
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 (unclassified: active once the teaser is published, else draft)
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)
coverImageUrl string Download URL of the cover picture, or null when the project has none
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 — every new project starts here
inactive Paused or on hold

A new project is created with an explicit active classification, and an explicit classification always wins. A project old enough to carry none reads active once its teaser is published, and draft before that.

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)
coverImageId string No Cover picture: an image id from POST /api/v1/images or images:import with purpose=project (see Pictures)

[!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).

Send coverImageId to set a new cover picture, or null to remove it; the project answers with its coverImageUrl. A closed project's cover cannot be changed.

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.

Close a Project

curl -X POST "https://app.propstreet.com/api/v1/projects/123/close" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "price": { "value": 182, "currency": "SEK", "scale": "millions" },
    "yield": { "value": 4.9 },
    "buyerType": "external",
    "buyerCompanyId": "4711",
    "sellerCompanyId": "815",
    "signedOn": "2026-02-01",
    "completedOn": "2026-03-01"
  }'

Closing records the outcome and makes the project read-only. buyerType is prospect (with buyerProspectId), external (the default — a sale to a buyer outside the prospect list or not named; buyerContactId or buyerCompanyId are optional) or none for a closure without a sale. The closing also becomes a deal on /api/v1/comps (see Comps); the fields below are recorded on the deal only and are optional — a close without them creates the deal with missing: ["buyer"] and the like:

Field Description
buyerCompanyId The buyer as a company in your network
sellerContactId, sellerCompanyId The seller as a contact or a company in your network; both name the company with the contact as its contact person
sellerAdvisorContactId, sellerAdvisorCompanyId The seller's advisor as a contact or a company; both name the company with its contact person; your own firm when both are omitted
buyerAdvisorContactId, buyerAdvisorCompanyId The buyer's advisor as a contact or a company; both name the company with its contact person
signedOn, completedOn The signing and completion dates; the close date stands in when both are omitted. A completion before the signing answers 422; a date in the future is recorded as sent
completedIsPlanned true when completedOn is a planned completion that has not happened yet
priceUnknown true when the deal was made but its price is not known; not with price. The deal reads priceUnknown: true and is not counted as missing a price
priceCertainty, yieldCertainty confirmed, reported or estimated on the deal; omitted, a range is reported and a single value confirmed
priceApproximate, yieldApproximate true when the value is approximate ("ca"); omitted, a range is approximate and a single value is not
dealName The deal's name (at most 256 characters), visible to your whole organisation; omitted, the deal is named after the project unless it is still untitled. Given only when the deal is first recorded; rename it later with PATCH /api/v1/comps/{id}

A contact who works at a company (their primary company in your network) is recorded on the deal as that company with the contact as its contact person; buyerContactId with buyerCompanyId names the buyer company's contact person.

With buyerType: "none", a price, yield, buyerContactId or buyerProspectId is refused with 400, and a buyer company, seller, advisor, date or priceUnknown with 422; the certainty, "ca" and dealName fields are ignored, since no deal is recorded. priceUnknown stands without a price, and completedIsPlanned needs completedOn (each 422). A side named both as a contact and as a company is the company with the contact as its contact person. A close that loses the race for its deal to another change answers 409 (/help/concurrency-conflict): read the project again and retry. A *ContactId field takes a contact and a *CompanyId field a company: a record of the other kind, or one that is not in your network, answers 422. The answer is the project with transactions[0] carrying compId (left out while your active organisation is not on the Professional plan, or the project belongs to another of your organisations), the dates, completedIsPlanned, seller, sellerAdvisor and buyerAdvisor; buyer and the other parties carry contactId or companyId when they are records in your network, and a party that is your own organisation (the seller's advisor by default) has type: "own_firm". The transaction reports what the close recorded: a party added to the deal later is on the deal, not on the project.

Transaction Object

Field Type Description
type string sell (the only type today)
closedUtc datetime When the project was closed
price, yield object The winning price and yield, each a value or a range (see Price Object above)
buyer, seller, sellerAdvisor, buyerAdvisor Party The deal's parties as the close recorded them; absent when not named
signedOn, completedOn date The signing and completion dates
completedIsPlanned boolean completedOn is a planned completion
compId string The deal on /api/v1/comps; left out of the JSON while your active organisation is not on the Professional plan, or the project belongs to another of your organisations

A Party carries type (internal — a buyer on Propstreet from your prospect list, external — an outside party, own_firm — your own organisation, unknown), name, and prospectId, contactId and companyId when they apply; an id that does not apply is left out of the JSON.

Correct the Close of a Project

curl -X POST "https://app.propstreet.com/api/v1/projects/123/close/correct" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H 'If-Match: "AAAAAAAAB9E="' \
  -d '{
    "price": { "value": 185, "currency": "SEK", "scale": "millions" },
    "buyerAdvisorContactId": "5150",
    "completedOn": "2026-04-01"
  }'

A closed project is read-only apart from notes, files and activity, but its outcome can be corrected: the price and yield with their priceCertainty / priceApproximate and yieldCertainty / yieldApproximate (omitted, the deal follows the corrected value), priceUnknown, buyerType, the buyer, seller and advisors (each a contact or a company, or both for a company with its contact person: buyerContactId / buyerCompanyId, sellerContactId / sellerCompanyId, sellerAdvisorContactId / sellerAdvisorCompanyId, buyerAdvisorContactId / buyerAdvisorCompanyId), signedOn, completedOn, completedIsPlanned, the project's propertyId (one of your own properties) and its asset. A field you omit keeps its value; a field sent as null is cleared. The project's deal on /api/v1/comps follows the correction; a correction to buyerType: "none" voids the deal (it leaves the comps search, and the comps change feed reports it with removalReason: "voided") and a correction back records it again.

Send the project's etag as If-Match to refuse a correction on a stale version (412). An open project answers 409 (close it instead), and so does a correction that loses the race for the deal to another change — read the project again and retry; a priceUnknown while the project keeps a price or is sent one, a completedIsPlanned without a completion date, and a correction to buyerType: "none" that names a price, yield, party or date answer 422, as do a party that is not a contact or company in your network (or a contact in a company field, and the other way round), a portfolio of fewer than two properties, a property that is not one of your own, and a completion before the signing. A correction that changes nothing answers the project unchanged. The answer is the project with its new ETag. Each correction appears on the project's activity timeline (GET /api/v1/projects/{id}/actions) as an audit row with event: "project.close_corrected". Every correction emits project.updated with transactions in changed_fields (with any project field it also changed, such as is_portfolio); on the Professional plan the deal's own webhook reports it too: comp.updated (or comp.deleted for a correction to no sale; for a correction from no sale to a sale, comp.restored when the deal was voided by an earlier correction, or comp.created when the project was closed without a sale and never had one).

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
status string Where the prospect stands: active, declined, closed, removed or deleted (see Prospect status)
classification string Deprecated. The original single-value classification you last wrote; not updated by interest / stage writes (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 Also list prospects deleted with DELETE (status: "deleted"; default: false). Removed prospects are listed without it.
status string Filter by where the prospect stands: active, declined, closed or removed. Omit to list every status.
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 Deprecated. 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 (due instant has passed), today (UTC day), this_week (today through the next six days)

Prospect status

status says where the prospect stands on your list. Each prospect has exactly one, read in this order:

Status Meaning
deleted Deleted with DELETE .../prospects/{id}; listed only with include_deleted=true
removed Taken off your list with POST .../remove (or :batchRemove, or in Propstreet). History kept; it still takes notes but refuses every other change until restored
closed The prospect's process was closed
declined Your team declined contact with a matched investor
active On your list and open to every change

The list returns removed prospects by default (filter with status=active to leave them out). A removed prospect comes back with Restore a Prospect, or by adding the same contact or company again.

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 deprecated: it is the original single-value vocabulary, kept unchanged for existing integrations: 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.

Adding a contact or company whose prospect you removed from this project puts that prospect back on the list — same id, history kept — instead of creating a second one; prospects:batch marks such an item "restored": true. The restore and the externalRefs you send are written together: if the references cannot be stored — another prospect in the project holds one of them — nothing is restored: the single add answers 409 (duplicate_external_ref) and prospects:batch lists the item under failed with error: "duplicate_external_ref"; any other failure likewise leaves the prospect removed, so a retry does the whole add again. A restored prospect is answered with changeType: "update"; in the rare case it cannot be read back right after the restore, the add answers 422 with the prospect's id in prospectId and a Location header to read it from. The prospect is back on the list, so read it there; a retry with the same Idempotency-Key answers 409 (idempotency-outcome-unknown), telling you to check the prospect rather than adding it again.

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 time 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 due instant (dueDateUtc) has passed.

# Add a task (type: call | meeting | email; dueDate: "today", "tomorrow", "7d", "1w", an ISO date or an ISO datetime.
# A date without a time is due by the end of that day, UTC; a past instant 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"

# Move a task to another contact, company or prospect (answers with the task under a new id)
curl -X POST "https://app.propstreet.com/api/v1/projects/123/prospects/456/tasks/789/move" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{"targetType": "contact", "targetId": "321"}'

# Restate a task's type, due date, assignee or note (answers with the task under a new id)
curl -X POST "https://app.propstreet.com/api/v1/projects/123/prospects/456/tasks/789/replace" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{"dueDate": "next_week", "assignee": null}'
Field Type Description
type enum call, meeting, email
dueDate string Relative (7d, 1w), named (today, tomorrow, next_week), ISO date (2026-07-01) or ISO datetime (2026-07-01T14:30:00Z). A date without a time is due by the end of that day, UTC. Overdue once the instant has passed; a past instant records a task that is already overdue
assignee string A team member's user id, or me. Omit to leave the task unassigned. Must be a member of your organisation
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, for the same prospect and body, returns the task that was created instead of adding a second one; the same key on another prospect, or with a different body, is refused with 422. Contacts and companies carry the same tasks, in the same shape and with the same verbs, under /api/v1/network/contacts/{id}/tasks and /api/v1/network/companies/{id}/tasks.

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.

Complete, cancel and reopen 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; the timeline's type filter also takes notes — news items included — news for news items alone, chat and audit); a completion row names the task it closed in details.taskActivityId, the id of that task's prospect.task_added row. 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.

Move and replace. A task stays on the record it was filed on, so neither verb edits it in place. Each files the task anew and cancels the original, both or neither, and answers 200 OK with the task under its new id; use that id from then on.

  • Move (TaskMoveRequest: targetType = contact, company or prospect, plus targetId) files the same task — type, due date, note and assignee — on the target. A prospect target is a prospect on one of your own projects. The assignee comes along while they belong to the organisation that acts on the target; otherwise the task lands unassigned. Send assignee (a user id or me) to choose who it lands with.
  • Replace (TaskReplaceRequest) restates type, dueDate, assignee or note. A field you omit keeps the task's own value; null clears the assignee or the note. Name at least one field.
  • A completed or canceled task, and a move onto the task's own record, are refused with 400; a task or target you cannot reach returns 404. A replace asked twice finds its original canceled and returns 400.
  • Send If-Match with the etag of the task's row on the activity timeline to change only the version you read (412 when it has changed); without it, a change that lands while the request runs is refused with 409. With an Idempotency-Key, a retry of the same request returns the task the first call filed; the same key with another request is refused with 422.

Contacts and companies take the same move and replace verbs under /api/v1/network/{contacts|companies}/{id}/tasks/{taskId}/.

Your task list

GET /api/v1/tasks lists follow-up tasks across contacts, companies and prospects in one place — your own by default. It also lists the tasks on offers you received, read-only.

# Your overdue tasks and today's, in Stockholm days
curl "https://app.propstreet.com/api/v1/tasks?window=overdue&window=today&tz=Europe/Stockholm" \
  -H "Authorization: Bearer $TOKEN"
Parameter Description
assignee me (default), none for unassigned tasks, a team member's user id, or any for everyone's. Repeatable, except any
window overdue, today, next_7_days, later, or done7 for tasks completed in the last seven days. Repeatable; default overdue, today and next_7_days. Canceled tasks are never listed
type call, meeting, email. Repeatable
contact_id, company_id, project_id Only tasks about these contacts or companies, or on prospects of these projects. Repeatable
q Free text over the note and the contact, company and project names (at most 100 characters)
tz IANA time zone whose midnights cut the day windows (default: your own)
sort due (default), created or type; prefix - for descending
page_size, cursor 1-100 per page (default 50); pass nextCursor to continue

Each repeatable filter takes at most 50 values; values of one filter are alternatives, and the filters combine. Each item carries the task fields plus subject — the record the task hangs off (contact, company, prospect, or offer for an offer you received) — with project, contact, company, note, createdUtc and, on completed tasks, completedUtc. Change a task on its record's task endpoints with subject.id (and, for a prospect, project.id) and the task's id. A task on an offer you received is read-only over the API.

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 POST "https://app.propstreet.com/api/v1/projects/123/prospects/456/remove" \
  -H "Authorization: Bearer $TOKEN"

Takes the prospect off your list and keeps its history, as removing it in Propstreet does. It then reads status: "removed", still takes notes, and refuses every other change until you restore it. Removing a prospect that is already removed changes nothing. Returns 204 No Content.

To delete the prospect record instead (it then reads status: "deleted" and is listed only with include_deleted=true), send DELETE .../prospects/456.

Restore a Prospect

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

Puts a removed prospect back on your list with its history. Only your team's removal is taken back: interest, stage, tasks and notes stay as they were, and so does anything else filed on the prospect — its status is read from what remains (a decline or close filed since still counts), and a prospect that held a Case Room seat gets that access back only if nothing else, such as a removal by the other side, still stands in the way. Restoring a prospect that is not removed changes nothing, so a retry is safe. Returns 204 No Content. A restore is refused with 409 when putting the prospect back would list its investor twice — the same investor was added again meanwhile under another contact, or its organisation became the project's seller; the prospect stays removed (:batchRestore reports it under failed). A restore fires prospect.updated with status in changed_fields. Adding the same contact or company again restores it too, also when you send the external references it already holds.

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 Deprecated. Filter by the prospect's legacy classification: not_contacted, contacted, interested, not_interested, bidder. Not updated by interest / stage writes; read the prospect's interest and stage instead

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 (unclassified: active once the teaser is published, else draft); omitted when you are not on the project's team
prospectClassification string Deprecated. The legacy single-value classification recorded on this project; not updated by interest / stage writes, so read the prospect (prospectId) for its 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)

Batch Update Prospects

Set the same fields on up to 100 prospects of one project:

curl -X POST "https://app.propstreet.com/api/v1/projects/123/prospects:batchUpdate" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 6f1c2a9e-1b7d-4c55-9a0e-3f5b8d2c7e10" \
  -d '{
    "prospectIds": ["456", "457", "458"],
    "set": {
      "interest": "interested",
      "note": "Met at the property show",
      "owner": "me",
      "addProjectTags": ["Core"]
    }
  }'

set takes interest, stage (with stageReason), classification, note, owner, addProjectTags and removeProjectTags, at least one of them. interest, stage, stageReason, classification and owner mean what they mean on PATCH .../prospects/{id}; note, addProjectTags and removeProjectTags are batch-only (PATCH replaces the whole list with projectTags instead), and PATCH's projectTags, accessExpiry and externalRefs are not available in a batch. A field set does not know is refused with 400, as is an unknown top-level field. The fields are applied in the order PATCH uses: classification, interest, stage, owner, project tags. note is logged as a note of its own on each prospect, together with interest or classification (it is refused without one of them). A malformed body is refused with 400 before anything is written. The owner is judged against each prospect's project, also before anything is written: an owner a prospect cannot have fails that prospect (it is listed in failed and nothing is written to it), where PATCH answers 400. Each prospect then succeeds or fails on its own; a prospect refused by one field is left out of the fields after it but keeps the ones before it, since the writes are not one transaction. Send an Idempotency-Key header so that a retry returns the first answer instead of logging the note again. A failed prospect is retried as Retrying a failed prospect describes.

Every requested id comes back in exactly one list:

{
  "updated": ["456", "457"],
  "unchanged": [],
  "failed": [
    {
      "prospectId": "458",
      "error": "validation_error",
      "message": "This prospect is closed, so it can no longer be changed; a note can still be logged on it.",
      "partiallyUpdated": false
    }
  ]
}

unchanged lists prospects that already read what you asked for, so nothing was written. Branch on error and partiallyUpdated, which are stable; message is written for a person and may be reworded.

Batch Remove Prospects

curl -X POST "https://app.propstreet.com/api/v1/projects/123/prospects:batchRemove" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "prospectIds": ["456", "457"] }'

Removes each prospect as POST .../prospects/{id}/remove does, keeping its timeline history. The answer has the same shape: updated lists the prospects this request removed, unchanged the ones that were already removed, so a retry is safe without a key.

Batch Restore Prospects

curl -X POST "https://app.propstreet.com/api/v1/projects/123/prospects:batchRestore" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "prospectIds": ["456", "457"] }'

Restores each prospect as POST .../prospects/{id}/restore does. The answer has the same shape as the batch removal: updated lists the prospects this request put back, unchanged the ones that were not removed, so a retry is safe without a key.

Batch Add Tasks

curl -X POST "https://app.propstreet.com/api/v1/projects/123/prospects/tasks:batch" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 0d9b4c2e-8f3a-4e61-b7d5-2a6c9e1f4b38" \
  -d '{ "prospectIds": ["456", "457"], "type": "call", "dueDate": "next_week", "assignee": "me" }'

Takes the fields of POST .../prospects/{id}/tasks plus prospectIds, and gives each prospect a task of its own:

{
  "added": [{ "prospectId": "456", "taskId": "9001" }],
  "failed": [
    {
      "prospectId": "457",
      "error": "not_found_or_no_access",
      "message": "Prospect does not belong to project 123.",
      "partiallyUpdated": false
    }
  ]
}

Error codes of the update, remove and task batches:

Error Description Retryable
validation_error The id is malformed, or the prospect's state refused the change No
not_found_or_no_access Not a prospect of this project you can reach No
conflict Another request changed the prospect at the same time Yes
internal_error An unexpected server fault; the request was valid Yes

POST .../prospects/classify reports the same codes in a code field on each failed item.

Retrying a failed prospect

A prospect whose error is retryable (see the table) is sent again in a new request — under a new Idempotency-Key if you used one, since the same key returns the stored answer, failures included. A prospect whose error is not retryable needs the request or the prospect to change first.

Every failed item also carries partiallyUpdated, whatever its error. On a batch update a prospect can fail on a later field after earlier ones — a note among them — were saved; it then reads true, and its message says so too. Read that prospect back and resend only the fields it is still missing, leaving out the note if it was already logged, or it is logged twice; the same key with that different body is refused with 422. false means nothing was written to the prospect, so it can be resent as it was. A batch removal or a batch task writes each prospect once, so its failures always read false.

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
project.restored A deleted project restored

Closing a project as a sale also emits comp.created for the deal it becomes (Professional plan; see Comps).

Prospect Events

Event Triggered When
prospect.created Prospect added to project
prospect.updated Interest, stage, classification, project tags or tags change, or the prospect is removed or restored (changed_fields names project_tags for the project-level list and status for a removal or restore)
prospect.deleted Prospect deleted from project

Webhook Payload Example

{
  "event": "project.updated",
  "resource_type": "project",
  "resource_id": 123,
  "timestamp": "2026-01-15T10:30:00.0000000Z",
  "action_id": 987654321,
  "changed_fields": ["communicated_at"],
  "actor_user_id": "abc123",
  "change_initiator": null
}

The payload names what changed, not the project itself: read it with GET /api/v1/projects/{id}. project.deleted also carries external_refs, since the project can no longer be read. See the Webhooks Guide for every payload field.

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("interest") or prospect.get("stage"),
                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