# Propstreet Documentation Follow Propstreet product updates and connect your systems to the professional network where real estate deals happen. Browse the [API Reference](/api-reference) for endpoint details. # Quick Start > Make your first Propstreet API call in under 5 minutes. ## 1. Create a Test Token 1. Log into [app.propstreet.com](https://app.propstreet.com) 2. Go to **Settings** → **Personal Settings** → **Personal Access Tokens** 3. Enter a name (e.g., "API Testing") and click **Generate** 4. Copy the token immediately—it won't be shown again ## 2. Make Your First Request Fetch your first 10 contacts: ```bash curl "https://app.propstreet.com/api/v1/network/contacts?page_size=10" \ -H "Authorization: Bearer YOUR_TOKEN" ``` You'll get a response like: ```json { "data": [ { "id": "con_abc123", "firstName": "Jane", "lastName": "Smith", "email": "jane@example.com" } ], "hasMore": true, "nextCursor": "eyJ..." } ``` ### Node.js Example ```javascript const response = await fetch( "https://app.propstreet.com/api/v1/network/contacts?page_size=10", { headers: { Authorization: "Bearer YOUR_TOKEN", }, } ); const { data, hasMore, nextCursor } = await response.json(); console.log(`Fetched ${data.length} contacts`); ``` ## 3. Next Steps > [!warning] > Personal Access Tokens expire after **30 days** and are tied to your account. For production integrations, use Bot User + Client Credentials. Now that you've made your first call: 1. **Choose your auth method** — Read the [Authentication Guide](./authentication) to set up production credentials 2. **Sync your data** — Learn [Sync Patterns](./sync-patterns) for pagination and delta sync 3. **Handle errors** — See [Errors & Retries](./errors-retries) for robust error handling 4. **Explore the API** — Browse the full [API Reference](/api-reference) for all endpoints # Authentication Guide > Choose the right authentication method for your Propstreet integration. Propstreet helps brokers and investors close more deals. This API lets you sync your professional network with your CRM so you can focus on what matters: dealmaking. ## Choosing an Auth Method > [!tip] > Not sure what to pick? Start with a **PAT** for testing, then move to **Bot User + Client Credentials** for production. | Your goal | Use this auth method | | ---------------------------------------- | ------------------------------------------------- | | Just want to try the API quickly | **Personal Access Token (PAT)** | | Production integration for your own team | **Bot User + Client Credentials** _(recommended)_ | | App for multiple Propstreet customers | **OAuth Authorization Code** | ## Comparison | Method | Best For | Rate Limit | Expiry | | ------------------------ | ---------------------------------- | ----------- | ----------- | | Personal Access Token | Testing, scripts, quick prototypes | 60 req/min | 30 days | | Bot + Client Credentials | Production integrations | 600 req/min | Token-based | | OAuth Authorization Code | Multi-tenant apps | 600 req/min | Token-based | ## Personal Access Token (PAT) **Best for:** Quick testing, single-user scripts, prototyping ### Setup 1. Log into [app.propstreet.com](https://app.propstreet.com) 2. Go to **Settings** → **Personal Settings** → **Personal Access Tokens** 3. Enter a descriptive name and click **Generate** 4. Copy the token immediately—it won't be shown again ### Usage ```bash curl "https://app.propstreet.com/api/v1/network/contacts" \ -H "Authorization: Bearer YOUR_PAT_TOKEN" ``` > [!warning] > PATs expire after **30 days** and are tied to your personal account. If you leave your company, integrations using your PAT will stop working. For production, use Bot User + Client Credentials. ## Bot User + Client Credentials (Recommended) **Best for:** Production integrations that sync your CRM with Propstreet Bot Users are dedicated integration accounts that aren't tied to individual team members. When someone leaves, your integrations keep running. ### Setup 1. Go to **Settings** → **Company Settings** → **Client Registrations** 2. Click **Create Client** 3. Enable **Machine-to-Machine (M2M) Mode** 4. Check **Associate with Bot User** and either: - Select an existing bot user, or - Click **Create New Bot** to create one inline 5. Store the `client_id` and `client_secret` securely ### Get Access Token ```bash curl -X POST "https://app.propstreet.com/openid/token" \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "grant_type=client_credentials" \ -d "client_id=YOUR_CLIENT_ID" \ -d "client_secret=YOUR_CLIENT_SECRET" \ -d "scope=api:public" ``` Response: ```json { "access_token": "eyJhbG...", "token_type": "Bearer", "expires_in": 3600 } ``` ### Usage ```bash curl "https://app.propstreet.com/api/v1/network/contacts" \ -H "Authorization: Bearer ACCESS_TOKEN" ``` > [!tip] > Bot Users give you 600 requests/min—10x the PAT limit. Perfect for keeping your CRM in sync. ## OAuth Authorization Code **Best for:** Apps that connect multiple Propstreet customers If you're building a product that multiple Propstreet customers will use, contact [feedback@propstreet.com](mailto:feedback@propstreet.com) for Authorization Code flow setup. ## Migrating from PAT to Bot User When moving to production: 1. Create a Bot User + OAuth Client (see above) 2. Update your integration to use Client Credentials flow 3. Revoke the old PAT in Settings → Personal Access Tokens Benefits: - 10x higher rate limits (600 vs 60 req/min) - No dependency on individual team members - Proper audit trail for integration actions # Sync Patterns Guide > Keep your CRM in sync with your Propstreet data. This guide shows you how to sync contacts, companies, relationships, projects, prospects, properties, and comps between Propstreet and your CRM so nothing falls through the cracks. The examples use contacts for brevity, but the same cursor, delta-sync, and delete-handling patterns apply to every v1 list endpoint that exposes `updated_since`, `include_deleted`, and `page_size`. Comps follow the same pattern on `GET /api/v1/comps`, with two additions: a deal voided by a correction to no sale comes back under `include_deleted` with `deletedUtc` and `removalReason: "voided"`, and hidden deals stay in the feed with `hidden: true`. See [Comps](./comps). ## Cursor-Based Pagination All list endpoints use cursor-based pagination for efficient handling of large datasets. ### How It Works 1. Request with `page_size` (max 500, default 500) 2. Response includes `nextCursor` if more data exists 3. Pass `cursor` to the next request 4. Continue until `hasMore` is `false` ### Example ```bash # First request curl "https://app.propstreet.com/api/v1/network/contacts?page_size=500" \ -H "Authorization: Bearer $TOKEN" ``` Response: ```json { "data": [...], "hasMore": true, "nextCursor": "eyJ1IjoiMjAyNS0wMS0xNVQxMDowMDowMFoiLCJpIjoxMjM0NX0=" } ``` ```bash # Subsequent requests curl "https://app.propstreet.com/api/v1/network/contacts?cursor=eyJ1Ijo..." \ -H "Authorization: Bearer $TOKEN" ``` ### How Long Will My Sync Take? | Network Size | Pages | Time (at 600 req/min) | | ---------------- | ----- | --------------------- | | 10,000 contacts | 20 | ~2 seconds | | 50,000 contacts | 100 | ~10 seconds | | 100,000 contacts | 200 | ~20 seconds | ## Delta Sync After the initial sync, use delta sync to fetch only changes. This keeps your CRM current without re-downloading everything. ### How It Works 1. Store `updatedUtc` from your last sync 2. Use `updated_since` parameter with that timestamp 3. Set `include_deleted=true` to discover removed records 4. Check `deletedUtc` to detect deletions ### Example ```bash # Get all changes since last sync curl "https://app.propstreet.com/api/v1/network/contacts?\ updated_since=2025-01-15T10:00:00Z&\ include_deleted=true&\ page_size=500" \ -H "Authorization: Bearer $TOKEN" ``` Response includes both updated and deleted records: ```json { "data": [ { "id": 123, "firstName": "Jane", "updatedUtc": "2025-01-16T08:30:00Z", "deletedUtc": null }, { "id": 456, "firstName": "John", "updatedUtc": "2025-01-16T09:00:00Z", "deletedUtc": "2025-01-16T09:00:00Z" } ] } ``` > [!important] > Always use `include_deleted=true` for bidirectional sync. Otherwise you won't know when records are removed from Propstreet. ## Recommended Sync Strategy ### Initial Sync ``` 1. Fetch all pages with page_size=500 2. Store each returned record in your CRM 3. Record the highest updatedUtc as your sync checkpoint ``` ### Incremental Sync (run every 5-15 minutes) ``` 1. Fetch with updated_since={checkpoint}&include_deleted=true 2. For each record: - If `deletedUtc` is set → remove it from your CRM - Otherwise → create or update it in your CRM 3. Update your checkpoint to the highest updatedUtc seen ``` ### Bidirectional Sync When syncing changes from your CRM back to Propstreet: 1. Compare `updatedUtc` timestamps 2. If Propstreet is newer → update your CRM 3. If your CRM is newer → push to Propstreet 4. For simultaneous edits → use business rules to decide ## Webhooks for Real-Time Updates For instant notifications instead of polling, use webhooks: 1. Subscribe to events (`contact.created`, `contact.updated`, etc.) 2. Receive HTTP POST to your endpoint when changes occur 3. Use delta sync as backup to reconcile missed events See the [Webhooks Guide](./webhooks) for setup and configuration. ### When to Use Each Approach | Approach | Best For | | -------- | ------------------------------------------------- | | Polling | Batch processing, guaranteed eventual consistency | | Webhooks | Real-time UI updates, instant reactions | | Both | Production systems needing reliability + speed | **Recommended production strategy:** 1. Handle webhooks for immediate updates 2. Run delta sync periodically (hourly/daily) to catch any missed events 3. Use webhook delivery history to identify and replay failures ## External References Track your CRM's IDs in Propstreet using `externalRefs`: ```bash curl -X POST "https://app.propstreet.com/api/v1/network/contacts" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "firstName": "Jane", "lastName": "Doe", "externalRefs": [ {"namespace": "salesforce", "id": "003xx000004TmiN"} ] }' ``` Benefits: - Find Propstreet resources by your CRM's ID - Avoid duplicates during sync - Track which records came from which system # Errors & Rate Limiting Guide > Handle errors gracefully and keep your integration running smoothly. ## Error Response Format All errors return [RFC 7807 Problem Details](https://tools.ietf.org/html/rfc7807): ```json { "type": "https://tools.ietf.org/html/rfc7807", "title": "Validation Error", "status": 400, "detail": "Email format is invalid", "instance": "/api/v1/network/contacts", "errors": { "email": ["The email field is not a valid e-mail address."] } } ``` ## HTTP Status Codes | Status | Meaning | What To Do | | ------ | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | 200 | Success | Process response | | 201 | Created | Entity created successfully | | 204 | No Content | Delete succeeded | | 400 | Validation Failed | Check `errors` for field details | | 401 | Unauthorized | Token expired—refresh and retry | | 403 | Forbidden | Check permissions, or the `type` below | | 404 | Not Found | Entity doesn't exist | | 409 | Conflict | A problem body: read its `type` — a lost race (`/help/concurrency-conflict`): refetch and retry; a rule of the record (`/help/operation-not-allowed`): a retry answers the same. A likely duplicate on `POST /api/v1/comps` carries `possibleDuplicates[]` instead (see below) | | 412 | Precondition Failed | Stale `If-Match` / ETag—refetch and retry | | 422 | Unprocessable | A business rule refused the request (e.g. a completion before the signing); fix the request, don't retry it as is | | 429 | Rate Limited | Wait for `Retry-After` seconds | | 500 | Server Error | Retry with backoff | | 503 | Service Unavailable | Wait for `Retry-After` seconds | ## Problem Types The `type` member tells you why a request was refused, so you can branch on it instead of reading `detail`. ### `/help/plan-required` **403.** The feature belongs to a plan your organisation is not on. The response names the plan in `requiredPlan`. Transactions (comps) and every plan gate added from now on answer with this type; a few older Professional features still answer a plain `403` without a `type`. ```json { "type": "/help/plan-required", "title": "Plan required", "status": 403, "detail": "This feature is part of the Professional plan, which your organisation is not on.", "requiredPlan": "professional" } ``` - **What Professional includes:** among other things Transactions (market transactions — the deals of your closed projects and the deals your team records). Plus does not include Professional. - **Whose plan counts:** the organisation the request acts for — the caller's active organisation. A member of several organisations reaches only the active organisation's deals, and only while that organisation is on Professional; another of their organisations being on it does not help. - **How to upgrade:** choose **Upgrade** where the app shows the Professional offer (for example on the Transactions page), or contact Propstreet. The same request succeeds once the organisation is on the plan; retrying before that answers the same 403. - It is different from a `403` without this type (your user or token lacks the permission) and from `/help/operation-not-allowed` (the record does not allow the operation in its current state). ### `/help/operation-not-allowed` **403 or 409.** The record does not allow the operation for you or in its current state, and repeating the request answers the same. `title` and `detail` say which rule. On comps, for example: hiding a deal or correcting an own property's facts when you are not the project's or the property's owner or an admin (`403`); changing a field a closed project owns, deleting a deal from a closed project, hiding a recorded deal, or merging two projects' deals (`409`). ### `/help/concurrency-conflict` **409.** Another change won a race for the same record — for example a project close or close correction that lost the race for its deal. Read the record again and retry. ### Likely duplicates **409** on `POST /api/v1/comps`, with a body that is not a problem: `{ "possibleDuplicates": [ { "compId", "name", "dealDate", "reason", "differingFields" } ] }`. Nothing was recorded; send the request again with `allowDuplicate: true` to record it anyway (see [Comps](./comps)). ### `/help/idempotency-conflict` and `/help/idempotency-outcome-unknown` **422** when an `Idempotency-Key` is reused with a different body; **409** when an earlier request with the key may or may not have been saved — check the resource, then retry with a new key. See [Idempotency](./idempotency). ## Rate Limiting ### Limits | Authentication | Limit | Best For | | ----------------------- | ----------- | --------------- | | OAuth Client (Bot User) | 600 req/min | Production sync | | Personal Access Token | 60 req/min | Testing | ### Response Headers Every response includes rate limit info: ``` RateLimit-Limit: 600 RateLimit-Remaining: 542 RateLimit-Reset: 45 ``` | Header | Meaning | | --------------------- | -------------------------------- | | `RateLimit-Limit` | Your limit for this window | | `RateLimit-Remaining` | Requests left | | `RateLimit-Reset` | Seconds until reset | | `Retry-After` | Seconds to wait (on 429 and 503) | ### Handling 429 Responses When you hit the limit: ```json { "type": "https://tools.ietf.org/html/rfc7807", "title": "Too Many Requests", "status": 429, "detail": "Rate limit exceeded. Please retry after 12 seconds." } ``` Just wait for `Retry-After` seconds, then continue. ## Retry Strategy For transient errors (429, 500, 502, 503, 504), use exponential backoff: ### JavaScript ```javascript async function fetchWithRetry(url, options, maxRetries = 3) { for (let attempt = 0; attempt < maxRetries; attempt++) { const response = await fetch(url, options); if (response.ok) return response.json(); if (response.status === 429) { const retryAfter = parseInt(response.headers.get("Retry-After") || "60"); await sleep(retryAfter * 1000); continue; } if (response.status >= 500) { await sleep(Math.pow(2, attempt) * 1000); continue; } throw new Error(`Request failed: ${response.status}`); } throw new Error("Max retries exceeded"); } ``` ### C# ```csharp public async Task FetchWithRetryAsync(string url, int maxRetries = 3) { for (int attempt = 0; attempt < maxRetries; attempt++) { var response = await _httpClient.GetAsync(url); if (response.IsSuccessStatusCode) return await response.Content.ReadFromJsonAsync(); if (response.StatusCode == HttpStatusCode.TooManyRequests) { var retryAfter = response.Headers.RetryAfter?.Delta ?? TimeSpan.FromSeconds(60); await Task.Delay(retryAfter); continue; } if ((int)response.StatusCode >= 500) { await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, attempt))); continue; } response.EnsureSuccessStatusCode(); } throw new Exception("Max retries exceeded"); } ``` ## Best Practices 1. **Check `RateLimit-Remaining`** before making requests 2. **Respect `Retry-After`** when you get a 429 3. **Use exponential backoff** for server errors 4. **Log 4xx errors**—they indicate bugs in your integration 5. **Use Bot Users** for production—10x the rate limit of PATs ## Large Network Sync For networks with 100k+ contacts: 1. Use max `page_size=500` to minimize requests 2. Implement delta sync—don't re-pull everything 3. If you consistently hit rate limits, contact [feedback@propstreet.com](mailto:feedback@propstreet.com) ## Webhook Delivery Retries Webhook deliveries use exponential backoff with jitter (±20%): - 9 total attempts (1 initial + 8 retries) over ~35 hours - Delays: 3s, 10s, 30s, 5m, 30m, 2h, 8h, 24h - Auto-disable after 10 consecutive delivery failures For complete webhook documentation, see the [Webhooks Guide](./webhooks). # Idempotency & Concurrency Guide > Safely retry requests and handle concurrent updates. When syncing your CRM with Propstreet, you need to handle network failures and concurrent edits gracefully. This guide shows you how. ## ETags for Optimistic Concurrency ETags prevent "lost update" problems when multiple systems modify the same contact. ### How It Works 1. GET returns an `ETag` header with a version identifier 2. PUT/PATCH includes `If-Match` header with that ETag 3. If the contact changed since you fetched it and your `If-Match` is stale, you get `412 Precondition Failed` 4. Refetch, merge changes, and retry ### Example ```bash # 1. Fetch the contact curl "https://app.propstreet.com/api/v1/network/contacts/123" \ -H "Authorization: Bearer $TOKEN" ``` Response: ``` ETag: W/"abc123def456" Content-Type: application/json { "id": 123, "firstName": "Jane", "lastName": "Doe" } ``` ```bash # 2. Update with If-Match curl -X PUT "https://app.propstreet.com/api/v1/network/contacts/123" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -H 'If-Match: W/"abc123def456"' \ -d '{"firstName": "Janet", "lastName": "Doe"}' ``` ### Handling 412 Precondition Failed If someone else updated the contact: ```json { "type": "https://tools.ietf.org/html/rfc7807", "title": "Precondition failed", "status": 412, "detail": "ETag mismatch - resource was modified" } ``` Resolution: 1. Refetch the contact to get the latest version 2. Merge your changes with the current state 3. Retry the update with the new ETag ## Idempotency-Key for Creates Send an `Idempotency-Key` header (a UUID v4) on a `POST` that creates something, so a retry after a timeout cannot create it twice: ```bash curl -X POST "https://app.propstreet.com/api/v1/comps" \ -H "Authorization: Bearer $TOKEN" \ -H "Idempotency-Key: 6f1c2b7e-8a4d-4c3e-9f20-1b5d7e9a0c11" \ -H "Content-Type: application/json" \ -d @deal.json ``` - The key is kept for **24 hours**. A retry with the same key and the same body answers the first response again (same status and body) without writing anything. - The same key with a different body — or, on a child route, for another parent — is refused with `422` (`/help/idempotency-conflict`). - When the first request may or may not have been saved, a retry answers `409` (`/help/idempotency-outcome-unknown`): check the resource, then retry with a new key. - The routes that take a key include the single creates (`POST` on contacts, companies, links, mandates, projects, prospects and tasks), the batch write routes documented as keyed in their own guides (for example `prospects:batchUpdate` and `prospects/tasks:batch`), task `move` / `replace`, and on comps: `POST /api/v1/comps`, `POST /api/v1/comps/{id}/parties`, `.../sources` and `.../notes` (the key covers the deal and the body), and `POST /api/v1/comps:merge`. - Not every batch route replays: `POST /api/v1/projects/{id}/prospects:batch` ignores the key, and a retry runs the batch again. - `PATCH` and `DELETE` ignore the header: use `If-Match` for `PATCH`, and treat a second `404` on `DELETE` as done. ## External References for Idempotent Creates Use `externalRefs` to make create operations safe to retry: ```bash curl -X POST "https://app.propstreet.com/api/v1/network/contacts" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "firstName": "Jane", "lastName": "Doe", "externalRefs": [ {"namespace": "my-crm", "id": "contact-12345"} ] }' ``` If you retry and the contact already exists with that external ID, you get the existing contact instead of a duplicate. ## Safe Retry Patterns ### Creating Contacts ```javascript async function createContactSafely(contact) { const payload = { ...contact, externalRefs: [{ namespace: "my-crm", id: contact.crmId }], }; try { return await createContact(payload); } catch (error) { if (error.status === 409) { // Already exists—fetch and return it return await getContactByExternalRef("my-crm", contact.crmId); } throw error; } } ``` ### Updating Contacts ```javascript async function updateContactSafely(id, changes, maxRetries = 3) { for (let attempt = 0; attempt < maxRetries; attempt++) { const { data, etag } = await getContact(id); const updated = { ...data, ...changes }; try { return await updateContact(id, updated, etag); } catch (error) { if (error.status === 412 && attempt < maxRetries - 1) { continue; // Retry with fresh data } throw error; } } throw new Error("Max retries exceeded"); } ``` ## Batch Operations For efficient sync, use batch endpoints: ```bash curl -X POST "https://app.propstreet.com/api/v1/network/contacts/batch" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"ids": [123, 456, 789]}' ``` Single request, multiple contacts. ## Best Practices 1. **Always use ETags for updates**—prevents overwriting changes 2. **Include external refs on create**—makes creates idempotent 3. **Implement retry with backoff**—handles transient failures 4. **Use batch operations**—reduces request count ## Conflict Resolution Strategies | Strategy | When to Use | | --------------- | ------------------------------------------- | | Last Write Wins | Simple integrations | | Merge | Different fields updated | | Timestamp-Based | Bidirectional sync with clear "latest wins" | | Manual Review | Critical data requiring human decision | For bidirectional sync, compare `updatedUtc`: - If Propstreet is newer → apply to your CRM - If your CRM is newer → push to Propstreet (with ETag) # Multi-Tenant Guide > Understand how data is organized and secured in Propstreet. ## Billing Groups Propstreet organizes data by **Billing Group**—typically your company or organization. Each Billing Group has its own: - Contacts, companies, projects, prospects, and properties - Team members and permissions - Bot users and OAuth clients - Subscription and billing ## How Access Tokens Work Every access token is scoped to one or more Billing Groups: ``` ┌─────────────────────────────────────────────────┐ │ Your Integration │ │ │ │ Access Token (scoped to Billing Group A) │ └─────────────────────┬───────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────┐ │ Propstreet API │ ├─────────────────────────────────────────────────┤ │ Billing Group A │ Billing Group B │ │ ✓ Accessible │ ✗ Not accessible │ │ │ │ │ • Your contacts │ • Other company's data │ │ • Your companies │ • Completely isolated │ │ • Your projects │ │ │ • Your properties │ │ │ • Your relationships│ │ └─────────────────────────────────────────────────┘ ``` ## Data Isolation **Your data is always isolated:** - You can only access contacts and companies in your Billing Group(s) - The same tenant boundary applies to projects, prospects, and properties - Other organizations cannot see your data - API responses never include cross-tenant data This is enforced at the API level—there's no way to accidentally access another organization's data. ## Common Scenarios ### Single Company Integration Most integrations connect one CRM to one Propstreet Billing Group: 1. Create a Bot User in your Billing Group 2. Create an OAuth Client linked to that Bot User 3. Use Client Credentials to get tokens scoped to your Billing Group All API calls will only see and modify your company's data. ### Multi-Company Integration If you're building a product that serves multiple Propstreet customers (like a CRM integration app): 1. Each customer authorizes your app via OAuth Authorization Code flow 2. You receive separate access tokens for each customer 3. Each token is scoped to that customer's Billing Group **Important:** Store tokens separately per customer and never mix them up. ### Enterprise with Multiple Billing Groups Some enterprises have multiple Billing Groups (e.g., regional offices). In this case: - A single Bot User might have access to multiple Billing Groups - API responses may include data from all accessible Billing Groups - **Comps are the exception:** `/api/v1/comps` reads and writes only the caller's active organisation, under that organisation's plan; a deal of another of their organisations answers `404`, and a project of another organisation carries no `compId` on its transaction - Use filtering if you need to work with one Billing Group at a time Contact [feedback@propstreet.com](mailto:feedback@propstreet.com) for enterprise multi-tenant configurations. ## Best Practices 1. **One integration per Billing Group** for simple setups 2. **Store tokens securely** and never share between customers 3. **Don't assume Billing Group structure**—let the API tell you what's accessible 4. **Contact support** for complex multi-tenant requirements # API Limits & Constraints > Know the limits before you build. ## Rate Limits | Authentication | Requests | Window | Best For | | ----------------------- | -------- | ---------- | --------------- | | OAuth Client (Bot User) | 600 | per minute | Production sync | | Personal Access Token | 60 | per minute | Testing | See [Errors & Rate Limiting Guide](./errors-retries) for handling 429 responses. ## Pagination | Parameter | Limit | Default | | ----------- | ------- | ------- | | `page_size` | 500 max | 500 | For large networks, you'll make multiple requests. At 500 contacts per page: | Network Size | Pages Required | | ------------ | -------------- | | 10,000 | 20 | | 50,000 | 100 | | 100,000 | 200 | ## Request Limits | Limit | Value | Notes | | -------------------- | ----------- | ---------------------- | | Request body size | 10 MB | For POST/PUT/PATCH | | Batch operation size | 100 IDs | For batch endpoints | | URL length | 8,000 chars | Including query params | ## File Uploads | Limit | Value | | ------------- | --------------------------- | | Max file size | 100 MB | | Allowed types | Common documents and images | ### Pictures A contact's photo, a company's logo and a project's cover are stored first, then set on the record. Upload the picture with `POST /api/v1/images` (multipart: `file` and `purpose` = `contact`, `company` or `project`), or fetch it from a public address with `POST /api/v1/images:import` (`{ "url": "https://...", "purpose": "company" }`). Both answer `201 Created` with `{ id, width, height, contentType, url }`. Pass the `id` as `profilePictureId` when you create or `PATCH` a contact or company, or as `coverImageId` on a project; `null` on `PATCH` removes the picture. ```bash curl -X POST "https://app.propstreet.com/api/v1/images" \ -H "Authorization: Bearer $TOKEN" \ -F "file=@logo.png" \ -F "purpose=company" ``` | Limit | Value | | ------------- | -------------------------------------------------------------------------------------------------------------------------- | | Max size | 10 MB and 50 megapixels; a larger upload is answered `413`, a larger import `400` | | Allowed types | PNG, JPEG, GIF and WebP, checked by content; SVG is not accepted | | Stored form | Resized to the size the Propstreet app uses for the `purpose` and re-encoded; the answer gives the stored size and type | | Import | `https` on the standard port, at most three redirects, `url` at most 2,000 characters; a limited number per minute (`429`) | When too many pictures are being processed at once, the answer is `503` with a `Retry-After` header. ## Field Limits | Field | Limit | | --------------------------------- | ------------------- | | Contact/Company name | 500 characters | | Email addresses | 254 characters each | | Phone numbers | 50 characters each | | Notes content | 50,000 characters | | Deal (comp) note | 4,000 characters | | Comp source note | 10,000 characters | | Comp parties / sources per create | 50 each | | Tags per entity | 100 | | External refs per entity | 50 | ## Response Times Typical response times under normal load: | Operation | Typical | 95th percentile | | -------------------- | ------- | --------------- | | GET single entity | < 100ms | < 300ms | | GET list (500 items) | < 500ms | < 1s | | POST/PUT/PATCH | < 200ms | < 500ms | | DELETE | < 100ms | < 300ms | | Search | < 500ms | < 2s | ## Timeouts | Timeout | Value | | ------------------ | ---------- | | Request timeout | 30 seconds | | Connection timeout | 10 seconds | If you hit timeouts on large operations, try: - Reducing page size - Using delta sync instead of full sync - Contacting support for optimization advice ## Need Higher Limits? For large-scale integrations or initial migrations, contact [feedback@propstreet.com](mailto:feedback@propstreet.com). We can temporarily increase limits for: - Initial data migrations (100k+ contacts) - Bulk import operations - Time-sensitive sync requirements # Testing & Development Tools > Tools and tips for building your integration. ## Interactive API Explorer The docs at [docs.propstreet.com](https://docs.propstreet.com) include an interactive API explorer powered by Scalar. You can: - Try any endpoint directly in the browser - Authenticate with your PAT or OAuth token - See real responses from the API > [!note] > The API explorer connects to production. Use test data or a dedicated test account. ## Thunder Client (VS Code) [Thunder Client](https://www.thunderclient.com/) is a lightweight REST client built into VS Code. It's the recommended tool for developers already using VS Code. ### Setup 1. Install the extension: Search "Thunder Client" in VS Code Extensions 2. Click the Thunder Client icon in the sidebar 3. **Collections** → **Import** → **Import from URL** 4. Enter: `https://app.propstreet.com/api/v1/openapi.json` ### Configure Authentication 1. Go to **Env** tab → Create new environment (e.g., "Production") 2. Add variables: - `baseUrl` = `https://app.propstreet.com` - `token` = your PAT or OAuth access token 3. In your collection: **Auth** → **Bearer** → `{{token}}` ### Features - **GUI-based testing** - no syntax to remember - **Response assertions** - validate status codes and body content - **Request chaining** - use response values in subsequent requests - **Collection runner** - run all tests sequentially - **Git-friendly** - collections stored as JSON files ## Postman / Insomnia Import our OpenAPI spec into your favorite API client: ### Postman 1. Download the [OpenAPI Specification](https://app.propstreet.com/api/v1/openapi.json) 2. In Postman: **File** → **Import** → Select the downloaded file 3. Set up environment variables: - `base_url` = `https://app.propstreet.com` - `token` = your PAT or OAuth access token 4. Add to collection: **Authorization** → **Bearer Token** → `{{token}}` ### Insomnia 1. **Application** → **Preferences** → **Data** → **Import Data** 2. Select "From URL" and paste: `https://app.propstreet.com/api/v1/openapi.json` 3. Configure environment with your token ## Code Generation Generate type-safe API clients from our OpenAPI spec: ### TypeScript ```bash # Using openapi-typescript npx openapi-typescript https://app.propstreet.com/api/v1/openapi.json -o propstreet.d.ts # Using openapi-fetch for runtime client npm install openapi-fetch ``` ```typescript import createClient from "openapi-fetch"; import type { paths } from "./propstreet"; const client = createClient({ baseUrl: "https://app.propstreet.com", headers: { Authorization: `Bearer ${process.env.PROPSTREET_TOKEN}`, }, }); const { data } = await client.GET("/api/v1/network/contacts", { params: { query: { page_size: 100 } }, }); ``` ### C# / .NET ```bash # Using NSwag dotnet tool install -g NSwag.ConsoleCore nswag openapi2csclient /input:https://app.propstreet.com/api/v1/openapi.json /output:PropstreetClient.cs ``` Or add to your `.csproj`: ```xml ``` ### Python ```bash # Using openapi-python-client pip install openapi-python-client openapi-python-client generate --url https://app.propstreet.com/api/v1/openapi.json ``` ```python from propstreet_client import Client from propstreet_client.api.contacts import list_contacts client = Client(base_url="https://app.propstreet.com") client = client.with_headers({"Authorization": f"Bearer {token}"}) contacts = list_contacts.sync(client=client, page_size=100) ``` ## Testing Best Practices ### Use a Dedicated Test Account - Create test contacts/companies with a clear naming convention (e.g., `[TEST] Jane Doe`) - Use test email addresses (e.g., `test+propstreet@yourcompany.com`) - Clean up test data periodically ### Start with Read Operations 1. First, test `GET /contacts` to verify authentication 2. Then test pagination with `page_size` and `cursor` 3. Test delta sync with `updated_since` 4. Only then move to write operations ### Test Error Handling Intentionally trigger errors to verify your handling: - **401**: Use an expired or invalid token - **404**: Request a non-existent ID - **429**: Make rapid requests to hit rate limits - **412**: Update with a stale ETag / `If-Match` ### Monitor Rate Limits Always check response headers during testing: ```bash curl -i "https://app.propstreet.com/api/v1/network/contacts" \ -H "Authorization: Bearer $TOKEN" 2>&1 | grep -i ratelimit ``` ## Debugging Tips ### Check Your Token ```bash # Verify token works curl -s "https://app.propstreet.com/api/v1/network/contacts?page_size=1" \ -H "Authorization: Bearer $TOKEN" | head -c 200 ``` ### Log Request/Response Most HTTP clients support logging. Enable it during development: ```javascript // Node.js with axios axios.interceptors.request.use((req) => { console.log(`${req.method} ${req.url}`); return req; }); ``` ### Use the OpenAPI Spec The spec at `/api/v1/openapi.json` is the source of truth for: - Required vs optional fields - Field types and formats - Valid enum values - Request/response schemas Automated catalog crawlers can discover it via `/.well-known/api-catalog` ([RFC 9727](https://datatracker.ietf.org/doc/html/rfc9727)). # Webhooks > Real-time notifications when contacts, companies, links, projects, prospects and comps (market transactions) change in your Propstreet network. Webhooks push event data to your server immediately when changes occur, eliminating the need to poll the API for updates. Our webhook implementation follows the [Standard Webhooks](https://www.standardwebhooks.com/) specification, an industry standard backed by Svix, Kong, Zapier, and others. This ensures compatibility with standard webhook libraries and consistent behavior across platforms. ## Quick Reference | Feature | Details | | ------------------- | --------------------------------------------------------------------------------------- | | **Events** | 22 types: contact/company/link/project/prospect/comp × created/updated/deleted and more | | **Signature** | HMAC-SHA256 via `webhook-signature` header (Standard Webhooks) | | **Retry Policy** | 9 attempts over ~35 hours with exponential backoff | | **Auto-Disable** | After 10 consecutive failures, or immediate on HTTP 410 response | | **URL Requirement** | HTTPS only | | **API Endpoints** | `/api/v1/webhooks` (CRUD + deliveries + test + replay) | ## How Webhooks Work ``` ┌─────────────────┐ ┌──────────────────┐ │ Propstreet │ │ Your Server │ │ │ │ │ │ Contact Updated├──────────────────────────► Webhook Handler │ │ │ POST + Signature │ │ │ │◄─────────────────────────┤ 200 OK │ └─────────────────┘ └──────────────────┘ ``` 1. **Event occurs** — A contact, company, link, project, prospect or comp is created, updated, or deleted 2. **Propstreet sends** — HTTP POST to your registered URL with signed payload 3. **You verify** — Check the HMAC-SHA256 signature 4. **You respond** — Return 2xx within 10 seconds 5. **Retry if needed** — Failed deliveries retry with exponential backoff ## Event Types Subscribe to specific events when creating a webhook: | Event | Trigger | | ------------------ | ----------------------------------------------------------------------------------------------------------------------- | | `contact.created` | New contact added to network | | `contact.updated` | Contact information changed | | `contact.deleted` | Contact removed from network | | `company.created` | New company added to network | | `company.updated` | Company information changed | | `company.deleted` | Company removed from network | | `link.created` | New contact-company relationship | | `link.updated` | Link details changed (role, etc.) | | `link.deleted` | Contact-company relationship removed | | `project.created` | New project created | | `project.updated` | Project information changed | | `project.deleted` | Project removed | | `project.restored` | Deleted project restored | | `prospect.created` | New prospect added to project | | `prospect.updated` | Prospect information changed | | `prospect.deleted` | Prospect deleted from project | | `comp.created` | A deal was recorded, or a project of yours closed as a sale | | `comp.updated` | A deal's fields, parties, sources, property or notes changed | | `comp.deleted` | A deal was deleted, merged into another deal, or voided because its project was corrected to no sale (`removal_reason`) | | `comp.restored` | A deleted deal was restored, or a voided one un-voided | | `comp.hidden` | A deal was hidden from the list for the whole organisation | | `comp.unhidden` | A hidden deal was shown again | ### Comp Events Comps (market transactions) are part of the Professional plan: `comp.*` events are delivered only while your organisation is on it. A closed project becomes a deal, so closing a project emits `project.updated` **and** `comp.created` — there is no `project.closed` event. Every comp event is one entry of the deal's history (`GET /api/v1/comps/{id}/actions`), so `action_id` is the entry's id. Comp payloads carry `project_id` when the deal came from a project of yours, and `comp.deleted` carries `removal_reason`: `deleted` (restorable — or, for the deal a merge folded into another, gone for good) or `voided` (the project was corrected to no sale; a correction back emits `comp.restored`). A merge emits `comp.updated` for the deal that stays and `comp.deleted` for the other, with `merged_into` naming the deal that stays so you can re-point your copy. `changed_fields` on `comp.updated` names the deal's own fields that changed (`price`, `currency`, `yield`, `signed_on`, …) or the part of the deal that did: `parties`, `sources`, `property`, `notes`. A change to the property itself — its location or facts, edited on the property, in the app or by an agent — emits `comp.updated` with `changed_fields: ["property"]` for every deal on it. Once a deal is deleted or voided, no further `comp.updated` is sent for it. See [Comps](./comps). ### Prospect Lifecycle Events Prospect lifecycle now has explicit, path-stable semantics: | User Action | API Path | Webhook Event | Notes | | ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | ------------------------------------------------------------------------- | | Remove prospect from prospect list | `POST /api/v1/projects/{project_id}/prospects/{prospect_id}/remove` | `prospect.updated` with `action.type: "removed"` and `changed_fields: ["status"]` | Prospect remains in timeline and reads `status: "removed"` | | Restore a removed prospect | `POST /api/v1/projects/{project_id}/prospects/{prospect_id}/restore` (or `:batchRestore`, or adding the same contact/company again) | `prospect.updated` with `changed_fields: ["status"]` (no `action`) | Prospect is back on the list; read its `status` | | Delete prospect | `DELETE /api/v1/projects/{project_id}/prospects/{prospect_id}` | `prospect.deleted` | Prospect is deleted | | Delete contact from network | `DELETE /api/v1/network/contacts/{id}` | `contact.deleted` + one `prospect.updated` per affected prospect list entry | Cascade removes prospects from active prospect lists (timeline preserved) | | Delete company from network | `DELETE /api/v1/network/companies/{id}` | `company.deleted` + one `prospect.updated` per affected prospect list entry | Cascade removes prospects from active prospect lists (timeline preserved) | **Integration requirement:** subscribe to both `prospect.updated` and `prospect.deleted` to capture all prospect lifecycle transitions. ## Payload Structure Every webhook delivery includes: ```json { "event": "contact.updated", "resource_type": "contact", "resource_id": 12345, "timestamp": "2026-01-15T14:30:00.0000000Z", "action_id": 987654321, "changed_fields": ["email", "phone"], "actor_user_id": "abc123", "change_initiator": { "type": "oauth_client", "client_id": "partner-crm-client" } } ``` | Field | Description | | ------------------ | ----------------------------------------------------------------------------------------------------- | | `event` | Event type (e.g., `contact.updated`) | | `resource_type` | Resource type: `contact`, `company`, `link`, `project`, `prospect` or `comp` | | `resource_id` | ID of the affected resource | | `timestamp` | When the event occurred (ISO 8601 UTC) | | `action_id` | Unique identifier for this action (for tracing/debugging) | | `changed_fields` | Fields that changed (update events, and `["hidden"]` on `comp.hidden` / `comp.unhidden`; may be null) | | `actor_user_id` | User who triggered the change (for loop prevention) | | `change_initiator` | OAuth client that triggered the change (API calls only, see below) | | `project_id` | Comp events: the project the deal came from, or null for a recorded deal | | `removal_reason` | `comp.deleted`: `deleted` or `voided` | | `merged_into` | `comp.deleted` after a merge: the id of the deal that stays | ### Link Event Fields Link events (`link.created`, `link.updated`, `link.deleted`) include two additional fields: | Field | Description | | ------------ | -------------------------------------------------- | | `contact_id` | Contact ID of the contact in the link relationship | | `company_id` | Company ID of the company in the link relationship | These identify the contact and company in the relationship, which is especially important for `link.deleted` events where the link resource itself is no longer retrievable via the API. ### Delete Event Fields Delete events (`project.deleted`, `prospect.deleted`) include an additional field: | Field | Description | | --------------- | -------------------------------------------------------------- | | `external_refs` | Array of external references that were active at deletion time | Each entry in `external_refs` contains: | Field | Description | | ----------- | -------------------------------------------- | | `namespace` | External system identifier (e.g., `hubspot`) | | `id` | ID in the external system | This allows CRM integrators to identify and clean up the corresponding record in their system after deletion, since the resource is no longer retrievable via `GET` (returns 404). Only external references that were active (not previously expired) at the time of deletion are included. A deleted project can be restored within 30 days via `POST /api/v1/projects/{id}/restore`. A restore emits `project.restored` and brings back the project together with the prospects and external references that were removed by the same delete — after the event the project is retrievable via `GET` again. ### Changed Fields Reference The `changed_fields` array lists which fields were modified in an update event. **Contact fields:** | Field | Description | | -------------------- | ----------------------------------------------- | | `email` | Contact's email address | | `first_name` | First name | | `last_name` | Last name | | `phone` | Phone number | | `primary_language` | Primary language code | | `primary_company_id` | ID of primary company association | | `tags` | Array of tags | | `strategy` | Investment strategy notes | | `profile_picture` | Profile picture changed (triggers download URL) | **Company fields:** | Field | Description | | ----------------- | -------------------------------------------------------------------- | | `name` | Company name | | `country` | Country code | | `homepage_url` | Company homepage URL | | `domain` | Company domain (e.g., "acme.com") - set directly or derived from URL | | `tags` | Array of tags | | `strategy` | Investment strategy notes | | `profile_picture` | Logo/profile picture changed | **Link fields:** | Field | Description | | ----------- | ------------------------------ | | `job_title` | Contact's job title at company | **Project fields:** | Field | Description | | ----------------- | ---------------------------------------------------------------------------- | | `name` | Project name | | `is_portfolio` | Whether this is a portfolio project | | `property_count` | Number of properties in the project | | `published_at` | When the project was published | | `communicated_at` | When the project was communicated | | `closed_at` | When the project was closed | | `deleted_at` | When the project was deleted | | `transactions` | The close was corrected: what the project's `transactions[]` reports changed | **Comp fields:** | Field | Description | | ---------------------------------------------------------------------------- | --------------------------------------------------------------- | | `name` | The deal's name | | `street_address`, `location_precision` | The deal's address, and whether only its area is shown | | `signed_on`, `completed_on`, `completed_is_planned` | The deal's dates | | `price`, `currency`, `price_certainty`, `price_approximate`, `price_unknown` | The price and how sure you are | | `yield`, `yield_certainty`, `yield_approximate` | The yield and how sure you are | | `is_portfolio`, `property_count` | The portfolio pair | | `property` | The deal was anchored on another property, or its facts changed | | `parties` | A party was added or removed | | `sources` | A source was added or removed | | `notes` | A note was added | | `hidden` | The deal was hidden or shown again | **Prospect fields:** | Field | Description | | --------------- | --------------------------------------- | | `created_at` | When the prospect was created | | `deleted_at` | When the prospect was deleted | | `external_refs` | External CRM references changed | | `project_tags` | Your labels on the prospect changed | | `owner` | The prospect's owner was set or cleared | | `status` | Removed from or restored to your list | For full prospect state context, use the unified prospect resource (`/api/v1/projects/{projectId}/prospects`) with `teaser` and `capabilities`. ### Bidirectional Sync with change_initiator The `change_initiator` field enables reliable loop prevention for bidirectional sync integrations. When a change is made via the API using OAuth credentials, this field identifies the OAuth client that made the change. **When present:** The change was made via the API by an OAuth client. ```json { "change_initiator": { "type": "oauth_client", "client_id": "your-oauth-client-id" } } ``` **When absent (null):** The change was made via the Propstreet UI, an import, or another non-API source. This is more reliable than `actor_user_id` for bidirectional sync because: - It identifies your specific integration, not just the bot user - Multiple integrations using the same bot user can be distinguished - Works even if you rotate bot users ```javascript app.post("/webhooks/propstreet", (req, res) => { const { change_initiator, resource_id, event } = req.body; // Skip events triggered by our integration (prevents infinite loops) if (change_initiator?.client_id === process.env.MY_OAUTH_CLIENT_ID) { return res.status(200).send("Skipped self-triggered event"); } // Process the event - this came from another source syncToExternalSystem(event, resource_id); res.status(200).send("OK"); }); ``` ## HTTP Headers Every webhook request includes these headers per the [Standard Webhooks](https://www.standardwebhooks.com/) specification: | Header | Example | Description | | -------------------- | ------------------ | ------------------------------------ | | `Content-Type` | `application/json` | Always JSON | | `webhook-id` | `msg_2nYj9K4...` | Unique message ID for idempotency | | `webhook-timestamp` | `1704067200` | Unix timestamp (seconds since epoch) | | `webhook-signature` | `v1,K7sBgP...` | HMAC-SHA256 signature (base64) | | `X-Propstreet-Event` | `contact.updated` | Event type (for routing convenience) | ## Signature Verification **Always verify webhook signatures** to ensure requests come from Propstreet. We follow the [Standard Webhooks signature scheme](https://www.standardwebhooks.com/). The signature format is: `v1,{base64_signature}` To verify: 1. Extract headers: `webhook-id`, `webhook-timestamp`, `webhook-signature` 2. Construct the signed payload: `{webhook-id}.{webhook-timestamp}.{raw_body}` 3. Decode your secret (strip `whsec_` prefix, base64-decode the rest) 4. Compute HMAC-SHA256 and base64-encode the result 5. Compare signatures using constant-time comparison > **Note:** You can use standard webhook verification libraries like [svix/webhooks](https://github.com/svix/svix-webhooks) instead of implementing verification manually. ### Python ```python import hmac import hashlib import time import base64 def verify_signature( payload: str, msg_id: str, timestamp: str, signature: str, secret: str, tolerance_seconds: int = 300 ) -> bool: """Verify webhook signature per Standard Webhooks spec.""" try: ts = int(timestamp) except ValueError: return False # Check timestamp to prevent replay attacks if abs(time.time() - ts) > tolerance_seconds: return False # Decode the secret (strip whsec_ prefix if present) if secret.startswith("whsec_"): secret = secret[6:] key = base64.b64decode(secret) # Construct signed payload: {msg_id}.{timestamp}.{payload} signed_payload = f"{msg_id}.{timestamp}.{payload}" # Compute signature computed = base64.b64encode( hmac.new(key, signed_payload.encode(), hashlib.sha256).digest() ).decode() # Parse v1 signature from header (format: "v1,{base64}") expected = signature.split(",")[1] if "," in signature else signature return hmac.compare_digest(computed, expected) # Usage in Flask from flask import Flask, request app = Flask(__name__) WEBHOOK_SECRET = "whsec_your-base64-encoded-secret" @app.route("/webhooks/propstreet", methods=["POST"]) def handle_webhook(): msg_id = request.headers.get("webhook-id", "") timestamp = request.headers.get("webhook-timestamp", "") signature = request.headers.get("webhook-signature", "") payload = request.get_data(as_text=True) if not verify_signature(payload, msg_id, timestamp, signature, WEBHOOK_SECRET): return "Invalid signature", 401 event = request.json # Process the event... return "OK", 200 ``` ### JavaScript (Node.js) ```javascript const crypto = require("crypto"); function verifySignature( payload, msgId, timestamp, signature, secret, toleranceSeconds = 300 ) { const ts = parseInt(timestamp, 10); if (isNaN(ts)) return false; // Check timestamp to prevent replay attacks const now = Math.floor(Date.now() / 1000); if (Math.abs(now - ts) > toleranceSeconds) { return false; } // Decode the secret (strip whsec_ prefix if present) const secretKey = secret.startsWith("whsec_") ? secret.slice(6) : secret; const key = Buffer.from(secretKey, "base64"); // Construct signed payload: {msg_id}.{timestamp}.{payload} const signedPayload = `${msgId}.${timestamp}.${payload}`; // Compute signature const computed = crypto .createHmac("sha256", key) .update(signedPayload) .digest("base64"); // Parse v1 signature from header (format: "v1,{base64}") const expected = signature.includes(",") ? signature.split(",")[1] : signature; return crypto.timingSafeEqual(Buffer.from(computed), Buffer.from(expected)); } // Usage in Express const express = require("express"); const app = express(); app.post( "/webhooks/propstreet", express.raw({ type: "application/json" }), (req, res) => { const msgId = req.headers["webhook-id"]; const timestamp = req.headers["webhook-timestamp"]; const signature = req.headers["webhook-signature"]; const payload = req.body.toString(); if ( !verifySignature( payload, msgId, timestamp, signature, process.env.WEBHOOK_SECRET ) ) { return res.status(401).send("Invalid signature"); } const event = JSON.parse(payload); // Process the event... res.status(200).send("OK"); } ); ``` ### C# ```csharp using System.Security.Cryptography; using System.Text; public class WebhookSignatureValidator { public static bool VerifySignature( string payload, string msgId, string timestampStr, string signature, string secret, int toleranceSeconds = 300) { if (!long.TryParse(timestampStr, out var timestamp)) { return false; } // Check timestamp to prevent replay attacks var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); if (Math.Abs(now - timestamp) > toleranceSeconds) { return false; } // Decode the secret (strip whsec_ prefix if present) var base64Secret = secret.StartsWith("whsec_", StringComparison.Ordinal) ? secret[6..] : secret; var keyBytes = Convert.FromBase64String(base64Secret); // Construct signed payload: {msg_id}.{timestamp}.{payload} var signedPayload = $"{msgId}.{timestamp}.{payload}"; var payloadBytes = Encoding.UTF8.GetBytes(signedPayload); // Compute signature (base64-encoded HMAC-SHA256) var computed = Convert.ToBase64String( HMACSHA256.HashData(keyBytes, payloadBytes) ); // Parse v1 signature from header (format: "v1,{base64}") var expected = signature.Contains(',') ? signature.Split(',')[1] : signature; return CryptographicOperations.FixedTimeEquals( Encoding.UTF8.GetBytes(computed), Encoding.UTF8.GetBytes(expected) ); } } ``` ## Retry Policy Failed deliveries are retried with exponential backoff: | Attempt | Delay After Failure | Cumulative Time | | ------- | ------------------- | --------------- | | 1 | (initial) | 0 | | 2 | 3 seconds | 3s | | 3 | 10 seconds | 13s | | 4 | 30 seconds | 43s | | 5 | 5 minutes | ~6m | | 6 | 30 minutes | ~36m | | 7 | 2 hours | ~2.5h | | 8 | 8 hours | ~10.5h | | 9 | 24 hours | ~34.5h | **Retriable conditions:** - HTTP 5xx errors (server errors) - HTTP 429 (rate limited) - Connection timeouts - Network errors **Non-retriable conditions:** - HTTP 4xx errors (except 429) indicate configuration issues - **HTTP 410 Gone** — Immediately disables the webhook (see Auto-Disable below) After 9 failed attempts, the delivery is marked as `exhausted`. ## Auto-Disable Webhooks are automatically disabled in two scenarios: ### 1. HTTP 410 Gone (Immediate) Per the [Standard Webhooks specification](https://www.standardwebhooks.com/), if your endpoint returns **HTTP 410 Gone**, the webhook is disabled immediately without retries. Use this to programmatically unsubscribe from webhooks when an endpoint is permanently removed. ### 2. Consecutive Failures (Gradual) Webhooks are disabled after **10 consecutive failed deliveries** (i.e., deliveries that exhaust all retry attempts without success). This prevents wasting resources on unreachable endpoints. Note: retries within a single delivery don't count individually toward this limit. ### When Disabled - Status changes to `disabled` - No new deliveries are attempted - Existing pending deliveries remain in queue ### Re-enabling 1. Fix the issue with your endpoint 2. Update the webhook status to `active` 3. Consecutive failure counter resets ```bash curl -X PATCH "https://app.propstreet.com/api/v1/webhooks/123" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"status": "active"}' ``` ## Managing Webhooks ### Create a Webhook ```bash curl -X POST "https://app.propstreet.com/api/v1/webhooks" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "url": "https://your-server.com/webhooks/propstreet", "events": ["contact.created", "contact.updated", "contact.deleted"], "name": "CRM Sync" }' ``` Response includes the secret (shown only once): ```json { "id": "123", "url": "https://your-server.com/webhooks/propstreet", "events": ["contact.created", "contact.deleted", "contact.updated"], "name": "CRM Sync", "status": "active", "secret": "whsec_MIGfMA0GCSqGSIb3DQEBAQUAA4GNAD...", "createdUtc": "2026-01-15T10:00:00Z" } ``` > [!important] > Store the `secret` securely — it's only returned during creation. The secret uses the `whsec_` prefix followed by base64-encoded key material, per the Standard Webhooks specification. ### List Webhooks ```bash curl "https://app.propstreet.com/api/v1/webhooks" \ -H "Authorization: Bearer $TOKEN" ``` ### Update a Webhook ```bash curl -X PATCH "https://app.propstreet.com/api/v1/webhooks/123" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "events": ["contact.created", "contact.updated", "company.created"], "status": "paused" }' ``` ### Delete a Webhook ```bash curl -X DELETE "https://app.propstreet.com/api/v1/webhooks/123" \ -H "Authorization: Bearer $TOKEN" ``` ### Regenerate Secret If your secret is compromised: ```bash curl -X POST "https://app.propstreet.com/api/v1/webhooks/123/secret" \ -H "Authorization: Bearer $TOKEN" ``` > [!warning] > This invalidates the previous secret immediately. Update your handler before the next delivery. ## Viewing Delivery History Check recent deliveries for debugging: ```bash curl "https://app.propstreet.com/api/v1/webhooks/123/deliveries?limit=10" \ -H "Authorization: Bearer $TOKEN" ``` Response: ```json { "data": [ { "id": "456", "eventId": "msg_2nYj9K4xLm...", "eventType": "contact.updated", "status": "delivered", "attemptCount": 1, "lastAttemptUtc": "2026-01-15T14:30:01Z", "lastStatusCode": 200, "lastDurationMs": 150, "createdUtc": "2026-01-15T14:30:00Z", "deliveredUtc": "2026-01-15T14:30:01Z" }, { "id": "457", "eventId": "msg_3oZk0L5yMn...", "eventType": "contact.created", "status": "failed", "attemptCount": 3, "lastAttemptUtc": "2026-01-15T14:35:36Z", "lastStatusCode": 500, "lastError": "HTTP 500", "lastDurationMs": 2500, "createdUtc": "2026-01-15T14:35:00Z", "deliveredUtc": null } ] } ``` The `eventId` (sent as `webhook-id` header) uses the `msg_` prefix followed by a URL-safe base64-encoded hash, ensuring deterministic, idempotent message IDs per the Standard Webhooks specification. ### Delivery Statuses | Status | Meaning | | ----------- | ------------------------------------- | | `pending` | Waiting for first delivery attempt | | `delivered` | Successfully delivered (2xx response) | | `failed` | Last attempt failed, retry scheduled | | `exhausted` | All retry attempts exhausted | ## Security Best Practices 1. **Always verify signatures** — Never process webhooks without signature verification 2. **Use HTTPS** — Webhook URLs must use HTTPS (enforced by API) 3. **Respond quickly** — Return 2xx within 10 seconds; do heavy processing async 4. **Handle duplicates** — Use `webhook-id` header for idempotency 5. **Protect your endpoint** — Consider IP allowlisting or additional authentication 6. **Rotate secrets periodically** — Use the regenerate endpoint to rotate secrets 7. **Use raw request body unchanged** — When verifying signatures, use the raw request body exactly as received. Do not parse and re-serialize JSON, as this may change whitespace, ordering, or Unicode escaping, causing signature verification to fail. 8. **Return 410 to unsubscribe** — If your endpoint is permanently removed, return HTTP 410 Gone to automatically disable the webhook ### Idempotency Webhooks may be delivered more than once (e.g., if your server returns 200 but connection drops before we receive it). Use the `webhook-id` header to deduplicate: ```javascript const processedDeliveries = new Set(); app.post("/webhooks/propstreet", (req, res) => { const messageId = req.headers["webhook-id"]; if (processedDeliveries.has(messageId)) { return res.status(200).send("Already processed"); } // Process the event... processedDeliveries.add(messageId); res.status(200).send("OK"); }); ``` For production, store message IDs in a database with TTL. ## Testing Webhooks ### Test Endpoint Send a test delivery to verify your webhook endpoint is working: ```bash curl -X POST "https://app.propstreet.com/api/v1/webhooks/123/test" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"event_type": "contact.created"}' ``` Response: ```json { "success": true, "status_code": 200, "duration_ms": 145, "error": null } ``` **Key behaviors:** - Synchronous delivery (waits for response) - Not persisted in delivery history - Includes `X-Propstreet-Test: true` header - Payload includes `"_test": true` field - Uses first subscribed event if `event_type` is omitted - Requires webhook to be `active` (not paused/disabled) This is useful for: - Verifying endpoint connectivity before going live - Testing signature verification logic - Debugging delivery issues ### Local Development Use a tunneling service to expose your local server: ```bash # Using ngrok ngrok http 3000 # Register the temporary URL curl -X POST "https://app.propstreet.com/api/v1/webhooks" \ -H "Authorization: Bearer $TOKEN" \ -d '{ "url": "https://abc123.ngrok.io/webhooks/propstreet", "events": ["contact.created"] }' # Test the webhook curl -X POST "https://app.propstreet.com/api/v1/webhooks/123/test" \ -H "Authorization: Bearer $TOKEN" ``` ### Verify Your Setup 1. Create a test webhook pointing to your endpoint 2. Use the test endpoint to verify connectivity 3. Create a contact via the API 4. Check delivery history to confirm delivery 5. Review your server logs for the received payload ### Check Delivery History If webhooks aren't arriving: ```bash curl "https://app.propstreet.com/api/v1/webhooks/123/deliveries" \ -H "Authorization: Bearer $TOKEN" ``` Look for: - `status: "failed"` with `lastError` explaining the issue - `status: "exhausted"` indicating all retries failed - `lastStatusCode` showing what your server returned ## Recovering Failed Deliveries ### Replay Endpoint Re-queue failed or exhausted deliveries for retry: ```bash curl -X POST "https://app.propstreet.com/api/v1/webhooks/123/replay" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"since": "2026-01-01T00:00:00Z"}' ``` Response: ```json { "replayed_count": 15, "message": "15 deliveries re-queued for retry" } ``` **Key behaviors:** - Re-queues up to 100 failed or exhausted deliveries - Only replays deliveries created after the `since` timestamp - Resets attempt count to 0 and schedules immediate delivery - Requires webhook to be `active` (not paused/disabled) This is useful for: - Recovering after fixing an endpoint issue - Retrying exhausted deliveries after the 35-hour window - Bulk recovery after infrastructure outages ## Webhooks vs Delta Sync | Approach | Best For | | ---------- | ---------------------------------------------- | | Webhooks | Real-time UI updates, instant notifications | | Delta Sync | Batch processing, guaranteed consistency | | Both | Production systems needing speed + reliability | **Recommended strategy:** 1. Use webhooks for immediate updates 2. Run delta sync periodically (hourly/daily) to catch any missed events 3. Use delivery history to identify and replay missed webhooks ## Related Documentation - [Sync Patterns Guide](./sync-patterns) — Delta sync and pagination - [Errors & Rate Limiting](./errors-retries) — Error handling patterns - [Authentication Guide](./authentication) — OAuth and API credentials - [API Reference](/api-reference) — Full API specification # 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 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: ```bash 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`: ```json { "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: ```bash curl "https://app.propstreet.com/api/v1/projects?page_size=100" \ -H "Authorization: Bearer $TOKEN" ``` Response: ```json { "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](#optimistic-concurrency-etags)) | #### Asset Object ```json { "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 ```json { "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 ```json { "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 ```json { "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 ```bash 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](#optimistic-concurrency-etags) for details. ### Create a Project ```bash 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](./limits#pictures)) | > [!important] > Use the `Idempotency-Key` header to safely retry failed requests. See [Idempotency Guide](./idempotency) for details. ### Update a Project ```bash 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](#optimistic-concurrency-etags). ### Delete a Project ```bash 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 ```bash 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](./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 ```bash 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 ```bash curl "https://app.propstreet.com/api/v1/projects/123/prospects?page_size=100" \ -H "Authorization: Bearer $TOKEN" ``` Response: ```json { "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](#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](#optimistic-concurrency-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](#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](#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 | > [!NOTE] > 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](#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 ```bash 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: ```bash # 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: ```bash # 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](#optimistic-concurrency-etags). 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. ```bash # 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. ```bash # 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 ```bash 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 ```bash 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 ```bash 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 ```bash curl "https://app.propstreet.com/api/v1/network/contacts/789/projects?page_size=100" \ -H "Authorization: Bearer $TOKEN" ``` ### List Projects for a Company ```bash curl "https://app.propstreet.com/api/v1/network/companies/321/projects?page_size=100" \ -H "Authorization: Bearer $TOKEN" ``` Response: ```json { "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): ```json { "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 ```bash 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): ```bash 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: ```bash 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: ```bash # 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: ```bash # 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: ```python 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): ```bash 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: ```json { "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: ```bash 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](#retrying-a-failed-prospect) describes. Every requested id comes back in exactly one list: ```json { "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 ```bash 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 ```bash 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 ```bash 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: ```json { "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](./webhooks) 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](./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 ```json { "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](./webhooks) for every payload field. ### Filter by External Ref Subscribe to changes for specific CRM records: ```bash # 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: ```javascript 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: ```javascript 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: ```bash # 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: ```python 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 | ## Related Documentation - [Sync Patterns Guide](./sync-patterns) — Pagination and delta sync details - [Webhooks Guide](./webhooks) — Real-time event notifications setup - [Idempotency Guide](./idempotency) — Safe retries with idempotency keys - [Errors & Retries](./errors-retries) — Error handling patterns - [API Reference](/api-reference) — Full endpoint specifications # Identity & Teams > Look up the authenticated caller and their team memberships. The identity and teams endpoints expose the caller's own details, verified capabilities, and the teams they belong to. Use these to: - Bootstrap an integration with one call — `GET /api/v1/auth/me` returns the caller, their primary team, and any other teams they belong to or have been invited to. - Render team directories and member pickers. - Check whether the authenticated user is an admin of a given team before invoking admin-only operations. ## Quick Reference | Feature | Details | | ----------------------------- | -------------------------------------------------------------------------------------------------------- | | **`GET /auth/me`** | The caller's identity, capabilities, primary team, and any other teams they belong to or are invited to. | | **`GET /teams/{id}`** | A single team. Includes an inline member list (up to 50) when the caller is an active member. | | **`GET /teams/{id}/members`** | Paginated, role-filterable list of active members. Stable keyset ordering for cursor pagination. | | **Access levels** | Active members get the full member list; callers with a pending invite see the team's details only. | | **Inline cap** | `members[]` is capped at 50 entries. `hasMoreMembers: true` with a `memberCount` signals truncation. | | **IDs** | All identifiers are opaque strings. Treat them as such; do not assume any format. | | **Not found** | `404 not-found` — never `403` — when the caller has no visibility into the target team. | ## Endpoints ### `GET /api/v1/auth/me` Returns the authenticated caller. No query parameters — always a single object. ```bash curl "https://app.propstreet.com/api/v1/auth/me" \ -H "Authorization: Bearer YOUR_TOKEN" ``` Response: ```json { "id": "a3f8...", "email": "jane@acme.com", "firstName": "Jane", "lastName": "Smith", "capabilities": ["broker"], "culture": "en-US", "uiCulture": "en-US", "region": "US", "timeZone": "America/New_York", "isBot": false, "primaryTeam": { "id": "...", "name": "Acme Capital", "categories": ["private_equity"], "plan": "professional", "membership": { "role": "admin", "status": "active", "joinedUtc": "2024-02-01T10:00:00Z" }, "members": [/* up to 50 active members */], "memberCount": 7, "hasMoreMembers": false }, "secondaryTeams": [/* other teams — may include pending invitations */] } ``` **Notes:** - `capabilities` lists the caller's verified, non-revoked claims. Only `broker` and `investor` appear on the wire. - `firstName` and `lastName` are exposed as discrete fields. Compose display names client-side (e.g. `` `${firstName} ${lastName}`.trim() ``); fall back to `email` when both name fields are missing. - Callers with no active team (pre-onboarding, service accounts, etc.) return `primaryTeam: null` and `secondaryTeams: []`. Handle this gracefully — it's a valid runtime state. - `membership.status` inside each team tells you whether the caller is an active member or has a pending invite. Prefer this over inferring from position in the `secondaryTeams[]` array. ### `GET /api/v1/teams/{id}` Returns a single team by its opaque id. The caller must have visibility — active membership or a pending invite — into the team; `404` otherwise. ```bash curl "https://app.propstreet.com/api/v1/teams/TEAM_ID" \ -H "Authorization: Bearer YOUR_TOKEN" ``` The `members[]`, `memberCount`, and `hasMoreMembers` fields are **only populated when the caller is an active member** of the team. Callers with a pending invite see the team's details but not the member list. `hasMoreMembers: true` is a **truncation signal**, not continuation metadata. The inline member slice is capped at 50 entries and cannot be resumed — there is no cursor on the inline slice, and its ordering differs from the paginated `/teams/{id}/members` response. Clients that need the full roster should call `/teams/{id}/members` from page 1, not try to "append" pages onto the inline slice. ### `GET /api/v1/teams/{id}/members` Paginated list of a team's active members. The caller must be an active member; callers with a pending invite receive `404`. | Query parameter | Type | Description | | --------------- | ------ | --------------------------------------------------------------- | | `role` | string | Filter: `admin` or `member`. Omit for all roles. | | `page_size` | int | Page size, 1–100. Default 50. Values above the cap are clamped. | | `cursor` | string | Opaque cursor from a previous response's `page.nextCursor`. | Snake_case query parameter names match the rest of the Public API. ```bash curl "https://app.propstreet.com/api/v1/teams/TEAM_ID/members?role=admin&page_size=10" \ -H "Authorization: Bearer YOUR_TOKEN" ``` Response: ```json { "data": [ { "id": "...", "role": "admin", "status": "active", "user": { "id": "a3f8...", "firstName": "Jane", "lastName": "Smith", "email": "jane@acme.com" } } ], "page": { "pageSize": 10, "hasMore": false } } ``` ## Access levels Team endpoints distinguish two levels of access based on the caller's relationship to the team: - **Pending invite** — the caller has been invited but has not accepted. They see the team's details (`name`, `plan`, `country`, `contactPerson`, etc.) so they can recognise the invitation, but not the member list. - **Active member** — the caller has accepted. In addition to the details above, they see `members[]`, `memberCount`, `hasMoreMembers`, and can call `GET /teams/{id}/members`. `membership.status` on each team is the authoritative signal — use it rather than inferring from which fields are present. ## Member ordering The inline `members[]` slice on a team response is ordered for display: 1. Admins first. 2. Then by the composed first/last name, falling back to email when both are missing. 3. Then by member `id` as a stable tiebreaker. The paginated `/teams/{id}/members` endpoint uses a different, keyset-stable ordering so cursors remain valid across pages: 1. `joinedUtc` ascending. 2. Then by member `id` as a stable tiebreaker. These orderings differ by design. The inline slice is for display in a team card; the paginated endpoint is for walking the full roster with cursors. Because the orderings differ, the inline slice is **not a prefix** of the paginated listing — a client that wants the full roster must restart from page 1 of `/teams/{id}/members`. ## Related - [Multi-Tenant](./multi-tenant) — how teams isolate data between tenants. - [MCP Server](./mcp-server) — the same identity data is available to AI agents via the `get_me` tool. # Teasers > Anonymised property profiles attached to projects, properties, and Case Rooms — read + design-time write. A **teaser** is the anonymised view of a project, property, or Case Room. The project and property pre-market teasers are what investors see in their feed before signing an NDA; the room teaser is the post-NDA disclosure surface inside a Case Room. All three strip broker-only details (exact address, owner identity, full contact list) and render a curated subset (categories, locality, headline metrics, optional photos) controlled by the broker through a **visibility matrix**. The response shape is the same regardless of parent context — the parent determines authorization and lifecycle, not the field shape. ## Quick Reference | Feature | Details | | ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | | **Parent contexts** | `project` (broker-authored), `property` (investor pre-market), `room` (post-NDA per-room teaser) | | **Read endpoints** | `GET /api/v1/projects/{id}/teaser`, `GET /api/v1/properties/{id}/teaser`, `GET /api/v1/projects/{id}/rooms/{roomId}/teaser` | | **Write endpoints** | `PATCH` on the same three URIs — standard partial-update semantics | | **Concurrency** | `ETag` on every GET; `If-Match` optional on PATCH — omit to skip the precondition; `412 Precondition Failed` only when supplied with a stale token | | **Stages** | `property_added` → `drafting` → `published` → `verified` → `communicated` | | **Authorization** | `project` + `room`: broker-only; `property`: portfolio access (investor) | | **Lifecycle transitions** | Not available via the API (publish / unpublish / verify / communicate). Perform them in the Propstreet app. | ## How Teasers Work A teaser sits between the broker (who owns the source data) and the investor (who consumes it pre-NDA). Three things determine what an investor actually sees: 1. **The parent context** — `project`, `property`, or `room` — picks the lifecycle and the authorization gate. 2. **The visibility matrix (`options`)** — broker decides per field which display mode to expose: `actual`, `hidden`, `span`, or `custom_span`. On read responses, fields that aren't available for the current plan or property data appear as `{ "display": "unavailable" }` — that value is read-only and cannot be set on a PATCH. 3. **The projection (`projection`)** — what the investor view actually renders, after applying the matrix to the property's stored fields. Hidden fields are absent (not `null`) so the JSON shape mirrors the investor view. ``` ┌────────────────────────────────────────────────────────────────────┐ │ PARENT (one of) │ │ │ │ project (broker-authored) property (pre-market) room (post-NDA)│ │ ──────────────────────── ────────────────────── ───────────────│ │ • broker drafts • investor-initiated • per-room │ │ • published / verified • portfolio teaser • lives only │ │ • communicated to • virtual envelope when in a Case │ │ matched investors no real teaser yet Room context │ └────────────────────────────────────────────────────────────────────┘ │ ▼ TEASER (same DTO shape) ┌─────────────────────────────────────┐ │ stage / publishedUtc / verifiedUtc │ │ options (visibility matrix) │ │ template (allowed display modes) │ │ projection (what investors see) │ │ nudge (optional steerable hint) │ │ etag (optimistic concurrency) │ └─────────────────────────────────────┘ ``` ### Lifecycle stages | Stage | Means | | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `property_added` | Property has been linked but the broker hasn't started editing the teaser. Returned as a thin envelope on the property route when no real teaser row exists yet. | | `drafting` | Teaser row exists; not yet published. Investors don't see it. | | `published` | Broker has confirmed the matrix and prose and submitted the teaser to Propstreet for review. Locked from broad edits — unpublish to change. Investors still don't see it. | | `verified` | Propstreet has reviewed and approved the teaser. Only from here can the broker communicate it. Same field shape as `published`. | | `communicated` | Distributed to the matched prospect list. Furthest along — `communicatedUtc` is the source of truth, takes precedence over earlier timestamps when deriving stage. | Stage precedence on the wire: `communicatedUtc > verifiedUtc > publishedUtc > drafting > property_added`. Always read the timestamps; don't infer from `stage` alone. ### Nudges When a prerequisite is missing or the current state blocks the obvious next action, the response includes a steerable hint: ```jsonc "nudge": { "code": "no_property_attached", "message": "Attach a property to this project before drafting the teaser.", "navigateUri": "propstreet://project/123" } ``` Branch on the `code` field (not the message text) — codes are stable across releases: | Code | When | | ------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | | `no_property_attached` | Project has no property linked yet — broker can't start drafting. | | `no_pre_market_yet` | Property has no pre-market record — virtual envelope returned. | | `transferred_to_other_broker` | The property's latest pre-market record was transferred to another broker — read still works, edits are blocked. | | `teaser_locked_unpublish_first` | Teaser is `published` / `verified` / `communicated` — unpublish before edits. | | `room_teaser_not_designed` | Room teaser hasn't been designed yet — a virtual default room teaser is returned alongside the nudge; the first PATCH saves a real room teaser. | | `no_investment_size` | The project has no investment size yet — set one before designing the teaser. | | `incomplete_property_data` | The property's data isn't ready yet — some required values are missing. Wait for data to finish loading, or complete the missing fields on the property. | ## Authorization by surface Each teaser surface is gated on a different role: | Surface | Required role | What you'll see if wrong | | ------------------------------------------------------- | ------------- | ------------------------ | | `GET/PATCH /api/v1/projects/{id}/teaser` | **Broker** | 403 Forbidden (no body) | | `GET/PATCH /api/v1/projects/{id}/rooms/{roomId}/teaser` | **Broker** | 403 Forbidden (no body) | | `GET/PATCH /api/v1/properties/{id}/teaser` | **Investor** | 403 Forbidden (no body) | A 403 is returned without a ProblemDetails body — treat it as "the token's role does not match this surface." A 404 with a ProblemDetails body means "id not found or you don't have access to that specific row" and is returned only for callers whose role allows the surface. If you're integrating from a CRM / back-office, mint a token whose role matches the surface you'll write to: brokers for project + room teasers, investors for property pre-market teasers. ## REST API ### `GET /api/v1/projects/{id}/teaser` Returns the project's teaser. Broker-only. ```http GET /api/v1/projects/42/teaser HTTP/1.1 Authorization: Bearer ... ``` Response: ```http HTTP/1.1 200 OK ETag: W/"abc123def456" Content-Type: application/json ``` ```jsonc { "id": "8472", "createdUtc": "2026-04-10T08:00:00Z", "updatedUtc": "2026-04-12T10:24:00Z", "etag": "W/\"abc123def456\"", "stage": "published", "publishedUtc": "2026-04-12T10:24:00Z", "isPreMarket": false, "exclusivity": "exclusive", "title": "Office building (SE)", "tags": ["cbd", "long_leases"], "options": {/* visibility matrix */}, "template": {/* allowed display modes per field */}, "projection": {/* investor-facing rendered view */}, "navigateUri": "propstreet://project/42/teaser", } ``` `id`, `createdUtc`, `updatedUtc`, and `etag` are always present on every teaser response. Until the teaser is first saved, `id` is `"0"` and `createdUtc` / `updatedUtc` are the creation time of the project, Case Room or property the teaser belongs to (never the time of the read) — this covers thin envelopes (e.g. `nudge.code = no_pre_market_yet`) and the default teaser shown before the first edit. Cache by the parent ID, not by `id`. Every save creates a new teaser version, so after a save `id` changes and `createdUtc` equals `updatedUtc`. The `teaser` summary on a project (`GET /api/v1/projects`, `GET /api/v1/projects/{id}`) carries the same `id`, timestamps and `etag` as the teaser endpoint serves. The `etag` value in the body and the `ETag` response header are identical — use whichever is easier for your client to read. ### `GET /api/v1/properties/{id}/teaser` Returns the property's pre-market teaser, or a thin `property_added` envelope when no pre-market record exists yet (`nudge.code = no_pre_market_yet`). Investor-readable under the same portfolio access gate as the rest of `/api/v1/properties/*`. ### `GET /api/v1/projects/{id}/rooms/{roomId}/teaser` Returns the per-room teaser inside a Case Room. Broker-only. When the room teaser hasn't been designed yet, returns a virtual default room teaser plus `nudge.code = room_teaser_not_designed` so the client can prompt the broker to design one (the first PATCH pins a real row). ## Writing teasers The write surface is **design-time only**: brokers shape the visibility matrix, the tag snapshot, and the per-field display modes. Lifecycle transitions (publish, unpublish, verify, communicate, clone) are not available via the API — perform them in the Propstreet app. ### PATCH endpoints #### `PATCH /api/v1/projects/{id}/teaser` #### `PATCH /api/v1/properties/{id}/teaser` #### `PATCH /api/v1/projects/{id}/rooms/{roomId}/teaser` Standard PATCH semantics: send only the fields you want to change. **Omitted** means "leave unchanged"; an **explicit value** (including `null` where allowed) means "set to this". Set a tag filter and update two option fields: ```http PATCH /api/v1/projects/42/teaser HTTP/1.1 Authorization: Bearer ... Content-Type: application/json If-Match: W/"abc123def456" ``` ```jsonc { "tags": ["cbd", "long_leases"], "options": { "yield": { "display": "span" }, "rentalIncome": { "display": "hidden" }, }, } ``` Reset the snapshot to the property's current template pool: ```jsonc { "tags": null } ``` Update a single field without touching anything else: ```jsonc { "options": { "locality": { "display": "actual" }, }, } ``` Field-by-field semantics on the PATCH body: - **`options`** — sparse map. Only keys present overwrite the current option for that field. Absent keys are untouched. To revert a single option to the template default, set it explicitly; you cannot "unset" a single option via omission. - **`tags`** — snapshot, see below. - The numeric `span` payload visible on GET responses is **GET-only** — it's derived from the property data; PATCH carries the display mode only. #### `tags` — snapshot semantics `tags` is a **snapshot** — the exact set the author chose to disclose to investors at write time. It is stored verbatim and rendered verbatim; the server never resolves it live against the property's current pool. If a tag is later added to or removed from the property, the snapshot on an existing teaser does not change — the author writes a new teaser to pick those changes up. This is load-bearing for the broker audit trail and the legal/disclosure story: what an investor saw at moment X is what was disclosed at moment X. | PATCH body | GET reads back | Meaning | | ------------------- | ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `tags` omitted | unchanged | **No change** — the field is left alone. Default for any PATCH that doesn't mention it. | | `"tags": ["a","b"]` | `"tags": ["a","b"]` (after dedupe + clamp to the pool) | **Write snapshot** — investor sees exactly these tags. Wire codes outside `template.tags` are rejected with `400`. | | `"tags": []` | `"tags": []` | **Explicit empty snapshot** — author chose to show no tags on this teaser. | | `"tags": null` | `"tags": ` | **Reset to pool** — sugar for "snapshot the property's current `template.tags` as-is." The server materialises the pool and stores it; no "follow live" mode. | Read back the response (`tags`) rather than assuming the input was preserved exactly — duplicates are deduped, and on the Free plan the pool may be silently clamped to empty before the snapshot lands. The order of entries in `tags` is **not part of the contract** — clients that depend on a specific display order should sort client-side. Treat it as set-shaped. #### Free-plan clamp Brokers on the Free plan may PATCH options or tags that exceed Free-tier capabilities (e.g. `span` on a plan-restricted metric). The PATCH still returns HTTP 200 with the **clamped** result — the server silently downgrades the offending option to the closest permitted display mode and the response reflects the clamped state. There is no separate "outside your plan" error; re-read `options` and `tags` from the response. #### Write preconditions | Precondition | Status | `type` | | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | ---------------------------------------------------------------------------------------------------------- | | Project or property pre-market teaser has reached `published` / `verified` / `communicated` (room teasers exempt — they keep moving across the full lifecycle, including after `verified` / `communicated`) | 409 | `/help/teaser-already-published` | | Body fails shape validation (unknown field, malformed display mode, etc.) | 400 | `/help/validation-failed` (default `ValidationProblemDetails`) | | Body is empty (`{}` — no `options` or `tags` specified) | 400 | `/help/teaser-patch-empty` | | Project has no property linked | 409 | `/help/teaser-project-has-no-property` (GET still 200 with `nudge.code = no_property_attached`) | | Property's latest pre-market record has been transferred to another broker | 409 | `/help/teaser-transferred-to-other-broker` (GET still 200 with `nudge.code = transferred_to_other_broker`) | #### Auto-create on first edit If you PATCH a property's teaser and no pre-market record exists yet, the server creates one in the same request and the response carries the new teaser. The same applies to a Case Room that doesn't have a per-room teaser yet. The operation is idempotent — parallel PATCHes on the same property or room converge on a single teaser. ## Concurrency Teasers use optimistic concurrency. Every read response carries an `ETag` (in both the HTTP header and the body's `etag` field). PATCH supports — but does not require — `If-Match`: send it to gate the write on the version you read, or omit it to skip the precondition. When `If-Match` is supplied and is stale, you get `412 Precondition Failed` and the write is rejected. ### How it works 1. `GET` returns an `ETag` header with a version identifier. 2. `PATCH` optionally includes the `If-Match` header carrying that value. Omit `If-Match` for fire-and-forget writes; include it for safe concurrent edits. 3. If `If-Match` is supplied and the teaser changed since you fetched it, you get `412 Precondition Failed`. 4. Refetch, merge your changes, and retry with the new ETag. ### Example ```bash # 1. Fetch the teaser curl "https://app.propstreet.com/api/v1/projects/42/teaser" \ -H "Authorization: Bearer $TOKEN" \ -i ``` Response (headers + body): ``` ETag: W/"abc123def456" Content-Type: application/json { "stage": "drafting", "options": { /* ... */ }, "etag": "W/\"abc123def456\"", ... } ``` ```bash # 2. Update with If-Match curl -X PATCH "https://app.propstreet.com/api/v1/projects/42/teaser" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -H 'If-Match: W/"abc123def456"' \ -d '{ "tags": ["cbd", "long_leases"] }' ``` ### Handling 412 Precondition Failed If someone else updated the teaser: ```json { "type": "urn:propstreet:error:precondition-failed", "title": "Precondition failed", "status": 412, "detail": "ETag mismatch - resource was modified" } ``` Resolution: 1. Refetch the teaser to get the latest `ETag` and current state. 2. Merge your intended changes with the refreshed `options` and `tags`. 3. Retry the PATCH with the new `If-Match` value. ## Field reference ### `options` — visibility matrix (broker-authored) Per-field decision. Each option carries a closed enum of display modes. Examples: ```jsonc "options": { "locality": { "display": "actual" }, "yield": { "display": "span" }, "investmentSize": { "display": "custom_span" }, "rentalIncome": { "display": "hidden" } } ``` Each option entry has exactly one field — `display`. There is no embedded `span` value on the option itself. The numeric range that backs a `span` / `custom_span` display lives on the GET response under `projection.` as a typed range DTO. For example, with `options.yield.display = "span"` and `options.investmentSize.display = "custom_span"`: ```jsonc "projection": { "yield": { "min": 4.5, "max": 5.5 }, // PercentOrRangePublicDto "investmentSize": { "min": 100, "max": 200, "currency": "SEK", "scale": "millions" } // PriceOrRangePublicDto } ``` Display values on PATCH: `actual`, `hidden`, `span`, `custom_span`. Read responses additionally include `unavailable` (read-only) for fields that aren't available given the current plan or property data. The numeric range is GET-only — it's computed from the property data and surfaced on `projection`; PATCH carries the display mode only. ### `template` — what the broker is allowed to choose Mirrors `options` keys but lists the modes that are valid given the active subscription plan, the underlying property data, and the parent context. Use this to surface a UI for editing options without sending invalid selections. ### `projection` — what investors see Rendered view. Hidden fields are absent (not `null`) — so the JSON shape mirrors the investor view directly. ### `exclusivity` — sales exclusivity (project parent only) Mirrored from the parent project. Values: `exclusive`, `non_exclusive`, `other`. Null on property pre-market and room teasers (no parent project exclusivity to mirror). > **Naming note.** Aligned with the parent project resource (`/api/v1/projects/{id}`), which renamed `mandate` to `exclusivity` in April 2026 (see [changelog](./changelog#unreleased)). The teaser surface emits `exclusivity` natively — there is no deprecated `mandate` alias on the teaser response. ## Privacy & boundaries - **No broker identity surfaces.** A teaser response never embeds the owning broker's name, contact, or organization. Investors see the projection only. - **Field hiding is structural.** A hidden field is omitted, not nulled — so consumers can't infer "had a value once" from a `null`. See [Data Ownership](https://docs.propstreet.com/data-ownership) for the broader rules. - **Lifecycle transitions are unavailable.** State changes (publish, unpublish, verify, communicate, clone) cannot be performed via this API — perform them in the Propstreet app. # Comps > Market transactions: the deals of your closed projects and the deals your team records — each on a property with its facts as of the deal, with parties, sources, history and analysis. A **comp** is one market transaction your organisation knows of. Every closed project of yours becomes a comp automatically (the close is the deal); every other comp is recorded by your team — from the press, from a market contact, from a call. A comp is anchored on exactly one property: one of your own properties, or a property recorded for the deal alone (somebody else's building, which never appears on `/api/v1/properties`). A portfolio is one record with the portfolio's totals and a property count. Comps are visible to your whole organisation and are part of the **Professional** plan: every comp route answers `403` with the problem type `/help/plan-required` for an organisation on another plan (see [Errors & Retries](./errors-retries)). A member of several organisations reaches the comps of their **active** organisation only — the one they act for in Propstreet — and the plan is that organisation's; a comp of another of their organisations answers `404` until they switch to it. ## Quick Reference | Feature | Details | | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Resource** | `/api/v1/comps` | | **Sync** | `GET /api/v1/comps` — cursor-paged, oldest change first; `updated_since`, `include_deleted` (deleted and voided deals); hidden deals are always in the feed | | **Search** | `POST /api/v1/comps:search` — the app's filters plus locality, country, area, price, yield, party, advisor and project; `limit` / `offset` with `total` | | **Analysis** | `POST /api/v1/comps:summary` — the same filters → counts, latest deal, yield and price-per-square-metre medians with what they leave out, deals and volume per quarter, top buyers and sellers | | **Property search** | `POST /api/v1/comps/properties:search` — your own properties and the properties recorded for your deals, by name, street or locality, to anchor another deal on the same record | | **Read** | `GET /api/v1/comps/{id}` (with `ETag`), `POST /api/v1/comps:batchGet`, `GET .../{id}/parties`, `GET .../{id}/sources`, `GET .../{id}/property`, `GET .../{id}/actions` | | **Write** | `POST /api/v1/comps` (idempotent, duplicate check), `PATCH .../{id}` (`If-Match`), `DELETE .../{id}`, `POST .../{id}:restore`, `POST .../{id}:hide` / `:unhide`, `POST /api/v1/comps:merge` (idempotent), `POST`/`DELETE` parties and sources, `PUT .../{id}/property`, `PUT .../{id}/property/facts`, `PUT .../{id}/property/picture`, `POST .../{id}/notes` | | **Vocabulary** | A deal's project is a `project`; a party or a source names a `contactId` or a `companyId`; every enum is a word (`sale`, `confirmed`, `seller_advisor`, `own_project`, …) | | **Currency** | Prices are never converted. Every read works in one request `currency` (your own when omitted); price filters and sorts compare only deals priced in it | | **Authorization** | Broker organisations on the Professional plan | ## How Comps Work ``` Closed project ──(the close)──▶ Comp ◀──(recorded)── Your team │ ├── property one record, facts as of the deal (propertyId; ownProperty tells your own from one recorded for a deal) ├── parties[] seller, buyer, seller_advisor, buyer_advisor → contactId | companyId | your own firm ├── sources[] article | activity (a note) | contact | company, each under a channel: news | market_contact └── actions the history: every change with old and new values ``` - **One date.** `dealDate` is the completion date unless it is only planned, else the signing date, else the planned completion. Period filters and the analysis always use it; `signedOn` and `completedOn` are also sortable. - **Honest numbers.** `price` is in full currency units with `priceCertainty` (`confirmed`, `reported`, `estimated`) and `priceApproximate`; `priceUnknown: true` means the price is known to be unknown. `yield` is a plain number in percent (5.0 = 5%), with `yieldCertainty` and `yieldApproximate` beside it — a number here, unlike `transactions[].yield` on a project, which is a value-or-range object. Land has a price but never a yield or a `pricePerSqm`. - **Sources, not recorders.** `sources[]` say where your team learned about the deal; `createdVia` says who recorded it (`app`, `api`, `agent`, `system`). Your own closed project is not a source — it is `project`. - **Source channels.** `sourceChannels[]` lists every channel the deal counts under in the source filter: `own_project` (a closed project of yours), `news` (an article, or a news note), `market_contact` (a contact, a company, or one of their calls, meetings, e-mails or notes), or `none`. A deal that came from an article and was confirmed by a call counts under both. - **What is missing.** `missing[]` names what keeps the deal from being complete (`price`, `buyer`, `seller`, `property`, `location`, `address`, `property_count`, `facts_at_deal_date`, `completion_confirmation`). `completion_confirmation` means the planned completion date has passed: confirm the completion with `completedIsPlanned: false` (a closed project's deal: correct the project's close) or move the date. Treat `missing[]` as open-ended — a new value may be added. - **Hidden deals.** A deal from a closed project can be hidden from the list for the whole organisation (`hidden`, `hiddenUtc`). The search leaves hidden deals out unless you send `includeHidden`; the analysis never counts them; the change feed always carries them, so a hide reaches your copy. - **Access to the project.** Everybody in the organisation sees the deal; the project behind it keeps its own access rule. When the caller may not open the project, `project` comes back with `accessible: false` and no `id` or `name`. The same holds for an `activity` source on such a project: `accessible: false`, no `activityId` and no text. ## Sync: `GET /api/v1/comps` Streams every comp of your organisation in the order it last changed, oldest first, as a `PagedResponse` of comps. Follow `page.nextCursor` until `hasMore` is false; store the newest `updatedUtc` you saw and pass it back as `updated_since` next time. ```http GET /api/v1/comps?updated_since=2026-10-01T00:00:00Z&include_deleted=true&page_size=200 ``` | Parameter | Description | | ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `updated_since` | Only deals changed on or after this UTC timestamp; with `include_deleted`, the deletions since then too | | `include_deleted` | Also deleted and voided deals. A deleted deal carries `deletedUtc` and `removalReason: "deleted"`; a deal whose project was corrected to no sale carries `deletedUtc` (= `voidedUtc`) and `removalReason: "voided"` — treat both as gone | | `kind` | `sale` (the only kind today) | | `page_size` | 1–500 (default 500) | | `cursor` | The `nextCursor` of the previous page | A change to a deal's parties, sources, property, notes or hidden state moves its `updatedUtc`, so a delta sync sees it. Hidden deals are in the feed with `hidden: true`. Clocks and in-flight writes differ: on each run, re-read with an `updated_since` a few minutes before the newest `updatedUtc` you hold and treat a deal you already have as an update (dedupe by `id`); nothing is lost that way, and nothing is counted twice. ## Search: `POST /api/v1/comps:search` Filters and pages through deals as a grid, with the total. Voided deals are never listed. ```json { "category": ["office"], "period": "last_24_months", "sourceChannel": ["own_project", "news"], "locality": ["Stockholm", "Solna"], "priceMin": 100000000, "currency": "SEK", "sort": "-dealDate", "limit": 50, "offset": 0 } ``` | Filter | Description | | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `query` | Free text over the deal's name, its street address, the property, the place and the parties' names (at most 100 characters) | | `category[]` | Property types: `residential`, `office`, `retail`, `industrial`, `social_infrastructure`, `hotel`, `land`, `special_purpose` | | `period`, `from`, `to` | On the deal date: `last_12_months`, `last_24_months`, `last_5_years`, `all` (default) or `custom` with `from` and `to` (inclusive dates) | | `sourceChannel[]` | `own_project`, `news`, `market_contact`, `none` — any of them | | `within` | A GeoJSON `Polygon` or `MultiPolygon` (`[longitude, latitude]`), as on the property and mandate searches; only deals whose property's address lies inside match (an area-only location never does). Not with `near` | | `near` + `radius` | A point `{ latitude, longitude }` and a radius in metres (at most 100000); the pair is required together | | `incomplete` | Only deals with something in `missing[]` | | `includeHidden` | Also hidden deals (search only — the summary never counts them) | | `locality[]`, `country` | Localities (any of them) and an ISO 3166-1 alpha-2 country code | | `areaMin`, `areaMax` | Leasable area, in square metres | | `priceMin`, `priceMax` | In full units of the request `currency`; deals priced in another currency never match | | `pricePerSqmMin`, `pricePerSqmMax` | In the request `currency` | | `currency` | ISO 4217; your own when omitted. Echoed as `currency` on the response | | `yieldMin`, `yieldMax` | In percent | | `partyId` + `partyRole` | A contact or company id that is a party (or a party's contact person), in any role or the given one (`seller`, `buyer`, `seller_advisor`, `buyer_advisor`) | | `advisedById` | A company id that advised on either side | | `projectId` | The deal of one project of yours | | `sort` | Omitted, the newest deal comes first. `dealDate`, `signedOn`, `completedOn`, `price`, `yield`, `pricePerSqm`, `leasableArea`; a name alone sorts ascending, prefix `-` for descending (`-dealDate` is the default order). Deals without the value sort last; on a price sort, deals in another currency come after those in the request currency | | `limit`, `offset` | 1–100 (default 100) and 0–10,000 | The response is a `SearchResponse` of comps (`results`, `total`, `hasMore`, `offset`, `limit`) plus `currency`. ## Analysis: `POST /api/v1/comps:summary` Takes the same filters and answers the numbers the Propstreet app shows for that selection: | Field | Description | | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `count`, `withoutPrice`, `landCount` | Deals in the selection, those without a known price, and land deals | | `latest` | The latest deal by deal date, whether or not its price is known | | `yield`, `pricePerSqm` | A median with `min`, `max`, `count` and `approximateCount`, computed only over the deals that carry the value — your own estimates, land and portfolios (a portfolio's figures blend its property types) are left out and counted under `excluded` (`withoutValue`, `estimated`, `land`, `otherCurrency`, `portfolio`, `otherMainType`). A deal of several property types counts under its main type (the largest area) only: in another type's median it is left out and counted as `otherMainType`. Medians need exactly one property type; otherwise `reason` is `select_one_category` (`not_applicable_for_land` when only land is selected) | | `quarters[]` | Per quarter of the deal date: `count` (every deal) and `volume` (the summed price of the `pricedCount` deals priced in the request currency) | | `topBuyers[]`, `topSellers[]` | The most active contacts and companies in your network — `contactId` for a person, `companyId` for a company — with their deal `count` and `volume`; your own organisation and removed records are not ranked | | `reason` | `too_many` when the selection holds more than 5,000 deals: only `count`, `withoutPrice` and `landCount` are answered; `latest` is empty, both medians carry `reason: too_many`, and `quarters`, `topBuyers` and `topSellers` are empty lists — narrow the filters | ## Reading a Comp A comp carries, besides the fields described above: `id`, `uri` (an opaque `propstreet://comp/{id}` deep link, not a web address), `kind` (`sale`), `name` (the deal's own name, else the property's), `category` and `type` (the main property type, and the type with its sub-type as the property endpoints spell it, e.g. `industrial/warehouse`), `streetAddress` and `locationPrecision`, `dealDate`, `signedOn`, `completedOn`, `completedIsPlanned`, the price and yield fields with `pricePerSqm`, `project`, `property` (with `factsAsOf`, the date of the facts locked when the deal was recorded), `parties[]`, `sources[]` (each with `archived` when its note was archived, and `createdBy`), `sourceChannels[]`, `missing[]`, `hidden` / `hiddenUtc`, `createdVia`, `createdBy`, `createdUtc`, `updatedUtc`, `etag`, and on a removed deal `deletedUtc`, `voidedUtc` and `removalReason`. `GET /api/v1/comps/{id}` answers the comp with its `ETag`; `POST /api/v1/comps:batchGet` takes `{ "ids": [...] }` (at most 100) and answers `found` and `notFound`. A voided deal answers `404`, as a deleted one does. The sub-resources read one part of the deal: - `GET /api/v1/comps/{id}/parties` — the parties. A party is a `contactId` (a person) or a `companyId` in your network, or your own firm (`ownFirm: true`, no id). A company party may carry its contact person — the person your team dealt with there — as `contactId` next to `companyId`, with `contactName`; it is recorded with the party, so it stays when the person later moves to another company. `fromProject: true` marks a party that came with the project's closing. `removed: true` marks a record that was removed from your network; the party keeps pointing at it. - `GET /api/v1/comps/{id}/sources` — the sources. An `article` carries `url`, `title`, `publisher` and `publishedOn`; an `activity` carries the note's `activityId` (its current version on the activity timelines) and its text as `name`; a `contact` or `company` carries the id and the name. Every source has a `channel` and may carry a `comment`. The app names two kinds of source: a **news item** — an `article`, or an `activity` from the news watch (channel `news`) — and a **person or company** — a `contact` or `company`, or an `activity` that is one of their calls, meetings, e-mails or notes (channel `market_contact`). - `GET /api/v1/comps/{id}/property` — the property with its facts as of the deal: `propertyId` (send it as `property.propertyId` to anchor another deal on the same record) and `ownProperty` (`true` for one of your own properties, also readable on `/api/v1/properties/{id}`; `false` for a property recorded for a deal, read and changed through its deals only), `isPortfolio` and `propertyCount`, `pictureId` and `pictureUrl` (its download address) when it has a picture, the name, locality and country, `location`, `categories[]` with rented and vacant area, `leasableArea`, `parcelArea`, `buildingRights`, `yearBuilt`, `rentedPercent`, `contractLengthYears`, `rentalIncome`, `netOperatingIncome` and `totalCosts`. `rentalIncome` and `netOperatingIncome` are in the deal's currency; a deal with an unknown price states them in the property's own currency, and a deal dated before the exchange rates go back leaves them out. A closed project without a property answers `404`. - `GET /api/v1/comps/{id}/actions` — the history, newest first, as a `PagedResponse` in the shape of the other action timelines: each entry carries an opaque `cursor`, `page_size` takes 1–100 (default 100), and `page.nextCursor` continues the read (a cursor that is not one answers `400`). Each entry is a `note` (its text in `details.message`, `details.kind: "note"`) or an `audit` with an `event` in the webhook vocabulary (`comp.created`, `comp.updated`, `comp.hidden`, `comp.unhidden`, `comp.deleted`, `comp.restored`), the `user`, `via` (`app`, `api`, `agent`, `system`) and `changes[]` — every changed deal field with `oldValue` and `newValue` under its public name (`price`, `currency`, `signed_on`, …) — plus `propertyChanges[]` for the property's facts edited through the deal. ## Recording a Deal `POST /api/v1/comps` records a deal your team learned about. Send an `Idempotency-Key` to make a retry safe (see [Idempotency](./idempotency)). ```json { "property": { "name": "Kv. Verkstaden 3", "locality": "Sundbyberg", "country": "SE", "location": { "latitude": 59.3808, "longitude": 17.9358 }, "categories": [ { "type": "office", "rentedArea": { "value": 9000, "unit": "sqm" }, "vacantArea": { "value": 1000, "unit": "sqm" } } ], "yearBuilt": 1998 }, "name": "Verkstaden", "streetAddress": "Industrivägen 12", "signedOn": "2026-03-01", "price": { "value": 180, "currency": "SEK", "scale": "millions" }, "priceCertainty": "reported", "priceApproximate": true, "yield": 5.1, "parties": [ { "role": "buyer", "companyId": "4711" }, { "role": "seller_advisor", "ownFirm": true } ], "sources": [ { "kind": "article", "url": "https://example.com/news/verkstaden", "title": "Verkstaden sold", "publisher": "Fastighetsbladet" } ] } ``` - **The property.** Either `propertyId` — one of your own properties, or a property recorded for another deal of yours; find either with `POST /api/v1/comps/properties:search` (below) — or a new record for somebody else's building: `locality`, `country` and `location` are required; send the locality's centre when only the district is known. A portfolio is `isPortfolio: true` with `propertyCount` (at least two) and the portfolio's totals. For land, send `parcelArea` (and `buildingRights`) instead of areas, and no yield. - **The address.** `streetAddress` and `locationPrecision` are the deal's own, not the property's: `locationPrecision: "area"` shows only the deal's area (its address is not public, the map draws a ring). A deal without a street address lists `address` under `missing` until it has one; one that shows only its area does not. Both are read on the deal (`streetAddress`, `locationPrecision`) and changed with `PATCH`, also on a deal from a closed project. - **The numbers.** A signing or a completion date is needed; a completion before the signing answers `422` (the same holds for a project's close and its correction); no date is too far ahead — a signing, completion or planned completion in the future is recorded as sent. `price` takes `scale`; without `priceCertainty` a price is `confirmed`. `priceUnknown: true` records that the price is known to be unknown. - **Rules** are answered with `422`: land takes no yield, a portfolio needs its count, a new property needs its location, locality and country, `rentalIncome` needs a property type in `categories` (it is recorded per type), an amount must stay within range once scaled, a party must be a contact or company in your network (or `ownFirm`), a contact person goes only with a company, a source carries exactly the link its kind needs (an `activity` source its `activityId` or a `note`; only an `activity` source files a note; a source names a `contactId` or a `companyId`, never both). At most 50 `parties` and 50 `sources` per request; a source's `comment` is at most 2,000 characters and an article's `url` at most 2,048. A malformed request — an unknown word, an id that is not one, a coordinate off the globe — is `400`. A source may file a note on a contact or company (`note`, see below); notes are filed only once the deal has passed every rule and the duplicate check, so a refused or held-back deal files nothing. - **Duplicates.** When the deal looks like one your organisation already has, nothing is recorded and `409` carries `possibleDuplicates[]` — each with `compId`, `reason` (`same_project`, `same_property_within_180_days`, `same_parties_within_30_days`, `same_name_and_locality`, `similar_locality_category_date_area`) and `differingFields`. Send the request again with `allowDuplicate: true` to record it anyway. The answer is `201` with the deal, its `ETag` and its absolute address in `Location`. ### Finding the property: `POST /api/v1/comps/properties:search` Before recording a deal on a building your team may already know, search for it: `{ "query": "Verkstaden" }` (1–100 characters, matched against the name, the street address of its deals and locality) answers `results[]` — at most 20, best match first — each with `propertyId`, `ownProperty`, `name`, `streetAddress` (of its latest deal that shows one), `locality`, `isPortfolio` and `propertyCount` (of its latest deal) and `dealCount`. Send the `propertyId` as `property.propertyId` on `POST /api/v1/comps` or `PUT .../{id}/property`, so the second deal shares the record instead of recording the building again. A property recorded for a deal is found only here and on its deals; it never appears on `/api/v1/properties`. ## Changing a Deal - `PATCH /api/v1/comps/{id}` changes the deal's own fields: omit a field to keep it, send `null` to clear it; `price` is cleared with `null` and set with `{ value, currency, scale }`. Send the deal's `etag` as `If-Match` to refuse a change on a stale version (`412`). On a deal from a closed project the project's fields — price, `priceUnknown`, `priceApproximate`, yield, `yieldApproximate`, the dates, the portfolio pair and the property — are refused with `409` (`priceCertainty` and `yieldCertainty`, the name, the address and its precision stay the deal's own): correct them with `POST /api/v1/projects/{id}/close/correct` (see [Projects & Prospects](./projects-prospects)). Every change is one entry in the history, with the old and new value of each field. - `POST .../{id}/parties` and `DELETE .../{id}/parties/{partyId}` add and remove parties. A `contactId` of someone who works at a company (their primary company in your network) is recorded as that company with the contact as its contact person; send `companyId` and `contactId` together to name the contact person at a company yourself; a contact without a company is the party alone. The same rule applies to `parties[]` on `POST /api/v1/comps` and to the parties of a project's close; a party that came with the project's closing is corrected with `POST /api/v1/projects/{id}/close/correct` (`409`). `POST .../{id}/sources` and `DELETE .../{id}/sources/{sourceId}` add and remove sources. A child write changes the deal's version, so every one of them answers **the deal**: a POST with `201`, the deal as the body, its new `ETag` and the child's address in `Location` (`.../parties/{partyId}`, `.../sources/{sourceId}`; a note's `Location` is the history); a DELETE with `200`, the deal and its new `ETag`. An `activity` source may file a new note on a contact or company in the same call — `note: { contactId | companyId, type: call | meeting | note, text }` (`text` 1–10,000 characters) — which becomes the source and also shows on their timeline; the note is filed only once every rule has passed, together with the source (a refused source files no note). These POSTs take an `Idempotency-Key`; every one of them answers the whole deal with its new version. - `PUT .../{id}/property` anchors the deal on another property (an existing record by `propertyId`, or a new one); a deal from a closed project keeps the project's property (`409`, correct the close instead). `PUT .../{id}/property/facts` corrects the facts as of the deal — the whole set replaces the previous one as a new dated fact row, logged in the history. A deal's facts are locked when it is recorded, so editing a property on `/api/v1/properties/{id}` never changes them. On a property recorded for a deal, the facts are restated for that deal; on one of your own properties, only its owner or an admin may correct them (`403` otherwise), the correction applies to every deal recorded with the same facts, and the property's name and location stay. Facts that are not a valid property (no location, no property type, a portfolio of fewer than two) answer `422`. Both take `If-Match`. `PUT .../{id}/property/picture` sets the property's picture — `{ "pictureId": "…" }` with an image id from `POST /api/v1/images` (`purpose: "project"`, the cover size), or `{ "pictureId": null }` to remove it — on your own property or one recorded for a deal; every deal on the property shows it as `property.pictureId`, with its download address in `property.pictureUrl` and logs it in its history (`picture`). It takes `If-Match`; an id that is not a picture you may use answers `400`. The picture of one of your own properties is changed by the property's owner or an admin (`403` otherwise), as on the property itself. - `POST .../{id}/notes` adds a note (`text`, 1–4,000 characters) to the deal's history; it takes an `Idempotency-Key`. - `POST .../{id}:hide` hides a deal from a closed project for the whole organisation (see Hidden deals above); `POST .../{id}:unhide` shows it again. Only the project's owner or an admin may (`403`); a recorded deal is deleted instead (`409`). Repeating either changes nothing. Both answer the deal with its new `ETag`. - `POST /api/v1/comps:merge` folds two deals that are one into one: `{ "survivorId": "…", "otherId": "…", "takeFromOther": ["price", "signed_on"] }`. The survivor keeps its values except the fields named in `takeFromOther` (public field names, as in the history's `changes[]`; a field takes its companions — a price its currency and qualifiers, a date the other dates, `property` the portfolio pair and facts), gains the other's parties and sources (each once), and both record the merge in their history; the other is deleted for good and cannot be restored — the change feed reports it deleted and it emits `comp.deleted`. A deal from a closed project is always the one kept, whichever you named, and keeps the project's fields (`409` when `takeFromOther` names one); two deals from closed projects are never merged (`409`). The answer is the deal that stays, with its `ETag`. Send an `Idempotency-Key` to make a retry safe. - `DELETE /api/v1/comps/{id}` deletes a recorded deal (`204`); `POST .../{id}:restore` brings it back. A deal from a closed project cannot be deleted (`409`) — correct the project's close instead (`POST /api/v1/projects/{id}/close/correct`); a correction to no sale voids it (see delta sync above). ## The deal on the project A closed project's `transactions[]` entry points at its deal: `compId` (left out of the JSON while your active organisation is not on the Professional plan, or the deal belongs to another of your organisations), `signedOn`, `completedOn`, `completedIsPlanned`, `seller`, `sellerAdvisor` and `buyerAdvisor`; the `buyer`, `seller` and advisor parties carry `contactId` or `companyId` when they are records in your network, named as the deal names them. The transaction reports what the close recorded: a party your team later adds to the deal is on the deal, not on the project. See [Projects & Prospects](./projects-prospects). ## Related Documentation - [Projects & Prospects](./projects-prospects) — closing a project - [Sync Patterns](./sync-patterns) — delta sync and cursors - [Errors & Retries](./errors-retries) — `/help/plan-required` # MCP Server > Connect AI agents to your Propstreet data using the Model Context Protocol. The Propstreet MCP server lets AI assistants (Claude, Cursor, Windsurf, etc.) read and write your contacts, companies, links, properties, projects, prospects, and recorded deals (comps) directly. It uses the same data and permissions as the REST API, exposed over the [Model Context Protocol](https://modelcontextprotocol.io/) standard. ## Quick Reference | Property | Value | | ------------- | --------------------------------------------------------------- | | Endpoint | `https://app.propstreet.com/mcp` | | Transport | Streamable HTTP (stateless) | | Protocol | MCP `2026-07-28`, with legacy client compatibility | | Auth | Bearer token (`api:public` scope) | | Rate Limiting | Same as REST API, varies by token type (see [Limits](./limits)) | | Discovery | `GET /.well-known/oauth-protected-resource/mcp` | ## How It Works ``` Your AI Agent ──MCP protocol──▶ Propstreet MCP Server │ Same services & tenant isolation as REST API │ Your contacts, companies, links, properties, projects, prospects & comps ``` The MCP server is a thin layer over the same service layer that powers the REST API. Everything you can do via MCP respects the same permissions, rate limits, and data isolation as regular API calls. ## Connecting Your AI Agent ### Option 1: OAuth Auto-Connect (supported clients) Many MCP clients support OAuth discovery + Dynamic Client Registration (DCR). Point your client at: ``` https://app.propstreet.com/mcp ``` If the client supports this flow, it will: 1. Discover auth endpoints via `/.well-known/oauth-protected-resource/mcp` (the `401` from `/mcp` points there; the resource it names is `https://app.propstreet.com/mcp`) 2. Register itself automatically (Dynamic Client Registration) 3. Redirect you to Propstreet to approve access 4. Start making tool calls #### Claude (Web/Desktop) Add the connector via **Settings** → **Connectors** and use: - **URL:** `https://app.propstreet.com/mcp` > [!note] > For remote servers, Claude Desktop uses the Connectors UI. Remote MCP entries in `claude_desktop_config.json` are ignored. #### Cursor / Windsurf Add an MCP server with: - **Type:** Streamable HTTP - **URL:** `https://app.propstreet.com/mcp` The editor will prompt you to authenticate on first use. ### Option 2: Personal Access Token For quick testing or scripts, use a PAT: 1. Generate a token at **Settings** → **Personal Settings** → **Personal Access Tokens** 2. Configure your MCP client with a Bearer token header #### Claude Code (CLI) ```bash claude mcp add --transport http propstreet \ https://app.propstreet.com/mcp \ --header "Authorization: Bearer YOUR_PAT_TOKEN" ``` > [!note] > In Claude Code CLI, remote Streamable HTTP servers use `--transport http`. #### curl (legacy compatibility testing) Current MCP clients should connect to the endpoint directly and handle discovery and request metadata. The raw request below only verifies compatibility with older clients that negotiate through `initialize`: ```bash curl -X POST https://app.propstreet.com/mcp \ -H "Content-Type: application/json" \ -H "Accept: application/json, text/event-stream" \ -H "Authorization: Bearer YOUR_PAT_TOKEN" \ -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1.0.0"}}}' ``` > [!warning] > PATs have lower rate limits than OAuth clients. For production use, set up a Bot User with Client Credentials. See the [Authentication Guide](./authentication) and [Limits](./limits) for details. ### Option 3: Bot User + Client Credentials For production integrations: 1. Create a Bot User and OAuth Client at **Settings** → **Company Settings** → **Client Registrations** 2. Obtain an access token via Client Credentials flow (see [Authentication Guide](./authentication)) 3. Use the access token as a Bearer token in your MCP client configuration ## Available Tools Once connected, your AI agent can use up to 100 tools. Visibility is role-scoped: 40 tools are common to all accounts; **broker** accounts add 50 project, prospect, room, teaser and comps tools; **counterparty** accounts add up to 10 mandate, offer, and pre-market-teaser tools, depending on verification and visible invitations. Cross-role accounts see the union. ### Discovery | Tool | Description | | ------------------- | ----------------------------------------------------------------------------------------------------- | | `find` | Exact field-based search (`field:value`) across contacts, companies, and (broker) projects | | `search_network` | Semantic search across the user's Network — contacts and companies, ranked by embedding similarity | | `search_properties` | Spatial + asset-type search across properties the caller owns (tenant-scoped, not market comparables) | | `search_projects` | (broker) Spatial + filter search across projects, including linked-property attributes | | `search_mandates` | (investor) Spatial + filter search across investment strategies | ### Contacts | Tool | Description | | ----------------------- | ----------------------------------------------------------------------------------------- | | `list_contacts` | List contacts with optional country and tag filters | | `get_contact` | Get by smart ID (ID, email, name, or external ref) | | `create_contact` | Create with name, email, phone, tags, language, company, photo | | `update_contact` | Partial update (name, email, phone, tags, language, photo) | | `delete_contact` | Permanently delete (cannot be undone). Use `preview=true` for dry run | | `add_contact_task` | Add a follow-up task (call, meeting or email with a due date; optional assignee and note) | | `complete_contact_task` | Complete one task on the contact (the task's id or its `list_activities` row id) | ### Companies | Tool | Description | | ----------------------- | ----------------------------------------------------------------------------------------- | | `list_companies` | List companies with optional country and tag filters | | `get_company` | Get by smart ID (ID, name, or external ref) | | `create_company` | Create with name, country, domain, tags, strategy, logo | | `update_company` | Partial update (name, country, website, domain, tags, strategy, logo) | | `delete_company` | Permanently delete (cannot be undone). Use `preview=true` for dry run | | `add_company_task` | Add a follow-up task (call, meeting or email with a due date; optional assignee and note) | | `complete_company_task` | Complete one task on the company (the task's id or its `list_activities` row id) | ### Links | Tool | Description | | ------------- | ------------------------------------------------------------------------------------------------------------------------------------- | | `create_link` | Create or reuse a contact-company relationship using smart contact/company refs; a job title you give replaces the link's current one | | `delete_link` | Remove a contact-company relationship with optional `preview=true` dry run | ### Properties These tools reach your organisation's own properties only. A property recorded for a deal — somebody else's building — is read with `get_comp` and changed with `update_comp_facts`, `move_comp` and `set_comp_property_picture`; its id answers `not_found` here, with a message that says so. | Tool | Description | | ----------------- | ----------------------------------------------------------------------------- | | `list_properties` | List properties with optional country and asset-type filters | | `get_property` | Get by smart ID (ID or name) | | `create_property` | Create a property with description, location, tenure, and idempotency support | | `update_property` | Partial update (name, description, country, locality, coordinates, tenure) | | `delete_property` | Permanently delete (cannot be undone). Use `preview=true` for dry run | ### Comps (broker) Market transactions — the deals of your own closed projects and the deals your team recorded from the press and market contacts, each anchored on a property with a map location. A Professional-plan module: every tool answers `plan_required` in plain words for an organisation on another plan. The write tools run in conversations only; a scheduled run can read deals but never records or changes one. | Tool | Description | | --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `list_comps` | List deals with the Transactions page's filters (property type, period, source channel, place, point + radius, price, yield, party, advisor, project, free text) plus the analysis of the selection and a scope block; `sort` takes an axis with `-` for descending, as the public API does | | `get_comp` | One deal in full — property facts as of the deal and its picture (`property.pictureId`, `property.pictureUrl`), parties, sources, what it is missing, its etag and the last 20 history rows; a deal hidden from the list is found by its name too | | `create_comp` | Record a deal on an existing property record or a new one (a text address is geocoded), with price, yield, dates, parties, sources; a likely duplicate is a `conflict` listing the existing deals, and `force=true` records it anyway | | `update_comp` | Change a deal's own fields through a `{set, clear}` patch; honours `ifMatchEtag` | | `move_comp` | Move a deal to another property record, or to a new one for somebody else's building (a text address is geocoded); honours `ifMatchEtag` | | `update_comp_facts` | Correct a deal's property facts as of the deal (types and areas, land, building rights, year built, lease term, income, costs), restated in full. A deal's facts are locked when it is recorded, so `update_property` never changes them. On somebody else's building the facts are restated for this deal only; on one of your own properties only its owner or an admin may correct them (`access_denied` otherwise), and the correction applies to every deal recorded with the same facts; honours `ifMatchEtag` | | `set_comp_property_picture` | Set or remove the picture of a deal's property from a public https `pictureUrl` (fetched and stored) or a REST-uploaded `pictureFileId`; an empty `pictureFileId` removes it. Every deal on the property shows it; on one of your own properties only its owner or an admin may change it; honours `ifMatchEtag` | | `add_comp_party` | Add a seller, buyer or advisor — a contact or company in the network, or the organisation itself (`ownFirm`) | | `remove_comp_party` | Remove a party by its id; the history keeps it. `preview=true` for a dry run | | `add_comp_source` | Add an article, an activity-log note (optionally filed on a contact or company in the same call) or a contact/company as a source | | `remove_comp_source` | Remove a source by its id; the history keeps it. `preview=true` for a dry run | | `add_comp_note` | Add a note to the deal's history | | `delete_comp` | Delete a recorded deal, restorable with `restore_comp`; a deal from a closed project is corrected on the project instead. `preview=true` for a dry run | | `restore_comp` | Bring a deleted deal back with its parties, sources and history; a deal merged into another cannot be restored | | `hide_comp` | Hide a closed project's deal from the team's list and analysis (the project's owner or an admin) | | `unhide_comp` | Show a hidden deal again | | `merge_comps` | Merge two deals that are one: the kept deal takes the named fields and the other's parties and sources; the other is deleted for good (when the other is a closed project's deal, that one is kept). `preview=true` for a dry run | ### Files Uploaded documents and images, scoped to what the caller can access. | Tool | Description | | -------------- | ----------------------------------------------------------------------------------------------------------------------------------- | | `search_files` | Semantic search over uploaded documents and images; optional `projectRef` / `prospectRef` / `roomRef` / `offerRef` narrow the scope | | `list_files` | List uploaded documents (newest first) within an optional scope, without searching their contents | ### Projects (broker) Sell-side deals — open a project, add prospects, track stage, close on outcome. A closed project's transaction carries the deal the closing became — its `compId`, parties and dates — wherever the project is read (`find`, `list_projects`, `get_project`, `search_projects`). | Tool | Description | | ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `list_projects` | List with status/stage/teaser filters | | `get_project` | Get by smart ID (ID, name, or external ref) | | `create_project` | Create project with optional smart `price`, `count` and cover | | `update_project` | Update name, mandate notes, smart `price`, or cover | | `close_project` | Close as sold/withdrawn with smart deal fields; `buyerCompanyRef`, `sellerRef`, `sellerAdvisorRef`, `buyerAdvisorRef`, `signedOn`, `completedOn` and `completedIsPlanned` go on the deal the closing becomes (advisors may be a person or a company), `dealName` names it (the project's name when omitted, unless the project is still untitled), `priceCertainty` / `yieldCertainty` (`confirmed`, `reported`, `estimated`) and `priceApproximate` / `yieldApproximate` ("ca") qualify the figures, and the answer carries the deal's `compId` (empty on a plan without comps or for a closing without a sale); `priceUnknown` records a sale whose price was not disclosed (no price with it; none of these with `buyerType: none`) | | `correct_project_close` | Correct a closed project's outcome through a `{set, clear}` patch — price, yield, `priceUnknown`, their certainty and "ca", buyer type, the buyer (`buyerCompanyRef`, `buyerContactRef`, as `close_project` names it), seller and advisors, dates, property, portfolio and its property count; the deal it became follows; honours `ifMatchEtag`. `preview=true` for a dry run | | `delete_project` | Delete a draft project; restorable for 30 days with `restore_project`. Use `preview=true` for dry run | | `restore_project` | Restore a project deleted within the last 30 days, with the prospects and external refs the delete removed | ### Prospects (broker) The prospect list: the contacts and companies a broker is courting on a project. | Tool | Description | | ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | | `list_prospects` | List prospects on a project with pipeline stage filters | | `get_prospect` | Get prospect by smart ID within smart project scope | | `add_prospect` | Add contacts/companies via smart identifiers (max 100) | | `update_prospect` | Update owner, room-access expiry, tags, or external refs; owner and project tags on up to 100 prospects at once with `prospectRefs` | | `set_prospect_interest` | Record the broker's judgement (interested, maybe, not_interested) on one or many prospects, or clear it with `none` | | `override_prospect_stage` | Correct the process stage of one or many prospects, with a reason | | `remove_prospect` | Remove one or up to 100 prospect-list entries (`prospectRefs`), history kept, with optional `preview=true` | | `restore_prospect` | Put one or many removed prospects back on the prospect list, with their history; optional `preview=true` | | `classify_prospects` | Classify one or many prospects by pipeline stage (optional note, logged on each prospect as its own note entry) | | `set_prospect_contact_person` | Designate a contact person on a company-only prospect | | `add_prospect_task` | Add a follow-up task to one or many prospects (max 100; per-prospect results) | | `complete_prospect_task` | Complete one task on a prospect (its `nextTask` id or its `list_activities` row id) | | `list_prospect_projects` | List projects where a contact or company is a prospect | ### Rooms (broker) Case Rooms — the secure workspaces a broker opens for external participants (Case Rooms and Seller Portals), with document folders, access requests, reports, and — on Case Rooms — the per-prospect Q&A desk. | Tool | Description | | ----------------- | ---------------------------------------------------------------------------------------------------------- | | `list_rooms` | List the broker's rooms (Case Rooms + Seller Portals) on a project, with open-question counts | | `get_room` | Room context in one shot — members, folders/files, access requests, reports, engagement, and a Q&A rollup | | `create_room` | Open a new Case Room on a project | | `list_questions` | List a room's Q&A threads (subject, status, category, priority, assignee, prospect), with optional filters | | `get_question` | Read one Q&A thread in full — every post, including internal drafts not yet released to the prospect | | `draft_answer` | Write an internal draft answer on a thread — never sent to the prospect; a broker reviews and releases it | | `update_question` | Triage a thread — set category and/or priority, or route it to a team member | ### Teasers (broker) Curated, anonymised views of a project or its post-NDA Case Rooms. See the [Teasers guide](./teasers) for the field model and lifecycle. | Tool | Description | | ----------------------- | -------------------------------------------------------------------------------------- | | `get_project_teaser` | Read the broker-authored teaser for a project — stage, timestamps, per-field display | | `update_project_teaser` | Update a project teaser — set or clear fields, with optional `ifMatchEtag` concurrency | | `get_room_teaser` | Read the broker-authored per-room teaser inside a Case Room | | `update_room_teaser` | Update a room teaser — set or clear fields, with optional `ifMatchEtag` concurrency | ### Mandates (investor) The investor's acquisition criteria — target geography, asset types, ticket size. Used to match incoming offers. | Tool | Description | | ---------------- | --------------------------------------------------------------------------------- | | `list_mandates` | List the caller's mandates | | `get_mandate` | Get by smart ID (ID, name, or external ref) | | `create_mandate` | Create a mandate with target geography, asset types, ticket size, and idempotency | | `update_mandate` | Partial update via `set` / `clear` patch semantics | | `delete_mandate` | Permanently delete (cannot be undone). Use `preview=true` for dry run | ### Offers (counterparty) Read-only inbox of visible offers for the caller: broker-shared buying opportunities, pending invitations, and accepted Seller Portal offers where the caller is the seller. `list_offers` rows include `category` (`buying`, `invitation`, `selling`) plus lifecycle `status` so agents do not need to infer the workflow from missing fields. | Tool | Description | | ------------- | ----------------------------------------------------------------------------------------------- | | `list_offers` | List offers with optional category and status filters | | `get_offer` | Get an offer by smart ID — lifecycle detail, or a pending invitation envelope before acceptance | ### Property Teasers (investor) The investor-authored pre-market teaser on a property the caller owns — what brokers see before a property goes to market. See the [Teasers guide](./teasers). | Tool | Description | | ------------------------ | ------------------------------------------------------------------------------------------- | | `get_property_teaser` | Read the pre-market teaser on a property, or a status code when none exists yet | | `update_property_teaser` | Update the pre-market teaser — set or clear fields, with optional `ifMatchEtag` concurrency | ### Activities | Tool | Description | | -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `log_activity` | Log a note, meeting, call or email against a contact, company, prospect, or offer. Scopes are mutually exclusive: `networkRef`, `projectRef`+`prospectRef`, `projectRef` alone (a project-level note, type `note` only), or `offerRef`. News only on a contact or company (`networkRef`) | | `list_activities` | Read activities (notes, news, meetings, calls, emails, audit events) across contacts, companies, projects, prospects, and offers; a note or news item that is a source of a deal names it in `sourceOfComps` (`id`, `name`) | | `list_tasks` | List follow-up tasks across contacts, companies and prospects — yours by default; due windows `overdue`, `today`, `next_7_days`, `later`, `done7`; narrow by kind (`types`) or to one contact, company or project (`contactRef`, `companyRef`, `projectRef`) | | `cancel_task` | Cancel one follow-up task on a contact, company or prospect, named by its record and its id. A canceled task cannot be reopened | | `reopen_task` | Reopen one completed follow-up task on a contact, company or prospect, so the task is open again | | `move_task` | Move one open follow-up task to another contact, company or prospect. Answers with the task under a new id | | `edit_task` | Edit one follow-up task: restate its type, due date, assignee or note. Answers with the task under a new id | | `archive_activity` | Archive one row on a contact, company, project, prospect, or offer. Name the record with the same scope axes as `log_activity`; the id is looked up on that record only. Notes, follow-up tasks, task completions and stage corrections can be archived | | `unarchive_activity` | Restore one archived row — the undo of `archive_activity` | | `revise_note` | Replace a note with a new revision; the new revision has a new id and the old one expires | ### Profile | Tool | Description | | ------------- | ----------------------------------------------------------------------------------------------------------------- | | `get_me` | The caller's identity, capability statuses (broker/investor: active/pending/inactive), and team membership | | `update_me` | Update the caller's own user profile (name, job title, languages, theme) — for onboarding and business-card edits | | `update_team` | Update the caller's primary team profile. Requires team-admin; investor-values fields are atomic | ### Tags | Tool | Description | | ----------- | -------------------------------------------- | | `list_tags` | List distinct tags across contacts/companies | ## Preview Mode The delete tools (`delete_contact`, `delete_company`, `delete_project`, `delete_property`, `delete_mandate`, `delete_link`), `remove_prospect`, `close_project`, `correct_project_close`, `delete_comp`, `remove_comp_party`, `remove_comp_source`, `merge_comps` and `restore_prospect` accept `preview=true`. When set, the tool returns an impact summary (linked prospects, activity count, blockers) without executing the action. Other tools marked destructive — the `update_*` tools, `move_comp`, `hide_comp`, `set_comp_property_picture`, `set_prospect_contact_person`, `cancel_task`, `move_task`, `edit_task`, `archive_activity`, `revise_note`, `create_link` and the prospect judgement and stage tools — do not take `preview`. ## Prompts (Guided Workflows) The MCP server includes pre-built prompts that guide your AI agent through common multi-step workflows: | Prompt | Description | | -------------------- | -------------------------------------------------------- | | `prospect_review` | Review all prospects on a project with pipeline analysis | | `pipeline_summary` | Overview of pipeline health across all active projects | | `reconcile_contacts` | Compare Propstreet contacts against external CRM data | Use these as slash commands in your AI client (e.g., `/prospect_review` in Claude). ## Resources The server exposes a domain guide resource that MCP clients can read on connection: | Resource | Description | | ------------------------- | ----------------------------------------------------- | | `propstreet://api/schema` | Domain terminology, classifications, search operators | This helps the agent understand Propstreet concepts (projects, prospects, pipeline stages, etc.) before making tool calls. ## Example Conversations Once connected, you can ask your AI agent things like: - _"Show me all contacts tagged 'investor' that were updated this month"_ - _"Create a new project called 'Oslo Office Portfolio' with exclusive mandate"_ - _"Create a property called 'Antwerp Logistics Park' with country BE and locality Antwerp"_ - _"Add these 5 contacts as prospects to project 123"_ - _"What's the pipeline status across all my active projects?"_ - _"Find companies focused on logistics real estate in Sweden"_ - _"Show me all properties in Sweden and clear the coordinates on property 123"_ - _"Log a meeting note on contact 456 about the Q1 pipeline review"_ ## Troubleshooting ### "Unauthorized" or 401 errors - Verify your token is valid and not expired - Ensure the token has `api:public` scope - PATs expire after 30 days — regenerate if needed - A token must be issued for the MCP server (`https://app.propstreet.com/mcp`) or for Propstreet as a whole; one that is neither — such as a connection made before October 2026 — gets `401`. Your MCP client signs in again on its own; if it does not, reconnect it - Use the server URL exactly as `https://app.propstreet.com/mcp`, without a trailing slash: the OAuth resource is matched exactly ### "Forbidden" or 403 errors - Your account may lack the required permissions - Contact your organization admin to verify your role ### Rate limit errors (429) - See [Limits](./limits) for current rate limits by token type ### Tools not appearing - Ensure the MCP endpoint URL is exactly `https://app.propstreet.com/mcp` - Check that your client supports Streamable HTTP transport - Try disconnecting and reconnecting the MCP server ### Safety reminder - Only connect AI clients to MCP servers you trust - Review requested OAuth scopes before approving access - Keep only the tools enabled that your workflow requires # API Changelog > Stay up to date with API changes and improvements. All notable changes to the Propstreet Public API are documented here. This project uses [Semantic Versioning](https://semver.org/). --- ## [Unreleased] ### Added **Comps — market transactions (Professional plan):** - `GET /api/v1/comps` streams every deal your organisation knows of — the deals of your closed projects and the deals your team records — oldest change first, with `updated_since` and `include_deleted` (a deleted deal and a deal voided by a correction of its project both come back with `deletedUtc` and `removalReason`); hidden deals are in the feed with `hidden: true`. `POST /api/v1/comps:search` filters and pages through deals with the total (property type, period, source channel, a GeoJSON area (`within`) or point + radius, locality, country, area, price, price per square metre, yield, party, advisor, project; `sort`, `limit`, `offset`; every price filter in one request `currency`). `POST /api/v1/comps:summary` answers the analysis the Propstreet app shows for the same filters: counts, the latest deal, the yield and price-per-square-metre medians with what they leave out, deals and volume per quarter, the most active buyers and sellers. `GET /api/v1/comps/{id}` (with `ETag`), `POST /api/v1/comps:batchGet`, and `GET .../{id}/parties`, `.../sources`, `.../property` and `.../actions` (the history with old and new values, paged and shaped like the other action timelines: a note's text in `details`, an opaque `cursor` per entry) read a deal and its parts. See [Comps](./comps). - `POST /api/v1/comps` records a deal (idempotent with `Idempotency-Key`; a likely duplicate is answered `409` with `possibleDuplicates[]` unless `allowDuplicate` is sent), `PATCH /api/v1/comps/{id}` changes its own fields with `If-Match`, `DELETE` and `POST .../{id}:restore` delete and restore a recorded deal, `POST`/`DELETE` on `.../parties` and `.../sources` add and remove parties and sources (an `activity` source can file a new note on a contact or company in the same call) — a child POST answers `201` with the deal, its new `ETag` and the child's `Location`, a child DELETE `200` with the deal — `PUT .../{id}/property` re-anchors the deal, `PUT .../{id}/property/facts` corrects the facts as of the deal (a deal's facts are locked when it is recorded; on one of your own properties only its owner or an admin may correct them, `403` otherwise, and the correction applies to every deal recorded with the same facts), and `POST .../{id}/notes` adds a note (the child POSTs and the note take an `Idempotency-Key`); `POST .../{id}:hide` and `POST .../{id}:unhide` hide a deal from a closed project for the organisation and show it again, and `POST /api/v1/comps:merge` folds two deals that are one into one (idempotent with `Idempotency-Key`; the other deal is deleted for good). Every write lands in the deal's history with old and new values. A request a rule refuses (land with a yield, `rentalIncome` without a property type, an amount out of range) answers `422`; a malformed one `400`. Creates answer an absolute `Location`. - A deal's property may carry a picture: `PUT /api/v1/comps/{id}/property/picture` with `{ "pictureId": … }` (an image from `POST /api/v1/images`, or `null` to remove it), read back as `property.pictureId` and its download address `property.pictureUrl` on every deal on the property. The change is in each deal's history (`picture`) and emits `comp.updated` with `changed_fields: ["property"]`. - A deal's company party may carry its contact person: `contactId` next to `companyId`, with `contactName`, recorded with the party. A `contactId` of someone who works at a company is recorded as that company with the contact as its contact person; send `companyId` and `contactId` together to name the contact person yourself. The `partyId` search filter also finds the deals a contact was the contact person on. - The close and its correction take the deal's `priceCertainty`, `priceApproximate`, `yieldCertainty` and `yieldApproximate` (omitted, a range is reported and approximate, a single value confirmed), and a side sent as both a contact and a company names the company with the contact as its contact person (previously refused with `400`). - `POST /api/v1/projects/{id}/close` takes the deal's parties and dates: `buyerCompanyId`, `sellerContactId` or `sellerCompanyId`, `sellerAdvisorContactId` or `sellerAdvisorCompanyId`, `buyerAdvisorContactId` or `buyerAdvisorCompanyId`, `signedOn`, `completedOn` and `completedIsPlanned` — all optional, recorded on the deal the closing becomes — `dealName` (the deal's name, visible to your whole organisation; the project's name when omitted, unless it is still untitled), and `priceUnknown: true` for a deal whose price is not known (not with `price`). A party, date or `priceUnknown` with `buyerType: "none"` answers `422` (the certainty, "ca" and `dealName` fields are ignored there), and `completedIsPlanned` needs `completedOn` (`422`). A sale (`buyerType: "external"`) closes without a buyer too; its deal reports `missing: ["buyer"]`. - `POST /api/v1/projects/{id}/close/correct` corrects the close of a closed project — price, yield, `priceUnknown`, `buyerType`, the buyer, seller and advisors (each a contact or a company), the dates, the property and the asset — with PATCH semantics (omit a field to keep it, `null` clears it) and `If-Match`. The project's deal follows the correction; a correction to no sale voids it. An open project answers `409`. See [Projects & Prospects](./projects-prospects). - Webhooks `comp.created`, `comp.updated` (with `changed_fields`), `comp.deleted` (with `removal_reason`: `deleted` or `voided`, and `merged_into` for a deal merged into another), `comp.restored`, `comp.hidden` and `comp.unhidden`, delivered while your organisation is on the Professional plan. Closing a project as a sale emits `project.updated` and `comp.created`; editing a property emits `comp.updated` (`changed_fields: ["property"]`) for every deal on it. See [Webhooks](./webhooks). - A deal's dates are checked on every write, the close and its correction included: a completion before the signing answers `422`. No date is too far ahead — a signing, completion or planned completion in the future is recorded as sent. A close or correction that loses the race for the deal to another change answers `409` — read the project again and retry. A deal with an unknown price states its property's `rentalIncome` and `netOperatingIncome` in the property's own currency. The picture of one of your own properties is changed through a deal by the property's owner or an admin (`403` otherwise). - `POST /api/v1/comps/properties:search` finds the property a deal is about among your own properties and the properties recorded for your deals; send a hit's `propertyId` as `property.propertyId` to anchor another deal on the same record. A deal's `property` carries `propertyId` and `ownProperty` for both kinds. - Every comp route answers `403` with `/help/plan-required` for an organisation that is not on the Professional plan. - Comps are read and written for the caller's active organisation only: a member of several organisations reaches one organisation's comps at a time, under that organisation's plan; another organisation's comp answers `404`. - A closed project's `transactions[]` entry gains `compId` (the deal on `/api/v1/comps`; left out without the Professional plan or for a project of another of your organisations), `signedOn`, `completedOn`, `completedIsPlanned`, `seller`, `sellerAdvisor` and `buyerAdvisor`; `buyer`, `seller` and the advisors carry `contactId` or `companyId` when they are records in your network, and a party that is your own organisation has the new party `type` `own_firm`. It reports the parties the close recorded; a party added to the deal later is on the deal only. - MCP: seventeen comps tools for a Professional organisation, each declaring an `outputSchema` — `list_comps` and `get_comp` to read; `create_comp` (a likely duplicate is a `conflict` listing the existing deals, `force=true` records it anyway), `update_comp` (the deal's own fields), `move_comp` (to another property, or a new one from a location), `update_comp_facts` (the property facts as of the deal; on an own property, its owner or an admin only), `set_comp_property_picture` (the property's picture from a public `pictureUrl` or a REST `pictureFileId`), `add_comp_party` / `remove_comp_party`, `add_comp_source` / `remove_comp_source`, `add_comp_note`, `hide_comp` / `unhide_comp`, `merge_comps`, `delete_comp` and `restore_comp` to write. Deal fields are named in camelCase throughout (`takeFromOther: ["signedOn"]`). The writes run in conversations only, never on a scheduled run. `correct_project_close` corrects a closed project's outcome (the buyer as `buyerCompanyRef` / `buyerContactRef`, `ifMatchEtag`, `preview`); `create_comp`, `add_comp_party`, `add_comp_source` and `add_comp_note` take an `idempotencyKey`. `close_project` takes the deal's parties, dates, `dealName`, `priceUnknown`, certainty and "ca" (`priceApproximate`, `yieldApproximate`), and answers the deal's `compId` (empty without the Professional plan or for a no-sale close); `find`, `list_projects`, `get_project` and `search_projects` carry a closed project's deal on its transaction; `list_activities` names the deals an activity is a source of (`sourceOfComps`). A tool refused for the organisation's plan answers the new error code `plan_required`. - `project.close_corrected` on the project activity timeline (`GET /api/v1/projects/{id}/actions`, an `audit` row): the outcome of a closed project — price, yield, buyer type, parties, dates or property — was corrected after the close. There is no new webhook event: every correction emits `project.updated` with `transactions` in `changed_fields`, and on the Professional plan the deal's `comp.updated` (`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) reports it too. **Prospect status and restore:** - Every prospect carries `status`: `active`, `declined`, `closed`, `removed` (taken off your list with its history; it still takes notes and refuses other changes until restored) or `deleted` (listed only with `include_deleted=true`). Filter the list with `status=`. The list still returns removed prospects by default. - `POST /api/v1/projects/{projectId}/prospects/{id}/restore` and `POST /api/v1/projects/{projectId}/prospects:batchRestore` (body `{ "prospectIds": [...] }`, the answer shaped like `:batchRemove`'s) put removed prospects back on the list with their history. A prospect that is not removed is reported `unchanged`, so a retry is safe. - A restore is refused with `409` (`:batchRestore`: under `failed`) 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. - Adding a contact or company whose prospect you removed (`POST .../prospects`, `prospects:batch`) puts that prospect back — same id — instead of failing; `prospects:batch` marks it `"restored": true`. The external references you send with it are no longer refused as clashing with the ones that prospect already holds. - Re-adding a removed prospect restores it together with the `externalRefs` you send, or not at all: when another prospect of the project holds one of them, `POST .../prospects` answers `409` (duplicate external reference) and `prospects:batch` lists the item under `failed` with `error: "duplicate_external_ref"`. Any other failure to store the references likewise leaves the prospect removed, so a retry does the whole add again. `POST .../prospects` answers a restored prospect with `changeType: "update"`, and with `422` when it cannot be read back — the prospect's id in `prospectId` and a `Location` header to read it from (it never reports a made-up new prospect); a retry with the same `Idempotency-Key` then answers `409` (`idempotency-outcome-unknown`) instead of adding again. - `prospect.updated` webhooks for a removal and for a restore list `status` in `changed_fields`; a restore fires one (before, it fired none). - MCP: `restore_prospect` (one or many, with `preview`); `list_prospects` gains a `status` filter and leaves removed prospects out unless `status: "removed"`; `remove_prospect` reports an already-removed prospect as `unchanged`; `add_prospect` reports a prospect it put back as `restored`. - `type=news` on every action timeline — `GET /api/v1/network/{contacts|companies}/{id}/actions`, `GET /api/v1/projects/{id}/actions`, `GET /api/v1/projects/{projectId}/prospects/{id}/actions` and `GET /api/v1/offers/{id}/actions` — selects the news items alone. Before, `news` was not a known value and the whole timeline came back. `type=notes` is unchanged and still includes news items. **Move, restate and list follow-up tasks:** - `POST /api/v1/network/contacts/{id}/tasks/{taskId}/move`, `POST /api/v1/network/companies/{id}/tasks/{taskId}/move` and `POST /api/v1/projects/{projectId}/prospects/{id}/tasks/{taskId}/move` move a task to another contact, company or prospect. The body is the new `TaskMoveRequest`, `{ targetType, targetId, assignee }` with `targetType` as `contact` / `company` / `prospect`. A task stays on the record it was filed on, so the move files the same task — kind, due date, note and assignee — on the target and cancels the original, both or neither; the response (`200`) is the task on the target, under its own `id`. 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. A completed or canceled task, and a target that is the task's own record, return `400`; a task or target you cannot reach returns `404`. - `POST .../tasks/{taskId}/replace` on the same three routes restates a task's `type`, `dueDate`, `assignee` or `note` (the new `TaskReplaceRequest`). A field you omit keeps the task's own value; send `null` to clear the assignee or the note; name at least one field. A task is not edited in place: the response (`200`) carries the task under a **new** `id`, and the original is canceled. A replace asked twice finds its original canceled and returns `400`. - Both verbs take an optional `If-Match` header — the `etag` of the task's row on the activity list — and refuse with `412` when the task has changed since; without it, a change that lands while the request runs is refused with `409`. Send an `Idempotency-Key` header to make a retry safe: the same key for the same request returns the task the first call filed; the same key with another request is refused with `422`. - `GET /api/v1/tasks` lists follow-up tasks across contacts, companies and prospects — yours by default. Filter by `assignee` (`me`, `none` for unassigned, a team member's user id, or `any`), `window` (`overdue`, `today`, `next_7_days`, `later`, or `done7` for tasks completed in the last seven days; default the first three), `type`, `contact_id`, `company_id`, `project_id` and free text `q`; `tz` names the IANA time zone whose midnights cut the day windows (default your own); `sort` is `due` (default), `created` or `type`, prefixed `-` for descending; `page_size` 1-100 (default 50) and `cursor` page through it. Each item is the new `TaskListItem`: the `Task` fields plus `subject` — the record the task hangs off (`contact`, `company`, `prospect`, or `offer` for an offer you received) — `project` (a prospect's project), `contact`, `company`, `note`, `createdUtc` and, on completed tasks, `completedUtc`. - A task on an offer you received is listed with `subject.type: "offer"` (the offer at `/api/v1/offers/{id}`); such tasks are read-only over the API for now, and a move cannot file a task onto one (a `prospect` target is a prospect on your own project; anything else is 404). An empty filter value is ignored; `tz` takes at most 64 characters. A blank `assignee` on a move or replace is refused — send `null` (replace) to unassign, or omit it — and so is `assignee: "none"` on an add, move or replace (it is no user id; send `null` or omit it). `TaskReplaceRequest.note` advertises its 1-10000 character bound in the schema. - The `GET /api/v1/tasks` parameters publish the limits the list enforces: every repeatable filter (`assignee`, `window`, `type`, `contact_id`, `company_id`, `project_id`) carries `maxItems` 50, `q` `maxLength` 100, `tz` `maxLength` 64, and `sort` its `pattern`. A request over a limit is refused with `400`. `Task.id` (and so `ProspectTask.id` and `TaskListItem.id`) now names every verb that takes it on the task's record: complete, cancel, reopen, move and replace. - MCP: `list_tasks` (the same list: assignee, windows, `types`, one `contactRef` / `companyRef` / `projectRef`, free text) and one tool per task change — `cancel_task`, `reopen_task`, `move_task` (to another contact, company or prospect) and `edit_task` — each on a task named by its record (`networkRef`, or `projectRef` + `prospectRef`) and its id; a move or edit answers with the task under a new id. `add_contact_task`, `add_company_task`, `add_prospect_task`, `complete_contact_task`, `complete_company_task` and `complete_prospect_task` now point agents at these tools for cancelling, reopening, moving or editing a task. **Pictures — a contact's photo, a company's logo, a project's cover:** - `POST /api/v1/images` (multipart: `file` and `purpose` = `contact` / `company` / `project` — the word, as in every enum on this API; a number is refused with `400`) stores a picture and returns `{ id, width, height, contentType, url }` with `201`. `POST /api/v1/images:import` takes `{ url, purpose }` (`url` at most 2000 characters) and fetches the picture from a public https address instead (port 443, at most three redirects; you are responsible for the pictures you choose; your organisation can import a limited number per minute, past which the answer is `429`). PNG, JPEG, GIF and WebP up to 10 MB are accepted (a larger upload is answered `413`), SVG is not; the server resizes the picture to the size the Propstreet app uses for its purpose and re-encodes it, so the returned size and type are what was stored. - `profilePictureId` on contact and company create and `PATCH` (the image `id`; `null` on `PATCH` removes the picture), and `coverImageId` on project create and `PATCH`. The project response gains `coverImageUrl`. - MCP: `create_contact`, `create_company`, `create_project`, `update_contact`, `update_company` and `update_project` accept `pictureUrl` (a public https address of a picture the user chose) or `pictureFileId` (an image `id`); on the update tools an empty string removes the picture. **Bulk prospect writes — what the prospect list's selection bar does, for integrators and agents:** - `POST /api/v1/projects/{projectId}/prospects:batchUpdate` sets `interest`, `stage` (with `stageReason`), `classification`, `note`, `owner`, `addProjectTags` and `removeProjectTags` on up to 100 prospects. The answer lists every requested id once, in `updated`, `unchanged` or `failed` (`{ prospectId, error, message, partiallyUpdated }`, with `error` one of `validation_error`, `not_found_or_no_access`, `conflict` — another request changed that prospect at the same time — and `internal_error`). An owner a prospect cannot have fails that prospect, with nothing written to it, instead of a `400`. Send an `Idempotency-Key` header so a retry does not log the note twice. The stored answer includes its failures, so a failed prospect is retried as the guide's [Retrying a failed prospect](./projects-prospects#retrying-a-failed-prospect) says: a prospect whose `error` is retryable is resent in a new request under a new key. `partiallyUpdated` is `true` on a failed prospect whose earlier fields — a `note` among them — were already saved, whatever its `error`; resend only what it is missing, leaving out the note (the same key with that new body is refused with `422`). An id list holding a `null` is refused with `400`, as is a field the request does not know — on the body or inside `set` (PATCH's `projectTags`, `accessExpiry` and `externalRefs` are not batch fields). `prospects:batchRemove` and `prospects/tasks:batch` refuse an unknown field the same way. A prospect you cannot reach fails with `not_found_or_no_access`, whichever verb finds it out of reach. - `POST /api/v1/projects/{projectId}/prospects:batchRemove` removes up to 100 prospects as `POST .../prospects/{id}/remove` does, with the same answer shape; an already removed prospect is `unchanged`. - `POST /api/v1/projects/{projectId}/prospects/tasks:batch` adds the same follow-up task to up to 100 prospects and answers each prospect's new `taskId`. Accepts `Idempotency-Key`, with the same retry rule for a failed prospect as the update. - `POST /api/v1/projects/{projectId}/prospects/classify` adds a `code` field to each failed item (`validation_error`, `not_found_or_no_access`, `conflict`, `internal_error`), so a server fault can be told from a refusal; `error` still carries the reason in words. The schema marks `prospectId`, `error` and `code` required on each failed item, as every failed item has always carried them. - MCP: `update_prospect` accepts `prospectRefs` (up to 100) instead of `prospectRef` to set `owner`, `addProjectTags` and `removeProjectTags` on many prospects at once; `accessExpiry`, `tags` and `externalRefs` stay one prospect at a time. `remove_prospect` accepts `prospectRefs` too, with `preview=true`. `prospectRef` is no longer required on either tool; send exactly one of the two. The bulk answers count `updated` / `removed`, `unchanged` (already as asked, or already removed) and `failed`; a prospect another request changed at the same time fails with a message saying it is safe to retry. Each failure of a bulk `update_prospect` carries `partiallyUpdated`, `true` when the owner or tags landed before a later field failed, so the agent resends only what is missing. ### Deprecated - The prospect's `classification` field and the `classification=` list filter. They report only the last classification written through that field and are not updated by `interest` / `stage` writes; read `interest` and `stage`. MCP no longer serves `classification` on prospects, and `list_prospects` no longer takes a `classification` filter. The `classification` query parameter on `GET /api/v1/projects/{projectId}/prospects` is marked `deprecated: true` in the OpenAPI document. - The `classification` filter on `GET /api/v1/network/contacts/{id}/projects` and `GET /api/v1/network/companies/{id}/projects`, and the `prospectClassification` field on the projects they list. Both carry the same legacy single-value classification, which `interest` / `stage` writes do not update; read the prospect (`prospectId`) for its `interest` and `stage`. The filter is marked `deprecated: true` in the OpenAPI document. ### Changed - Breaking change for MCP clients: `change_task` is split into `cancel_task`, `reopen_task`, `move_task` and `edit_task`, and `update_activity` into `archive_activity`, `unarchive_activity` and `revise_note` (formerly `action: supersede`). Each tool does one thing, carries its own annotations (`reopen_task` and `unarchive_activity` are no longer marked destructive; `cancel_task`, `reopen_task`, `archive_activity` and `unarchive_activity` are `idempotentHint: true`, since a repeat leaves the row as the first call did) and declares an output schema. Each tool takes only its own action's parameters, unchanged apart from the removed `action` field (`revise_note` requires `content`). Answers: the task tools as before without `action`; `archive_activity` / `unarchive_activity` answer `activityId`, `isArchived` and `archivedAtUtc` (always present, null when active); `revise_note` answers `activityId` and `predecessorId`. - MCP: the server is its own OAuth resource, `https://app.propstreet.com/mcp` — the protected-resource metadata at `/.well-known/oauth-protected-resource/mcp` names it, and `/mcp` accepts only tokens issued for it or for Propstreet as a whole (personal access tokens, and tokens requested without a `resource`). A connection made before this change gets `401` once and signs in again; an OAuth client registered without any resource permission no longer reaches `/mcp` until one is added. - MCP tool annotations: every tool that can overwrite, clear or remove data is `destructiveHint: true` (all `update_*` tools, `cancel_task`, `move_task`, `edit_task`, `archive_activity`, `revise_note`, `create_link`, `set_prospect_interest`, `override_prospect_stage`, `classify_prospects`, `set_prospect_contact_person`); tools that geocode a named location are `openWorldHint: true`. - MCP: an unexpected server error answers `internal_error` with what to do next, instead of a bare "An error occurred"; a missing or malformed argument answers `invalid_parameter`. - MCP descriptions: `create_link`, `delete_mandate`, `get_me` and the `pictureUrl` inputs say exactly what they change and what a retry repeats; `set_prospect_contact_person` is titled "Set Prospect Contact Person"; the server instructions name only tools every caller has. - `POST /api/v1/projects/{projectId}/prospects/{id}/remove` documents its `409 Conflict`: a removal that lost a race with another change to the same prospect. Retry after re-reading the prospect. - `POST /api/v1/companies/find-duplicates` looks for similar names (the fuzzy fallback used when no company shares the LinkedIn page, domain or normalised name) among the newest 5,000 companies of your organisation; before, it read every one. Exact LinkedIn, domain and name matches still search all your companies. - Cancelling or undoing a task — and un-cancelling one or reviving its completion — through the app or MCP `archive_activity` / `unarchive_activity` now takes the same path as the task endpoints (un-cancelling a task on a contact or company that has since been deleted is `not_found`), so undoing the completion of a task that is also canceled — a pair only data written before the cancel rule can hold — is refused (`400`, "The task is canceled.") instead of reopening a task that stays canceled. - `PATCH /api/v1/network/{contacts|companies}/{id}/notes/{noteId}` and `PATCH /api/v1/projects/{projectId}/prospects/{id}/notes/{noteId}` archive and unarchive a task row or a completion through the task verbs, as the app and MCP `archive_activity` / `unarchive_activity` do: archiving the completion of a canceled task is refused (`400`, "The task is canceled."), and a lost race on a task row answers `409` in the task's words. - `POST /api/v1/projects/{projectId}/prospects/{id}/tasks/{taskId}/cancel` and `.../reopen` record the request's `Idempotency-Key` like the contact and company task verbs. - MCP: `add_contact_task` and `add_company_task` look up the contact or company before the `idempotencyKey`, and the key now names the record the reference found rather than the words of the reference, as on the API's task endpoints. A retry still replays the first answer even after `tomorrow` has come to mean another day, and two spellings of one record are one request; a retry after the record left your reach is not found, and the same reference now finding another record is refused as a conflict instead of replaying the first record's task. The task change tools (`cancel_task`, `reopen_task`, `move_task`, `edit_task`) name the task's record and a move's target the same way. - File and picture downloads carry `Content-Security-Policy: sandbox`, and a picture stored as SVG before this release is no longer served (`404`). - MCP: `remove_prospect` now removes the prospect the way the app and `POST .../prospects/{id}/remove` do: it leaves the shortlist with its timeline history kept, emits `prospect.updated` with `action.type: "removed"`, and stays readable. Before, it deleted the prospect like `DELETE .../prospects/{id}` (a `prospect.deleted` webhook and a `404` afterwards). Its preview now reports `reversible: true`. - `PATCH /api/v1/projects/{projectId}/prospects/{id}` with the owner the prospect already has writes nothing, so `updatedUtc` and the `ETag` stay as they were. - An `owner` or project-tag change on a prospect your side removed is refused (`400`, "Prospect is removed") on `PATCH .../prospects/{id}`, `prospects:batchUpdate` and MCP `update_prospect`; before, the owner of a removed prospect could still be changed. `PATCH` and MCP `update_prospect` decide this — and a project-tag list past the limit — before anything is written, so a request that also sets `externalRefs` (or, on MCP, `owner` or `tags`) no longer saves those fields and then answers `400`. - A refusal that comes from the prospect's state reads the same everywhere it can arise — "Prospect is closed", "Prospect has declined", "Prospect is removed", or that a Propstreet incoming lead awaits your answer — including on the prospect task endpoints (`POST .../prospects/{id}/tasks`, `.../move`, `.../replace`, a task moved onto a prospect from a contact or company) and every MCP tool, instead of internal wording such as "Action NextStep cannot be performed on closed offers". - MCP: the bulk `update_prospect` (`prospectRefs`) and `remove_prospect` answers count a reference that did not resolve in `failed`, so `failed` always matches the `errors` listed; a prospect that failed after earlier fields of the call were saved says so in its error. - MCP: `log_activity` describes `type=news` as news about the company or contact, and a `type=news` on a project, prospect or offer is refused with a pointer to `networkRef` on the company or contact instead of to `type=note`. - Language codes (`primaryLanguage` on contacts) accept any case and a region subtag (`sv-SE` → `sv`), and `no` for Norwegian (stored as `nb`). An unknown code is refused with `400` (`validation-failed`, listing the accepted codes) on create, `PATCH`, upsert and per item in batch upsert. - Before a teaser is first saved (`id` `"0"`), its `createdUtc` and `updatedUtc` are the creation time of the project, Case Room or property it belongs to; before, they were the time of the read, so `updatedUtc` appeared to move on every read. The `teaser` summary on a project now carries the `id`, save timestamps and `etag` of the teaser the teaser endpoint serves (before, it always read `id` `"0"`). - A teaser that belongs only to a Case Room no longer puts its project at teaser stage `drafting` — on the project's `teaser` summary and the `teaser_stage` filter — matching the teaser endpoint, which serves the project's own default until the project has a draft of its own. ### Fixed - Contact upsert (`PATCH /api/v1/network/contacts/external/{namespace}/{id}`, `POST /api/v1/network/contacts:batchUpsert`) with an unknown language code answered `200` and cleared the stored language; it is now refused with `400` like create and `PATCH`. - `profilePictureUrl` on contacts and companies was always `null`; it now carries the download URL when the record has a picture, and that URL — like the team and `/auth/me` picture URLs — downloads the picture instead of answering `404`. - File download URLs (`url` on note attachments and files, picture URLs) are `/api/v1/files/{id}` again. - **`POST /api/v1/files` in the schema is now a multipart upload with a binary `file` field.** It was described as a form-encoded object with the upload's internal properties, so generated clients could not call it. The `file` field is marked required on it and on `POST /api/v1/images`, as both endpoints always answered `400` without one; the endpoints themselves are unchanged. - **`UpdateContactRequest` in the schema now matches what the endpoint accepts.** It advertised `blocked` and an integer `profilePictureId`, neither of which `PATCH /api/v1/network/contacts/{id}` ever applied (the values were ignored). `blocked` is gone from the schema and is still ignored if sent; `profilePictureId` is now a real field and, like every id on this API, a string — a client that sends it as an integer now gets `400`. - **Batch prospect requests in the schema now state their one-item minimum.** `prospectIds` on `POST .../prospects/classify` and `items` on `POST .../prospects:batch` carry `minItems: 1`, and `items` also `maxItems: 100`, matching what the endpoints already enforced: an empty list was, and still is, refused with `400`. The new bulk endpoints declare the same bounds on `prospectIds`. - **An owner change now emits `prospect.updated`** with `changed_fields: ["owner"]`, whichever way it is made (the app, `PATCH .../prospects/{id}`, `prospects:batchUpdate`, MCP). Before, an owner-only change sent no webhook although the prospect serves its `owner`. The Changed Fields reference now also lists `project_tags`, which was already sent. --- ## [1.9.0] - September 2026 ### Added **Follow-up tasks on contacts and companies — the same dated calls, meetings and emails prospects already carry:** - `POST /api/v1/network/contacts/{id}/tasks` and `POST /api/v1/network/companies/{id}/tasks` add a task (`type` as `call` / `meeting` / `email`, `dueDate` as `today` / `tomorrow` / `7d` / an ISO date / an ISO datetime — a date without a time is due by the end of that day, UTC — plus optional `assignee` and `note`) and return it with `201`. The response is the new `Task` object, `{ id, type, dueDateUtc, assignee, overdue }`, and the body is the new `CreateTaskRequest` — field for field the prospect task's `ProspectTask` and `AddProspectTaskRequest`, which keep their names and fields. A `dueDate` in the past is accepted and records a task that is already overdue, and an entry can carry any number of open tasks. Send an `Idempotency-Key` header to make a retry safe: the same key, for the same entry and body, returns the task that was created instead of adding a second one; the same key on another entry, or with a different body, is refused with `422`. The entry is looked up first, so a retry on an entry that has since been deleted returns `404`. If the first request ended without a known outcome (its commit timed out), a retry under the same key is not run again: it returns `409` with problem type `/help/idempotency-outcome-unknown` — check the entry's tasks and send any new request with a new key. The prospect task endpoint behaves the same. - `POST .../tasks/{taskId}/complete` completes a task (`204`). Completing records who and when; a task can be completed once, and completing a canceled task is refused (`400`). - `POST .../tasks/{taskId}/cancel` cancels a task (`204`). It no longer counts as open and can no longer be completed; canceling it again is harmless. A completed task cannot be canceled (`400`) — reopen it first. - `POST .../tasks/{taskId}/reopen` undoes a task's completion (`204`), so the task is open again. A task that is not completed, or that was canceled, returns `400`. - **Task ids.** Every task verb takes either the task's `id` — the value returned when it was added — or the `id` of its `task_added` row on the entry's action list, so a caller syncing from `GET .../actions?type=tasks` can act on what it read. Either id is looked up on the contact or company in the URL only; a task that is not on that entry returns `404`. A contact id on a companies route (or the reverse) also returns `404`. - New action-timeline events. On the per-entry list `GET /api/v1/network/{contacts|companies}/{id}/actions`, a task and its completion are `type=audit` rows carrying `event: "task_added"` and `event: "task_completed"`; the note a task was filed with rides in `details.message`. `type=audit` on that list now returns every row that publishes the audit kind, task rows included, as it does on the project and prospect timelines; before, it returned only the change records. Unlike the data-change records, task rows are not immutable: cancelling a task archives its `task_added` row and reopening one archives its `task_completed` row (`details.archivedAt`, listed with `include_archived=true`); `event` tells them apart, since both kinds carry `type: "audit"`. On the rolled-up broker timelines, where a row can come from either family, the same rows are named `contact.task_added`, `contact.task_completed`, `company.task_added` and `company.task_completed`. A `task_completed` row names the task it closed in `details.taskActivityId`: the `id` of that task's `task_added` row on the same list, which the task verbs also accept. The prospect timelines carry the same field on `prospect.task_completed` rows. - `type=tasks` on the network action lists selects just the task rows. - With `include_archived=true`, a canceled task and an undone completion come back with `details.archivedAt` (when it was archived); active task rows carry none, as on the prospect timelines. - MCP: `add_contact_task`, `complete_contact_task`, `add_company_task` and `complete_company_task`, taking the same smart references the other contact and company tools take. ### Changed - The prospect task descriptions in the schema are now shared with the contact and company tasks: `ProspectTask.id` names the complete, cancel and reopen endpoints, and `AddProspectTaskRequest.assignee` asks for a member of your organisation. No field, type or required list changed. For a prospect task that is the same rule as before — the project's organisation is the organisation that acts on its prospects — but two things behave differently: an automated (bot) account is no longer accepted as an assignee on any task, and the `400` detail for an assignee outside the organisation now reads "The assignee must be a member of your team." instead of naming the project's team. - A note sent with a task is trimmed before it is stored, on prospects as on contacts and companies. - The task verbs (`complete`, `cancel`, `reopen`) on prospects, contacts and companies now document `409 Conflict`: when two verbs race on one task (a cancel against a completion), the one that commits second is refused with `409` rather than leaving the task both canceled and completed. Fetch the task again and retry. - **An `Idempotency-Key` on a prospect create or a prospect task names its target as well as the body.** `POST /api/v1/projects/{projectId}/prospects` hashes the project and the body; `POST .../prospects/{id}/tasks` hashes the prospect and the body (the project is checked before the key is looked at, so a prospect outside the project in the URL is `404`). A retry of the same call still replays the answer it got the first time; the same key sent for another project or prospect is refused with `422` instead of replaying that first answer, so a loop that reuses one key never silently skips the targets after the first. A key first sent before this release and retried after it is also refused with `422`; nothing is created twice. - MCP: `add_prospect_task`, `add_contact_task` and `add_company_task` hash their input exactly as sent — the references, the words for the due date and the rest — so a retry with the same key replays the first answer even after `tomorrow` has come to mean another day or a prospect has left the project, and two batches that refuse different references are two requests (`422` under one key). - MCP: rows written through the MCP server carry `source: "Agent"` on the contact and company action lists (`GET /api/v1/network/{contacts|companies}/{id}/actions`); they carried no source before. --- ## [1.8.0] - September 2026 ### Added **Interest and stage on prospects — the two axes beside the single `classification`:** - `interest` on the prospect response — your judgement of the prospect's appetite: `interested`, `maybe`, `not_interested`; `null` when none is recorded. `maybe` is new: it had no word in the old vocabulary. - `stage` on the prospect response — how far the process has come, derived from what has actually happened: `not_contacted`, `teaser`, `im`, `data_room`, `bid`. Never lowered by later events. - `stageOverride` on the prospect response — the latest manual stage correction, `{ stage, setBy, setAtUtc, reason }` where `setBy` is `{ id, name }`; omitted when the stage was never overridden. - `PATCH /api/v1/projects/{projectId}/prospects/{id}` accepts `interest` (send `null` to clear), `stage` (a manual correction, returned as `stageOverride`; later events can still raise it) and `stageReason` (only together with `stage`). - `DELETE /api/v1/projects/{projectId}/prospects/{id}/stage-override` withdraws the manual stage correction (`204`, also when there is none). Every correction on the prospect is withdrawn, so `stage` is derived from what has happened again and `stageOverride` is omitted. - `GET /api/v1/projects/{projectId}/prospects` accepts `interest=` (`interested`, `maybe`, `not_interested`, or `none` for prospects without a judgement) and `stage=` filters. They combine with each other and with `capability` / `tags` / `classification`. - New action-timeline events on `GET .../prospects/{id}/actions` and the project / offer timelines: `prospect.interest_changed` (an interest judgement recorded or cleared) and `prospect.stage_overridden` (a manual stage correction; `details.message` carries the reason when one was given). Both are `type=audit` rows. - MCP: `set_prospect_interest` (interest `interested` / `maybe` / `not_interested`, or `none` to clear; optional note) and `override_prospect_stage` (stage + optional reason); `list_prospects` gains `interest` and `stage` filters; `get_prospect` returns the three new fields. **Follow-up tasks on prospects — dated calls, meetings and emails you can complete, cancel and reopen:** - `nextTask` on the prospect response — the nearest-due open follow-up task, `{ id, type, dueDateUtc, assignee, overdue }`; omitted when the prospect has no open task. `type` is `call`, `meeting` or `email`; `overdue` is true once the due instant (`dueDateUtc`) has passed. - `openTaskCount` on the prospect response — how many open follow-up tasks the prospect carries. - `POST /api/v1/projects/{projectId}/prospects/{id}/tasks` adds a task (`type`, `dueDate` as `today` / `tomorrow` / `7d` / an ISO date / an ISO datetime — a date without a time is due by the end of that day, UTC — plus optional `assignee` and `note`) and returns it with `201`. A `dueDate` in the past is accepted and records a task that is already overdue. A prospect can carry any number of open tasks. 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. - `POST /api/v1/projects/{projectId}/prospects/{id}/tasks/{taskId}/complete` completes a task (`204`). Completing records who and when; a task can be completed once, and completing a canceled task is refused (`400`). - `POST /api/v1/projects/{projectId}/prospects/{id}/tasks/{taskId}/cancel` cancels a follow-up task (`204`). The task no longer counts in `nextTask` / `openTaskCount` and can no longer be completed; canceling it again is harmless. A completed task cannot be canceled (`400`); reopen it first. - `POST /api/v1/projects/{projectId}/prospects/{id}/tasks/{taskId}/reopen` undoes a task's completion (`204`), so the task is open again and can be completed again. A task that is not completed, or that was canceled, returns `400`. - **Task ids.** Every task verb accepts 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 id is looked up on the prospect in the URL only; a task that is not on this prospect returns `404`. - Canceling a task, reopening one and withdrawing a stage correction change what the prospect answers, so they also advance the prospect's `updatedUtc` and `ETag`: a delta sync on `updated_since` returns the prospect, and a stale `If-Match` is refused. - A canceled task, an undone completion and a withdrawn correction leave the default activity timeline; `include_archived=true` still returns them as `audit` rows carrying `details.archivedAt` — when the entry was archived, the same field archived notes carry. Active rows carry no `archivedAt`. - `GET /api/v1/projects/{projectId}/prospects` accepts `task_due=` (`overdue`, `today`, `this_week`) to find prospects with an open task due in that window. - New action-timeline events on `GET .../prospects/{id}/actions`: `prospect.task_added` (the note, when given, in `details.message`) and `prospect.task_completed`; `type=tasks` lists just the task rows. - MCP: `add_prospect_task` (type, due date, optional assignee and note, one or many prospects) and `complete_prospect_task` (by the task's id or its timeline id, looked up on that prospect only; `not_found` otherwise); `list_prospects` gains a `taskDue` filter; `get_prospect` returns `nextTask` and `openTaskCount`. **Project-level tags on prospects — your labels on an investor in one deal, separate from the contact's own tags:** - `projectTags` on the prospect response — your labels on this prospect within this project, for example `["Single tenant", "Follow up Q4"]`. They live on the prospect, not on the contact or company, so they never show on the same investor in another project, and they are never shown to the investor. The existing `tags` field keeps serving the contact's or company's network-wide tags. - `PATCH /api/v1/projects/{projectId}/prospects/{id}` accepts `projectTags` — the whole list (send `[]` to clear). Values are trimmed and de-duplicated (case-sensitive) and a multi-word value stays one label. `tags` is untouched. - `GET /api/v1/projects/{projectId}/prospects` accepts `project_tags=` (repeat the parameter; OR semantics, case-insensitive). It combines with every other filter, including `tags=`. - Webhooks: `prospect.updated` fires when the project tags change, with `project_tags` in `changed_fields`. - MCP: `update_prospect` gains `addProjectTags` / `removeProjectTags` (the existing `tags` param still replaces the contact's or company's network tags); `list_prospects` gains a `projectTags` filter; `get_prospect` returns `projectTags`. **Classification comments on the action timelines:** - `details.message` on `prospect.classified` rows — the comment the broker wrote with the classification. Present only when a comment was written; a classification without one keeps omitting `details` entirely. ### Changed - **New projects are created with an explicit `active` classification.** `POST /api/v1/projects` and MCP `create_project` record an `active` classification on the project as part of creating it, so the created project reads `classification: active` and appears under `classification=active` right away, and its action timeline carries the classification. A project old enough to carry no classification at all falls back to its teaser: it reads (and filters as) `active` once the teaser is published, and `draft` before that — the same answer the Propstreet app shows for it. On the associated-projects rows (`GET /api/v1/network/contacts/{id}/projects`, `GET /api/v1/network/companies/{id}/projects`) `classification` is omitted when you are not on the project's team; it is never defaulted for you. - **A note sent with a classification is now its own timeline entry.** `POST /api/v1/projects/{projectId}/prospects/classify` and MCP `classify_prospects` accept the same optional `note` as before, but the words are logged as a note on each prospect instead of being folded into the classification entry. A commented classification therefore produces **two** rows on the action timelines — a `note` row carrying the text in `details.message`, and the `prospect.classified` audit row, which no longer repeats it. The note behaves like every other prospect note: it has its own id and `ETag`, appears under `type=notes`, and can be archived or superseded through `PATCH /api/v1/projects/{projectId}/prospects/{id}/notes/{noteId}`. If you read the comment from the classification row, read it from the note row instead — entries written before this release keep the comment where it was. - **A prospect whose note could not be logged is reported as failed.** If the stage moves but the note cannot be written, the prospect is returned in `failed` with the note's reason instead of being counted in `classified`, so a partial result is never reported as a complete one. The stage that was accepted stays applied — retry the request to add the note. Prospects you can classify at all can take a note, so this is a fault path rather than an everyday outcome. - **`type=notes` now also returns commented classifications.** On `GET /api/v1/projects/{id}/actions`, `GET /api/v1/projects/{projectId}/prospects/{id}/actions`, and `GET /api/v1/offers/{id}/actions`, the note filter returns the same set the prospect's note count counts: note rows plus pipeline classifications carrying a broker comment. Such a row keeps the shape it always had — `type` stays `audit`, `event` stays `prospect.classified` — and is still returned by `type=audit`, so the two filters overlap on it. It is the same row in both: dedupe by `id` if you union the filters, and read the comment from `details.message`. - MCP: `update_activity` now names the record the row belongs to — `networkRef` for a contact or company, `projectRef` alone for a project note, `projectRef` + `prospectRef` for a prospect, or `offerRef` for an offer, the same scope `log_activity` takes. The activity id is looked up on that record only; an id from any other record is not found and nothing is changed. - **A property `name` you set through the API survives edits made in the Propstreet app.** `PATCH /api/v1/properties/{id}` and MCP `update_property` write the name that everything then reads back — an app user changing the property's price, locality or asset types no longer overwrites it with a generated label in their own language. A property first created in the app is still given a generated name of its own, and a property carrying no name yet gets one on its next app edit. ### Unchanged - **`classification` keeps its meaning and its values.** It is still the single-value classification you last wrote (`not_contacted` by default), read back exactly as written, and `classification=` still filters on it; the two new axes are additive beside it. Writing `classification` never lowers a `stage` or clears an `interest` — use the `stage` / `interest` fields for that. One addition: writing `bidder` also records `interested`, since a bid is interest. When a PATCH sends `classification` together with `interest` / `stage`, the two axes win. - **The mapping runs one way only — the two axes never write back to `classification`.** Setting `interest` or overriding `stage` leaves `classification` exactly as you last wrote it, so the two drift apart as soon as you use the axes. A prospect you classified `interested` in the spring and marked `not_interested` last week still answers `classification=interested`, and a prospect whose recorded interest is `interested` can still carry the classification `not_contacted`. Filter, report and route on `interest` and `stage`; read `classification` as the last word that was written, not as a summary of where the prospect stands today. - `prospectClassification` on the associated-projects lists (`GET /api/v1/network/contacts/{id}/projects`, `.../companies/{id}/projects`) keeps reporting the single-value classification recorded on that project; read the prospect itself for `interest` and `stage`. --- ## [1.7.0] - June 2026 ### Added **Project restore:** - `POST /api/v1/projects/{id}/restore` — restore a deleted project within 30 days of deletion. Restores the prospects and external references removed by the same delete. Returns `409` when the project is not deleted. - New webhook event `project.restored`, emitted when a deleted project is restored. See [Webhooks](./webhooks). - Deleting a project is now offered only for a true draft. A project that has been shared with investors (a teaser was communicated), or that has active Case Room members, is refused (`409`) and must be closed instead — closing preserves the full history and winds the rooms down. **Prospect owner & room-access expiry on `PATCH /api/v1/projects/{projectId}/prospects/{id}`:** - `owner` — assign a team member as the prospect's owner. Send the member's user id, or `"me"` for the caller; send `null` to unassign. Validated against the project's team (the user must be a member of the organisation that owns the project). - `accessExpiry` — schedule when the prospect's room access expires. Accepts a relative (`"7d"`, `"1w"`, `"1m"`), named (`"tomorrow"`, `"next_week"`, `"next_month"`), or ISO date/datetime value, and must be in the future. Only valid on a prospect connected to a room (otherwise `400`); send `null` to clear a scheduled expiry. - The prospect response (`ProspectPublicDto`) now includes a read-only `owner` object (`{ id, name }`) when an owner is assigned. **Optional note on batch classification:** - `POST /api/v1/projects/{projectId}/prospects/classify` accepts an optional `note`, attached to each prospect's classification action and surfaced in the prospect note column. Whitespace-only notes are rejected. **MCP Q&A Tools (broker):** - `list_questions` — list a Case Room's Q&A threads (subject, status, category, priority, assignee, prospect), with optional status and prospect filters. - `get_question` — read one thread in full: every post, including internal drafts not yet released to the prospect. Posts anchored to a room document carry a `file` object (stable file id + download path), directly usable for download. - `draft_answer` — write an internal draft answer on a thread. Drafts are never sent to the prospect — release stays a human decision in the app. - `update_question` — triage a thread: set `category` and/or `priority`, or route it to a team member via `assigneeRef`. **MCP Q&A rollups:** - `list_rooms` items now include `openQuestionCount`. - `get_room` now includes a `qa` block with per-status thread counts and the room's Q&A window. **Offer inbox categories:** - `GET /api/v1/offers` and MCP `list_offers` now include a `category` on each row: `buying`, `invitation`, or `selling`. `filter=all` returns the complete visible offer inbox across those categories. `filter=invitations` returns pending Case Room / Seller Portal invitations with `category=invitation` and `status=pending`; `filter=selling` returns accepted Seller Portal offers where the caller is the seller with `category=selling`. --- ## [1.6.0] - May 2026 ### Added **Note revisions (archive / unarchive / supersede):** - `PATCH /api/v1/network/contacts/{id}/notes/{noteId}` — archive, unarchive, or supersede a note on a contact. - `PATCH /api/v1/network/companies/{id}/notes/{noteId}` — same for a company. - `PATCH /api/v1/projects/{projectId}/prospects/{id}/notes/{noteId}` — same for a prospect note inside a project. - `PATCH /api/v1/projects/{id}/notes/{noteId}` — same for a project-level note. - Body is body-discriminated on `action`: `archive`, `unarchive`, or `supersede`. - **archive** soft-hides the note and stamps `archivedAt`. Idempotent — re-archiving an already-archived note is a no-op `204`. - **unarchive** clears `archivedAt`. Returns `400` when the note has already been superseded (unarchive the head of the chain instead). - **supersede** expires the predecessor and creates a new revision that points back at it. Send a `supersede` object with the fields to change; unset fields inherit from the predecessor. The `supersede` object also accepts `contentFormat` (`plaintext` or `markdown`) on every note type — contact, company, prospect, and project notes. - **`If-Match` is required on these four endpoints.** Unlike every other V1 `PATCH`/`DELETE` (where `If-Match` is best-effort), a missing or blank `If-Match` returns `428 Precondition Required` (`/help/precondition-required`). A stale `If-Match` still returns `412 Precondition Failed` (`/help/precondition-failed`). Fetch the resource, copy the `ETag`, then retry. A lost optimistic-concurrency race — a concurrent revision committed between your read and write — returns `409 Conflict` (`/help/concurrency-conflict`); re-fetch the `ETag` and retry. These PATCH endpoints are not replay-cached by `Idempotency-Key`; use the `ETag` precondition for retry safety. **Project notes (create):** - `POST /api/v1/projects/{id}/notes` — log a broker-authored note directly on a project, mirroring the existing prospect-note create endpoint. **Note response fields (read):** - `GET` action/note responses now expose `supersedesActivityId` (the predecessor a revision replaced), `supersededByActivityId` (the successor that replaced this note), `archivedAt` (when the note was archived; null when active), and `contentFormat` (`markdown` for markdown notes; null for plain text). - `NetworkAction.details` is now a typed OpenAPI union. Use `details.kind` (`note` or `audit`) to discriminate the payload; generated clients expose the note revision fields on the `note` details type. - The contact/company action endpoints (`GET /api/v1/network/contacts/{id}/actions`, `GET /api/v1/network/companies/{id}/actions`), the project action endpoint (`GET /api/v1/projects/{id}/actions`), and the prospect action endpoint (`GET /api/v1/projects/{projectId}/prospects/{id}/actions`) accept two new opt-in query params: `include_archived` (default `false`) surfaces archived notes alongside their `archivedAt`, and `include_superseded` (default `false`) surfaces the full revision chain with the entries linked via `supersedesActivityId` / `supersededByActivityId`. Both default off, so existing integrations see no change. **Project & prospect activity timelines (new):** - `GET /api/v1/projects/{id}/actions` and `GET /api/v1/projects/{projectId}/prospects/{id}/actions` are **new full activity timelines**. Each row carries a `type` of `note`, `chat`, or `audit` and a typed `details` union discriminated by `details.kind` (which mirrors `type`). `chat` rows are broker↔counterparty conversation entries (a message, a shared file, or a logged call) — the message body itself is private and is never returned; only a shared file is exposed (on `details.kind = chat`). `audit` rows carry a stable dot-notation `event` string (e.g. `project.created`, `prospect.accepted`, `room.file_uploaded`) — the action event vocabulary is a superset of the [webhook](./webhooks) event vocabulary, so the same names you subscribe to also appear inline on the timeline. (`chat` rows carry an `event` too, e.g. `prospect.message_sent` / `prospect.file_shared` / `prospect.call_logged`.) - New query params on both endpoints: `type` (`all` | `notes` | `chat` | `audit`, default `all`) filters by row type, and `include_rollups` (default `true`) controls cross-entity interleaving. On the project timeline `include_rollups=true` interleaves the project's prospect and Case Room activity (each rolled-up row carries a `subject` of `prospect` or `room`); on the prospect timeline it interleaves the prospect's Case Room activity and its linked contact/company notes (`subject` of `room`, `contact`, or `company`). Set `include_rollups=false` to scope the timeline to the entity's own actions (no `subject`). - The row `id` (and the `supersedesActivityId` / `supersededByActivityId` link fields) on **every** action timeline — project, prospect, offer, **and** the contact/company (`network`) endpoints — is now a **globally-unique, opaque** action id, uniform across the whole API and MCP. Treat it as opaque and pass it back verbatim — it is the same id the note PATCH endpoints accept and the same id MCP `list_activities` / `update_activity` use. **New endpoint — offer activity timeline (investor/seller-facing):** - `GET /api/v1/offers/{id}/actions` — the recipient's read of an offer's activity timeline (the counterparty view of the prospect timeline the broker sees in-app). The offer is the caller's own identity, so its Case Room activity folds in with no `subject`, and conversation entries appear as `type = chat`. Only what the recipient is entitled to see appears — broker-private notes and broker-internal events never surface here. Read-only: rows carry no `etag`. Supports the same `type`, `include_rollups`, `include_archived`, and `include_superseded` params. ### Changed - **V1 `ETag` headers are now strong (`"…"`) rather than weak (`W/"…"`).** Strong ETags are usable with `If-Match` strong comparison per [RFC 9110](https://www.rfc-editor.org/rfc/rfc9110#name-if-match). If you previously stripped a leading `W/` before sending `If-Match`, that step is no longer needed. ## [1.5.0] - May 2026 ### Added **Teasers:** - `GET /api/v1/projects/{id}/teaser` — broker-authored teaser for a project. Returns the visibility matrix (`options`), the allowed display modes per field (`template`), and the investor-facing `projection`. - `GET /api/v1/properties/{id}/teaser` — pre-market teaser for a property. Returns a thin `property_added` envelope (with `nudge.code = no_pre_market_yet`) when no pre-market record exists yet. - `GET /api/v1/projects/{id}/rooms/{roomId}/teaser` — per-room teaser inside a Case Room. Returns a virtual default room teaser plus `nudge.code = room_teaser_not_designed` when the room teaser hasn't been designed yet (the first PATCH pins a real row). - `PATCH /api/v1/projects/{id}/teaser`, `PATCH /api/v1/properties/{id}/teaser`, `PATCH /api/v1/projects/{id}/rooms/{roomId}/teaser` — design-time write surface. Standard PATCH semantics: send only the fields you want to change; omitted fields are unchanged. Auto-creates the pre-market record or room teaser on first PATCH. Free-plan callers receive clamped results. - Snapshot `tags` field — what investors see on the teaser. Send a list to write that snapshot verbatim (deduped and clamped to `template.tags`); `[]` writes an explicit empty snapshot; `null` resets the snapshot to the property's current template pool. Snapshots are byte-stable: subsequent property-tag edits do not change what investors see on an existing teaser — the author writes a new teaser to pick them up. - `If-Match` / `ETag` parity with the rest of V1 — every GET returns an `ETag` header and `etag` body field; every PATCH supports `If-Match` and returns `412 Precondition Failed` on stale; omit `If-Match` to skip the precondition check (V1 convention). - All three routes return the same teaser shape: `stage`, timestamps (`publishedUtc`, `verifiedUtc`, `communicatedUtc`), `options`, `template`, `projection`, optional `nudge`, and an `etag` field. - Stage precedence: `communicatedUtc > verifiedUtc > publishedUtc > drafting > property_added`. - Lifecycle transitions (publish, unpublish, verify, communicate) are not available via the API. Perform them in the Propstreet app. ## [1.4.0] - April 2026 ### Added **Identity & Teams:** - `GET /api/v1/auth/me` — fetch the authenticated caller's identity, capabilities, primary team, and any other teams the caller belongs to or has been invited to — all in a single call. Returns `primaryTeam: null` and `secondaryTeams: []` for callers with no active team (e.g. pre-onboarding or service accounts). - `GET /api/v1/teams/{id}` — fetch a team. Returns team details plus an inline roster (up to 50 active members) when the caller is an active member. Callers with a pending invite see the team's details without the member list. - `GET /api/v1/teams/{id}/members` — paginated list of a team's active members, filterable by `role=admin|member`. Stable keyset ordering for cursor pagination. **MCP Identity Tool:** - `get_me` — same payload as `/api/v1/auth/me`, returned as structured content so AI agents can bootstrap with a single tool call. **Offers:** - `GET /api/v1/offers` — list offers the investor has received. Filter by `all`, `active`, `ongoing`, `inactive`, `removed`, `unread`, or `unanswered`. `filter=unanswered` returns the single teaser currently awaiting a response. - `GET /api/v1/offers/{id}` — fetch a single offer with the anonymized teaser (asset categories, locality, country, price / yield / area / year ranges, zoning, tags, conversions). The broker contact card is populated after the investor accepts. Unknown or inaccessible ids return `404 Not Found`. - `teaser.title` — a short human-readable label composed from asset categories and locality in the caller's UI language (e.g. `"Industrial, Tullinge (SE)"`). Null when both are unknown. - `status`: `unanswered`, `accepted`, `rejected`, `expired`, `removed`, `closed`. - `capabilities`: `"chat"` when broker messaging is available. - Teaser range fields expose `min` / `max` (and `currency` / `scale` where applicable). Exact values appear as `min == max`. - Cursor pagination via the shared `data` + `page.{nextCursor, pageSize, hasMore}` envelope. **MCP Offer Tools:** - `list_offers` — list offers with the same filters as the REST endpoint. - `get_offer` — fetch a single offer by Smart Ref (offer id or `id=` / `ext=` selector). **Mandates:** - `/api/v1/mandates` — full CRUD for investor acquisition mandates, including cursor pagination, delta sync (`updated_since`, `include_deleted`), ETag-based concurrency, and idempotent create support. - `POST /api/v1/mandates/search` — search mandates by geography or location. - `POST /api/v1/mandates/batch/get` — fetch up to 100 mandates by id in a single call. - Mandate responses expose asset criteria (`categories`, `investmentSize`, `yearBuilt`, `parcelArea`), geographic area, `country`, and two parallel tag arrays — `positiveTags` (desirable attributes) and `negativeTags` (deal-breakers) — which are mutually exclusive. - Geographic input accepts `geography` (GeoJSON Polygon / MultiPolygon), `near` + `radius` (point and radius), or `location` (free-text locality resolved server-side). Exactly one mode is allowed per request. Write responses echo a `resolvedLocation` hint; `GET` responses do not. - Mandate responses include the standard audit/sync fields: `etag`, `changeType`, `changeOrigin`, and `deletedUtc`. - External reference support: attach third-party ids via `externalRefs` (namespace + externalId pairs) for linking mandates to records in your CRM or other systems. - `PATCH` is partial: omitted fields are unchanged; sent fields replace. Categories are full-replace. **MCP Mandate Tools:** - `list_mandates` — list mandates with optional country, category, tag, name, and `updatedSince` filters. - `search_mandates` — geography- or location-based search. - `get_mandate` — fetch a single mandate by Smart Ref (mandate id, name, `id=` / `name=` / `ext=` selector). - `create_mandate` — create a mandate with categories, ranges, tags, external refs, and geographic criteria. Supports idempotency. - `update_mandate` — partial update with the same semantics as the REST endpoint. - `delete_mandate` — delete a mandate. ### Deprecated **Project field `mandate` → `exclusivity`:** - `project.mandate` is now aliased by the new `project.exclusivity` field. Both names carry identical values (`exclusive`, `non_exclusive`, `other`) and are populated on every read response. Webhook `project.updated` events list both names in `changes` when the value changes. - Write requests (`POST /api/v1/projects`, `PATCH /api/v1/projects/{id}`) accept either field. Sending both `mandate` and `exclusivity` with non-null values on the same request is rejected with `422 validation_failed`; sending neither leaves the value unchanged. - MCP tools `create_project` and `update_project` accept only `exclusivity` on write. Reads still return both `mandate` and `exclusivity` on every project object for now. - `mandate` will be removed entirely in a future minor version. Migrate integrations to `exclusivity` now. --- ## [1.3.0] - March 2026 ### Added **Properties:** - `/api/v1/properties` - Full CRUD for property assets, including cursor pagination, delta sync (`updated_since`, `include_deleted`), ETag-based concurrency, and idempotent create support. - `GET /api/v1/projects/{id}/properties` - Returns properties linked to a project, using the same public property schema as the standalone property endpoints. - Property responses now expose audit/sync fields consistent with other versioned v1 resources: `etag`, `changeType`, `changeOrigin`, and `deletedUtc`. **Company Duplicate Detection:** - `POST /api/v1/companies/find-duplicates` — Check for potential duplicate companies before creating. Accepts `name` (required), `domain`, `countryCode`, and `linkedInUrl`. Returns matching candidates ordered by relevance (same-country matches first when `countryCode` is provided). Each result includes `id`, `name`, `domain`, and `countryCode`. **Company Fields:** - `linkedInUrl` on companies — Company LinkedIn URL, available on read (`GET`), create (`POST`), and update (`PATCH`). Searchable and tracked in audit history. **MCP Property Tools:** - `list_properties` - List broker properties with optional country/category filters. - `get_property` - Fetch a property by smart reference (`id`, `name`, `id=`, `name=`). - `create_property` - Create a property with idempotency support, description, location, and tenure fields. - `update_property` - Update name, description, country, locality, coordinates, and tenure, including coordinate clearing. **MCP Server:** - `create_company` now performs duplicate detection before creation and returns a conflict with candidate details unless `force=true`. This prevents accidental duplicate company creation by AI agents. ### Changed **Property Contract Clarifications:** - Area write payloads must include `unit: "sqm"` on `parcelArea`, `zoning.gfa`, `rentedArea`, and `vacantArea`. Read responses remain normalized to `sqm`. - Property tags are documented enum-backed snake_case values; unknown tags are rejected with `400 Validation failed` instead of being silently dropped. - Property PATCH location updates preserve tri-state semantics for nested `country` and `locality`, and late write collisions now return `409 Conflict`. **Prospect Tag Filters:** - Breaking change: `GET /api/v1/projects/{id}/prospects` now accepts repeatable `tags` query parameters with OR semantics instead of a single `tag` string. Example: `?tags=vip&tags=nordic`. **Property Tag Filters:** - Breaking change: `GET /api/v1/properties` now accepts repeatable `tags` query parameters with OR semantics instead of repeatable `tag`. Example: `?tags=core&tags=value_add`. **MCP Tool Tag Filters:** - Breaking change: MCP list tools now use array-based `tags` parameters consistently. `list_companies`, `list_contacts`, and `list_prospects` no longer accept string/CSV tag filters. See the [MCP Guide](./mcp-server) for tool-level details and the generated OpenAPI schema for the full property resource contract. --- ## [1.2.1] - February 2026 ### Changed **Contact/Company Deletion — Automatic Prospect Removal:** - `DELETE /api/v1/network/contacts/{id}` and `DELETE /api/v1/network/companies/{id}` no longer reject deletion when the entity is a prospect on active projects. Instead, the entity is automatically removed as a prospect from all active projects before deletion. - Each prospect removal fires a `prospect.updated` webhook with `action.type: "removed"`. The contact/company deletion itself fires `contact.deleted` / `company.deleted`. - Timeline history for affected projects is preserved — prospects remain visible in project activity logs. - MCP `delete_contact` / `delete_company` preview (`preview=true`) now includes `affected_projects` showing which projects will be affected. ### Fixed **Delete Webhook Payloads:** - `project.deleted` and `prospect.deleted` webhook payloads now include `external_refs` — the list of active external references at deletion time. CRM integrators need these to identify and clean up the corresponding record in their system, since `GET` returns 404 after deletion. - Prospects are now cascade-deleted when their parent project is deleted, ensuring `prospect.deleted` webhooks fire for each affected prospect. - Re-adding a previously removed prospect to a project now works correctly. Previously, the unique index blocked re-addition of deleted prospects. --- ## [1.2.0] - February 2026 ### Added **Projects & Prospects:** - `/api/v1/projects` - Full CRUD for real estate transaction projects - `/api/v1/projects/{id}/prospects` - Manage investor prospects on project prospect lists - **Unified prospects API** - Returns both prospect-list and communicated prospects in one endpoint - **Capability flags** - `communicated`, `accepted`, `chat`, `invitation` indicate prospect status - **Classification pipeline** - Track prospects: `not_contacted` → `contacted` → `interested` → `bidder` - **Filtering** - Query prospects by `capability`, `classification`, or `tag` - Cursor-based pagination with delta sync (`updated_since`, `include_deleted`) - Idempotency support via `Idempotency-Key` header - ETag-based optimistic concurrency control **Associated Projects:** - `GET /api/v1/network/contacts/{contactId}/projects` — List projects where a contact is a prospect - `GET /api/v1/network/companies/{companyId}/projects` — List projects where a company is a prospect - Cursor-based pagination with `page_size` and `cursor` parameters - Filter by `status` (open/closed) and `classification` (prospect pipeline stage) **Webhook Events:** - `project.created` - Fired when a new project is created - `project.updated` - Fired when project fields change (name, status, classification, etc.) - `project.deleted` - Fired when a project is deleted - `prospect.created` - Fired when a prospect is added to a project - `prospect.updated` - Fired when prospect classification or other fields change - `prospect.deleted` - Fired when a prospect is deleted from a project - Webhook payloads include `action` field when changes were triggered by business actions (e.g., `{"action": {"type": "classified"}}`) See the [Projects & Prospects Guide](./projects-prospects) for complete documentation. See the [Webhooks Guide](./webhooks) for webhook payload reference. **MCP Server:** - **Structured content** — All MCP tool responses now include `structuredContent` (typed JSON element, MCP 2025-06-18+) alongside the existing text `content`. Agents can parse responses programmatically without re-deserializing from text. No client-side changes required. **Link Webhook Payloads:** - Link event webhooks (`link.created`, `link.updated`, `link.deleted`) now include `contact_id` and `company_id` fields in the payload. This is critical for `link.deleted` events where the link resource is no longer retrievable via the API. - Re-creating a previously deleted link (same contact-company pair) now correctly triggers a `link.created` webhook event. Previously, reactivating a deleted link was silently treated as an update with no webhook fired. ### Changed **OpenAPI Schema Cleanup:** - Removed `Dto` suffix from schema component names: `WebhookDto` → `Webhook`, `WebhookCreatedDto` → `WebhookCreated`, `WebhookDeliveryDto` → `WebhookDelivery`, `EmailEntryRequestDto` → `EmailEntryRequest`, `PhoneEntryRequestDto` → `PhoneEntryRequest`, `CompanyLinkDto` → `CompanyLink`, `ContactLinkDto` → `ContactLink` - Added property descriptions to all public API schema components - Cleaned up internal terminology in API descriptions > **Note:** JSON property names and HTTP contracts are unchanged. If your code generator produces class names from OpenAPI schema IDs, update your generated code. --- ## [1.1.0] - February 2026 ### Added **Company Fields:** - `homepageUrl` - Company homepage URL - `domain` - Company domain, can be: - Set directly via API (e.g., `"domain": "acme-corp.com"`) when only domain is known - Auto-extracted from `homepageUrl` when URL is provided (e.g., "acme-corp.com" from "https://www.acme-corp.com/about") - When both are provided, `homepageUrl` takes precedence for domain derivation - Both fields now searchable and sortable via `/api/v1/network/companies:search` - Domain changes tracked in audit history and webhook payloads **Profile Pictures:** - `profilePictureUrl` on contacts and companies - Download URL when profile picture is set - Profile picture changes now tracked in webhook `changed_fields` as `profile_picture` **Webhooks:** - Real-time event notifications for contacts, companies, and links - 9 event types: `contact.created/updated/deleted`, `company.created/updated/deleted`, `link.created/updated/deleted` - HMAC-SHA256 signature verification via [Standard Webhooks](https://www.standardwebhooks.com/) headers (`webhook-id`, `webhook-timestamp`, `webhook-signature`) - _Note: Initially shipped as `X-Propstreet-Signature`; migrated to Standard Webhooks in v1.2.0_ - Exponential backoff retry (9 attempts over ~35 hours) - Auto-disable after 10 consecutive failures - Delivery history tracking via `/api/v1/webhooks/{id}/deliveries` - Secret regeneration endpoint - `changed_fields` reference documentation for all resource types See the [Webhooks Guide](./webhooks) for complete documentation. --- ## [1.0.0] - December 2025 ### Added **Core Resources:** - **Contacts** - Full CRUD, search, batch operations - **Companies** - Full CRUD, search, batch operations - **Links** - Contact-Company relationships - **Network Actions** - Activity notes on contacts and companies - **Files** - Document attachments (up to 100MB) **Authentication:** - **OAuth 2.0** with Client Credentials flow for production integrations - **Personal Access Tokens** for testing and scripts - **Bot Users** for dedicated integration accounts **Sync Features:** - **Delta sync** with `updated_since` and `include_deleted` - **Cursor-based pagination** (up to 500 per page) - **ETag-based concurrency** for safe updates - **External references** for CRM ID tracking **Rate Limits:** - OAuth Clients: 600 requests/minute - Personal Access Tokens: 60 requests/minute **Documentation:** - Comprehensive guides at docs.propstreet.com - Interactive API explorer - Code samples for TypeScript, C#, Python --- ## Roadmap ### Upcoming Features We're continuously improving the API. Contact [feedback@propstreet.com](mailto:feedback@propstreet.com) to share your integration needs. --- ## Deprecation Policy We follow a deprecation policy to ensure stability: 1. **Deprecation notice** - Announced in changelog and API responses 2. **Migration period** - Minimum 6 months before removal 3. **Removal** - After migration period ends We'll always provide migration guidance and support. --- ## Subscribe to Updates For API announcements: - Watch this changelog - Contact [feedback@propstreet.com](mailto:feedback@propstreet.com) to join our developer updates list # CRM Integrations Connect your existing CRM system to Propstreet and keep your contacts, companies, relationships, projects, prospects, and properties in sync. ## Supported Integrations | CRM System | Market | Sync Direction | Guide | | ----------- | --------------- | -------------- | --------------------------- | | Vitec Next | Norway | Bidirectional | [View Guide](./vitec-next) | | HubSpot | Global | Bidirectional | [View Guide](./hubspot) | | Lime CRM | Nordic (Sweden) | Bidirectional | [View Guide](./lime-crm) | | SuperOffice | Norway (Nordic) | Bidirectional | [View Guide](./superoffice) | | Pipedrive | Global | Bidirectional | [View Guide](./pipedrive) | | Salesforce | Global | Bidirectional | [View Guide](./salesforce) | ## How It Works ``` ┌─────────────┐ ┌─────────────┐ │ Your CRM │◄──────────────────►│ Propstreet │ └─────────────┘ Real-time Sync └─────────────┘ ``` 1. **Connect** - Authenticate your CRM with Propstreet 2. **Map** - We automatically map contacts and companies and define the supported project, prospect, and property sync shape for your CRM 3. **Sync** - Changes flow both directions in real-time 4. **Deduplicate** - Smart matching prevents duplicate records ## What Gets Synced ### Contacts - Name, email, phone - Company associations - Tags and categories - Notes and activity history - Profile pictures (where supported) ### Companies - Company name and identifiers - Homepage URL and domain - Country/region - Associated contacts - Company logos (where supported) ### Projects & Prospects (v1.2.0) - Real estate transaction projects (deals/opportunities) - Investor prospects on project prospect lists - Pipeline classifications (not_contacted → contacted → interested → bidder) - External references for CRM deal IDs - Real-time webhook events for project/prospect changes ### Properties (v1.3.0) - Broker property assets via `/api/v1/properties` - Linked project properties via `GET /api/v1/projects/{id}/properties` - Property location, categories, pricing, zoning, tenure, and enum-backed tags - Delta sync, optimistic concurrency, and deletion support consistent with other v1 resources ## Getting Started To enable a CRM integration for your organization: 1. Review the integration guide for your CRM 2. Contact [feedback@propstreet.com](mailto:feedback@propstreet.com) to request access 3. We'll help you configure the connection and run an initial sync ## Event Notifications Propstreet can notify your systems in real-time when data changes: - **Webhooks** — Receive HTTP POST notifications instantly when contacts, companies, links, projects, or prospects are created, updated, or deleted - **HMAC-SHA256 signatures** — Verify webhook authenticity - **Automatic retries** — Failed deliveries retry with exponential backoff See the [Webhooks Guide](../webhooks) for setup and configuration. ## Questions? - **Email**: [feedback@propstreet.com](mailto:feedback@propstreet.com) - **API Documentation**: [Authentication Guide](../authentication) # Vitec Next Integration Sync your Vitec Next (Vitec Megler) contacts and companies with Propstreet automatically. ## Overview | Feature | Support | | --------------------- | --------------------------- | | **Market** | Norway | | **Sync Direction** | Bidirectional | | **Real-time Updates** | Yes (customer-managed sync) | | **Contacts** | ✅ Full support | | **Companies** | ✅ Full support | | **Projects** | ✅ Full support (v1.2.0) | | **Prospects** | ✅ Full support (v1.2.0) | | **Properties** | ⚠️ API available (v1.3.0) | ## Data Mapping ### Contact Fields | Vitec Next | Propstreet | Notes | | ------------ | --------------- | ------------------------- | | First Name | First Name | | | Last Name | Last Name | | | Email | Email | Primary email synced | | Mobile Phone | Phone | Preferred phone number | | Categories | Tags | Mapped to Propstreet tags | | Notes | Notes | See notes section below | | Photo | Profile Picture | Via separate image API | > **Note**: Vitec Next supports multiple phone numbers. We sync the mobile number to Propstreet's phone field. ### Company Fields | Vitec Next | Propstreet | Notes | | ------------------- | ------------------ | --------------------------------------- | | Company Name | Name | | | Organization Number | External Reference | Used for matching | | Country | Country Code | ISO 2-letter code | | Website | Homepage URL | If available in custom fields | | — | Domain | Set directly or auto-extracted from URL | | Company Logo | Profile Picture | Via separate image API | > **Note**: Vitec Next's standard API may not include website fields. Check with your Vitec representative for custom field availability. ## How Sync Works ### Vitec → Propstreet You control the sync through your own integration layer: 1. Configure Vitec to send webhooks to your middleware (e.g., Zapier, Make, Azure Logic Apps, or custom service) 2. Your middleware transforms and pushes data to Propstreet via our API 3. Records are matched using email or organization number and created/updated in Propstreet This gives you full control over sync timing, filtering, and data transformation. ### Propstreet → Vitec When you update a contact in Propstreet, you have two options: **Option A: Webhooks (Recommended)** 1. Register a [Propstreet webhook](../webhooks) for `contact.*` and `company.*` events 2. Propstreet pushes changes to your endpoint in real-time 3. Your middleware transforms and pushes to Vitec Next **Option B: Polling** 1. Your integration layer polls the Propstreet API for changes (using `updated_since`) 2. Changes are transformed and pushed to Vitec Next via their API 3. Your Vitec records stay in sync Webhooks provide near real-time sync without polling overhead. ### Duplicate Handling We use smart matching to prevent duplicates: - **Contacts**: Matched by email address, then by name + phone - **Companies**: Matched by organization number (organisasjonsnummer) If we detect a potential duplicate, we update the existing record rather than creating a new one. ## Field Considerations ### Secondary Email Vitec Next supports two email addresses per contact. Propstreet syncs the primary email only; secondary emails are not synced. ### Multiple Phone Numbers Vitec Next has separate fields for mobile, home, work, and other phones. We sync the mobile number to Propstreet; other phone numbers are not synced. ### Notes vs Strategy Propstreet has two different fields for text information: - **Strategy** - Investment focus (e.g., "Office properties in Oslo, 50-200 MNOK") - **Notes** - Activity log entries (call notes, meeting summaries, etc.) How you map Vitec Next's `note` field depends on your organization's usage: - If notes contain investment criteria → map to `strategy` field - If notes contain call logs/activity → use the Notes API endpoint - If mixed usage → consider keyword-based routing in your integration ### Categories and Tags Vitec Next categories can be mapped to Propstreet tags. Define your category-to-tag mapping in your integration layer. ### Website and Domain Vitec Next's standard API may not expose website fields for companies. If your Vitec installation includes custom fields for company websites: - Sync the website URL to Propstreet's `homepageUrl` field - domain is auto-extracted - Alternatively, set Propstreet's `domain` field directly if only the domain is available - When both `homepageUrl` and `domain` are provided, `homepageUrl` takes precedence - Use the domain for company matching alongside organization number Contact your Vitec representative to determine if website fields are available in your installation. ### Profile Pictures Vitec Next uses a separate image API endpoint for photos: - Contact photos may be retrieved via the image API with a valid image ID - Company logos follow a similar pattern - Image IDs are typically stored as references in contact/company records **Sync considerations:** - Check if your contact records include an image ID field - Use Vitec's image API to retrieve photos when syncing to Propstreet - Propstreet's profile picture upload accepts standard image formats (JPEG, PNG) ## Setup Requirements To build a Vitec Next integration, you'll need: 1. **Vitec Partner Access** - Contact your Vitec representative to obtain API credentials 2. **Propstreet API Credentials** - Generate in your [account settings](https://app.propstreet.com) 3. **Integration Layer** - Middleware to handle webhooks and API calls (Zapier, Make, Azure Logic Apps, n8n, or custom) ## Getting Started 1. Generate Propstreet API credentials in your [account settings](https://app.propstreet.com) (requires login) 2. Obtain Vitec partner credentials from your Vitec representative 3. Review the [Propstreet API documentation](../../api-reference) for endpoints and data models 4. Set up your integration layer to sync data between Vitec Next and Propstreet 5. Test with a small dataset before full rollout You're responsible for building and maintaining the integration. ## Projects & Prospects (v1.2.0) Sync real estate listings between Vitec Next Estates and Propstreet Projects. Track investor prospects (spekulanter) through your pipeline with bidirectional classification sync. > **Note**: Vitec Next is a Norwegian real estate CRM. "Spekulant" (speculator) is the Norwegian term for prospective buyers/investors - this maps directly to Propstreet Prospects. ### Estate to Project Mapping | Vitec Next Field | Propstreet Project | Notes | | --------------------------- | ------------------ | ---------------------------- | | estateId | externalRefs | `namespace: "vitec-next"` | | baseInformation.projectName | name | Property/project title | | baseInformation.address | — | Street, city, zip (metadata) | | assignment.status | status | See status mapping below | | price | — | Asking price (custom field) | | dateChanged | updatedUtc | For delta sync | ### Estate Contact Relations to Prospect Mapping | Vitec Relation Type | Propstreet Prospect | Notes | | ------------------- | ------------------- | ------------------------------- | | Speculator | contactId | Via Contact external ref lookup | | Buyer | contactId | Confirmed buyer → `bidder` | | Speculator status | classification | See status mapping below | ### Estate Status to Project State Mapping Propstreet uses three separate fields for project state. Map Vitec estate statuses to the appropriate combination: | Vitec Status | Norwegian | `status` | `classification` | `teaser.stage` | Notes | | ----------------- | --------------- | -------- | ---------------- | -------------- | ---------------------- | | AssignmentAttempt | Under intag | open | draft | — | Property intake | | Coming | Kommer | open | active | published | Pre-marketing | | SoonForSale | Snart till salu | open | active | published | Coming soon | | ForSale | Till salu | open | active | communicated | Active listing | | Paused | Pausad | open | draft | — | Temporarily off-market | | Sold | Såld | closed | — | — | Transaction completed | - **`status`**: Lifecycle state (`open`, `closed`, `deleted`) — read-only, set by system - **`classification`**: Work stage (`draft`, `active`, `inactive`) — settable via API - **`teaser.stage`**: Teaser progress (`property_added`, `drafting`, `published`, `verified`, `communicated`) — read-only, set by system ### Speculator Status to Classification Mapping | Vitec Speculator Status | Code | Propstreet Classification | Notes | | ----------------------- | ---- | ------------------------- | ------------------ | | (new, no status) | — | not_contacted | Default state | | Assigned | 4 | contacted | Assigned to broker | | Interested | 3 | interested | General interest | | VeryInterested | 1 | interested | Highly interested | | BeenOnShowing | 2 | interested | Attended viewing | | FollowingBidding | 6 | interested | Monitoring bidding | | JoinedBidding | 0 | bidder | Active bidder | | NotInterested | 5 | not_interested | Declined interest | | Buyer (relation type) | — | bidder | Confirmed buyer | ### Sync Architecture **Vitec → Propstreet (using Push Notifications):** Vitec supports real-time push notifications via the Notifications API. 1. Subscribe to `Estate` and `EstateContact` events 2. On webhook, fetch full estate record and contact relations 3. Transform and push to Propstreet Projects API ```javascript // Handle Vitec webhook async function handleVitecWebhook(payload) { const { type, event, id, estateId } = payload; if (type === "Estate" && event === "Update") { const estate = await vitec.get( `/Estate/GetCommercialProperty?estateId=${id}` ); const project = await propstreet.post("/api/v1/projects", { name: estate.baseInformation.projectName, externalRefs: [{ namespace: "vitec-next", id: id }], }); } if (type === "EstateContact" && event === "Update") { // Fetch contact relation and map speculator status to classification const relations = await vitec.get( `/${customerId}/Estates/${estateId}/ContactRelations` ); // Upsert prospects with mapped classifications } } ``` **Propstreet → Vitec (using Webhooks):** 1. Register webhooks for `project.*` and `prospect.*` events 2. Look up Vitec Estate ID from `externalRefs` 3. Update Vitec via REST API (requires write permissions) ```javascript // Handle Propstreet webhook async function handlePropstreetWebhook(payload) { const vitecId = payload.data.externalRefs?.find( (ref) => ref.namespace === "vitec-next" )?.id; if (payload.event === "prospect.updated" && vitecId) { const vitecStatus = mapClassificationToVitecStatus( payload.data.classification ); // Update speculator status in Vitec } } function mapClassificationToVitecStatus(classification) { const mapping = { not_contacted: null, contacted: 4, // Assigned interested: 3, // Interested not_interested: 5, // NotInterested bidder: 0, // JoinedBidding }; return mapping[classification]; } ``` ### Webhook Events Subscribe to these Propstreet events for estate sync: | Event | Description | Vitec Action | | ---------------- | ----------------------------------- | ----------------------------- | | project.created | New project created | Create Estate (if supported) | | project.updated | Project fields modified | Update Estate | | project.deleted | Project deleted | Update status: Paused/Sold | | prospect.created | Investor added to prospect list | Add EstateContact relation | | prospect.updated | Classification changed | Update speculator status | | prospect.deleted | Investor deleted from prospect list | Remove EstateContact relation | ### Vitec Push Notification Subscription Subscribe to estate and contact events: ```json { "name": "propstreet-sync", "type": "Estate", "event": "Update", "statuses": ["ForSale", "Coming", "Sold"], "subtype": "CommercialProperty" } ``` ```json { "name": "propstreet-prospects-sync", "type": "EstateContact", "event": "Update", "subtype": "Speculator" } ``` ### Example: Create Project with Vitec Reference ```bash curl -X POST "https://app.propstreet.com/api/v1/projects" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "Oslo Commercial Property", "externalRefs": [{ "namespace": "vitec-next", "id": "OBJ11385_1395932274" }] }' ``` ### Example: Lookup Project by Vitec ID ```bash curl "https://app.propstreet.com/api/v1/projects/external/vitec-next/OBJ11385_1395932274" \ -H "Authorization: Bearer $TOKEN" ``` ### Vitec-Specific Considerations **Property Types:** - For commercial real estate, use `CommercialProperty` endpoint - Multi-unit developments use `Project` entity type - Residential properties have separate endpoints (`House`, `HousingCooperative`, etc.) **Viewing-Driven Sales:** - Property viewings ("visning") are central to Norwegian real estate - `BeenOnShowing` status indicates meaningful engagement - Consider mapping showing attendance to `interested` classification **Bidding Process:** - Norwegian bidding is transparent with `JoinedBidding` and `FollowingBidding` statuses - `FollowingBidding` contacts are engaged but not actively bidding - Both map to Propstreet `interested` or `bidder` depending on business rules **Rate Limits:** - 20 requests per second - 600 requests per minute - 10,800 requests per hour ## API References - [Propstreet Contacts API](../../api-reference#tag/contacts) - [Propstreet Companies API](../../api-reference#tag/companies) - [Propstreet Projects & Prospects Guide](../projects-prospects) - [Propstreet Webhooks Guide](../webhooks) - [Vitec Megler Hub Documentation](https://hjelp.vitecnext.no/integrations-and-api/hub/) - [Vitec Express Connect API](https://connect.maklare.vitec.net/) - [Vitec Notifications API](https://connect.maklare.vitec.net/Help/NotificationsApi) # HubSpot Integration Sync your HubSpot CRM contacts and companies with Propstreet automatically. ## Overview | Feature | Support | | --------------------- | --------------------------- | | **Market** | Global (Nordic-wide) | | **Sync Direction** | Bidirectional | | **Real-time Updates** | Yes (customer-managed sync) | | **Contacts** | ✅ Full support | | **Companies** | ✅ Full support | | **Projects** | ✅ Full support (v1.2.0) | | **Prospects** | ✅ Full support (v1.2.0) | | **Properties** | ⚠️ API available (v1.3.0) | ## Data Mapping ### Contact Fields | HubSpot | Propstreet | Notes | | --------------- | --------------- | --------------------------------- | | First Name | First Name | | | Last Name | Last Name | | | Email | Email | Primary email synced | | Phone / Mobile | Phone | Mobile preferred | | Job Title | Job Title | Synced to company connection | | Company | Connection | Links contact to company | | Lifecycle Stage | Tags | Mapped as tag (e.g., "customer") | | Avatar | Profile Picture | See profile picture section below | > **Note**: HubSpot supports additional emails. We sync the primary email to Propstreet's email field. ### Company Fields | HubSpot | Propstreet | Notes | | ------------------- | ------------------ | --------------------------------------- | | Company Name | Name | | | Website URL | Homepage URL | Full URL synced | | Domain | Domain | Set directly or auto-extracted from URL | | Country | Country Code | ISO 2-letter code | | Organization Number | External Reference | Custom property setup | | Company Logo | Profile Picture | See profile picture section below | ## How Sync Works ### HubSpot → Propstreet You control the sync through your own integration layer: 1. Configure HubSpot to send webhooks to your middleware (e.g., Zapier, Make, Azure Logic Apps, or custom service) 2. Your middleware transforms and pushes data to Propstreet via our API 3. Records are matched using email or company domain and created/updated in Propstreet This gives you full control over sync timing, filtering, and data transformation. ### Propstreet → HubSpot When you update a contact in Propstreet, you have two options: **Option A: Webhooks (Recommended)** 1. Register a [Propstreet webhook](../webhooks) for `contact.*` and `company.*` events 2. Propstreet pushes changes to your endpoint in real-time 3. Your middleware transforms and pushes to HubSpot **Option B: Polling** 1. Your integration layer polls the Propstreet API for changes (using `updated_since`) 2. Changes are transformed and pushed to HubSpot via their API 3. Your HubSpot records stay in sync Webhooks provide near real-time sync without polling overhead. ### Duplicate Handling We use smart matching to prevent duplicates: - **Contacts**: Matched by email address first, then HubSpot record ID - **Companies**: Matched by domain, then organization number (if configured) If we detect a potential duplicate, we update the existing record rather than creating a new one. ## Field Considerations ### Multiple Emails HubSpot supports additional email addresses per contact. Propstreet syncs the primary email only; additional emails remain in HubSpot but are not synced. ### Phone Numbers HubSpot has separate fields for phone and mobile phone. We prefer the mobile number for Propstreet; if mobile is empty, we use the main phone number. ### Job Title and Company HubSpot stores job title directly on the contact record. In Propstreet, job title is part of the company connection. When syncing: - The contact's job title maps to their primary company connection in Propstreet - If a contact is associated with multiple companies in HubSpot, the primary association is used ### Lifecycle Stage HubSpot's lifecycle stages (subscriber, lead, customer, etc.) are mapped to Propstreet tags. For example, a "Customer" lifecycle stage becomes a `lifecycle-customer` tag. ### Website URL and Domain HubSpot has two related company properties: - **Website URL** (`website`) - The full URL (e.g., `https://www.acme.com/about`) - **Company Domain Name** (`domain`) - The root domain only (e.g., `acme.com`) Propstreet mirrors this pattern: - **Homepage URL** (`homepageUrl`) - Maps to HubSpot's Website URL - **Domain** (`domain`) - Can be set directly or auto-extracted from Homepage URL When syncing HubSpot → Propstreet: - If HubSpot has `website`, use it for `homepageUrl` - domain is auto-extracted - If HubSpot only has `domain` (no website), set Propstreet's `domain` field directly - When both are provided in the same request, `homepageUrl` takes precedence for domain derivation When syncing Propstreet → HubSpot, set HubSpot's `website` property with the full `homepageUrl`. HubSpot will auto-populate its `domain` property from this. ### Profile Pictures **Contact avatars:** - HubSpot auto-populates contact avatars from social media (via FullContact) and Gravatar - Manually uploaded avatars are accessible via `hs_avatar_filemanager_key` - HubSpot does **not** support setting contact avatars via API - **Recommendation**: Sync Propstreet → HubSpot only for profile pictures uploaded through Propstreet's UI **Company logos:** - HubSpot stores company logos but API support is limited - Company logos cannot be reliably set via API - **Recommendation**: Manage company logos directly in each system > **Note**: Due to HubSpot API limitations, profile picture sync is one-way (Propstreet → your display layer) or manual. ### Organization Number (Nordic) HubSpot doesn't have a built-in organization number field. For Nordic markets, create a custom property in HubSpot to store: - **Norway**: Organisasjonsnummer - **Sweden**: Organisationsnummer - **Denmark**: CVR-nummer - **Finland**: Y-tunnus This enables accurate company matching via Propstreet's `externalRefs` field. ## Setup Requirements To build a HubSpot integration, you'll need: 1. **HubSpot Account** - Professional or Enterprise tier recommended for webhook support 2. **Propstreet API Credentials** - Generate in your [account settings](https://app.propstreet.com) 3. **Integration Layer** - Middleware to handle webhooks and API calls (Zapier, Make, Azure Logic Apps, n8n, or custom) 4. **HubSpot Custom Properties** - Create any additional fields you need (e.g., organization number) ## Getting Started 1. Generate Propstreet API credentials in your [account settings](https://app.propstreet.com) (requires login) 2. Review the [Propstreet API documentation](../../api-reference) for endpoints and data models 3. Set up your integration layer to sync data between HubSpot and Propstreet 4. Create any custom properties needed in HubSpot 5. Test with a small dataset before full rollout You're responsible for building and maintaining the integration. ## Frequently Asked Questions ### Which HubSpot plans support the integration? The integration works with all HubSpot plans. However, HubSpot webhook support (for real-time sync to your middleware) requires Professional or Enterprise tier. ### How often does data sync? Sync frequency depends on how you configure your integration layer: - **Real-time**: Use HubSpot webhooks (Pro/Enterprise) to trigger immediate syncs - **Scheduled**: Poll both APIs on a schedule (e.g., every 15 minutes, hourly) - **Manual**: Trigger syncs on-demand ### What happens to data if I stop syncing? Your data remains in both systems. No data is deleted. Use `externalRefs` to maintain record linkage if you resume syncing later. ### Can I choose which contacts to sync? Yes. Your integration layer controls which records to sync. Common filtering approaches: - HubSpot lists or segments - Contact properties (e.g., only sync contacts with certain tags) - Company associations ### How should I handle merge conflicts? Design your integration layer to handle conflicts. Common approaches: - **Last write wins**: Use timestamps to determine the most recent change - **Source of truth**: Designate one system as authoritative for specific fields - **Manual review**: Flag conflicts for human review ## Projects & Prospects (v1.2.0) Sync real estate deals between HubSpot Deals and Propstreet Projects. Track investor prospects through your pipeline with bidirectional classification sync. ### Deal to Project Mapping | HubSpot Deal | Propstreet Project | Notes | | ----------------------- | ------------------ | ---------------------------------- | | hs_object_id | externalRefs | `namespace: "hubspot"` | | dealname | name | Direct mapping | | dealstage | status | Map to pipeline stages (see below) | | pipeline | — | Use to filter which deals to sync | | (custom) is_portfolio | isPortfolio | Create custom boolean property | | (custom) property_count | propertyCount | Create custom number property | | createdate | createdUtc | Read-only in Propstreet | | hs_lastmodifieddate | updatedUtc | For delta sync | ### Deal-Contact Associations to Prospect Mapping | HubSpot Association | Propstreet Prospect | Notes | | ------------------- | ------------------- | --------------------------------- | | Deal-Contact link | contactId | Via Contact external ref lookup | | Deal-Company link | companyId | Alternative for company prospects | | Association label | classification | Use custom labels (see below) | ### Stage Mapping Propstreet uses three separate fields for project state. Map HubSpot deal stages to the appropriate combination: | HubSpot Stage (example) | `status` | `classification` | `teaser.stage` | Notes | | ----------------------- | -------- | ---------------- | -------------- | ------------------------ | | Draft | open | draft | — | Initial deal setup | | Marketing | open | active | published | Deal being marketed | | Under Review | open | active | communicated | Teaser sent to investors | | LOI Submitted | open | active | communicated | Active negotiations | | Due Diligence | open | active | communicated | Deep investor engagement | | Closed Won | closed | — | — | Deal completed | | Closed Lost | deleted | — | — | Deal fell through | - **`status`**: Lifecycle state (`open`, `closed`, `deleted`) — read-only, set by system - **`classification`**: Work stage (`draft`, `active`, `inactive`) — settable via API - **`teaser.stage`**: Teaser progress (`property_added`, `drafting`, `published`, `verified`, `communicated`) — read-only, set by system ### Custom Association Labels for Classifications Create custom association labels in HubSpot to track prospect classifications: ```bash # Create custom label for Deal-Contact associations POST /crm/v4/associations/deal/contact/labels { "label": "Interested Investor", "name": "interested_investor" } ``` | Propstreet Classification | Suggested HubSpot Label | | ------------------------- | ----------------------- | | not_contacted | "Not Contacted" | | contacted | "Contacted" | | interested | "Interested Investor" | | not_interested | "Not Interested" | | bidder | "Bidder" | ### Custom Properties to Create Create these custom properties in HubSpot for real estate deal sync: ```bash POST /crm/v3/properties/deals { "name": "propstreet_project_id", "label": "Propstreet Project ID", "type": "string", "fieldType": "text", "groupName": "dealinformation" } ``` **Recommended properties:** - `propstreet_project_id` — For reverse lookup - `is_portfolio` — Boolean for multi-property deals - `property_count` — Number of properties - `property_type` — Office, Retail, Industrial, etc. ### Sync Architecture **HubSpot → Propstreet (using Webhooks):** 1. Create a HubSpot private app with `crm.objects.deals.read` scope 2. Subscribe to `deal.creation`, `deal.propertyChange`, `deal.deletion` events 3. Transform and push to Propstreet Projects API 4. Store Propstreet IDs in custom properties ```javascript // Handle HubSpot webhook async function handleHubSpotWebhook(payload) { const { objectId, propertyName, propertyValue } = payload; if (payload.subscriptionType === "deal.creation") { const deal = await hubspot.get(`/crm/v3/objects/deals/${objectId}`); const project = await propstreet.post("/api/v1/projects", { name: deal.properties.dealname, externalRefs: [{ namespace: "hubspot", id: String(objectId) }], }); // Store Propstreet ID in HubSpot await hubspot.patch(`/crm/v3/objects/deals/${objectId}`, { properties: { propstreet_project_id: project.id }, }); } } ``` **Propstreet → HubSpot (using Webhooks):** 1. Register webhooks for `project.*` and `prospect.*` events 2. Look up HubSpot Deal ID from `externalRefs` 3. Update HubSpot via CRM API ```javascript // Handle Propstreet webhook async function handlePropstreetWebhook(payload) { const hubspotId = payload.data.externalRefs?.find( (ref) => ref.namespace === "hubspot" )?.id; if (payload.event === "project.updated" && hubspotId) { await hubspot.patch(`/crm/v3/objects/deals/${hubspotId}`, { properties: { dealname: payload.data.name }, }); } } ``` ### Webhook Events Subscribe to these Propstreet events for deal sync: | Event | Description | HubSpot Action | | ---------------- | ----------------------------------- | ------------------------------- | | project.created | New project created | Create Deal | | project.updated | Project fields modified | Update Deal properties | | project.deleted | Project deleted | Move to Closed Lost stage | | prospect.created | Investor added to prospect list | Create Deal-Contact association | | prospect.updated | Classification changed | Update association label | | prospect.deleted | Investor deleted from prospect list | Remove Deal-Contact association | ### Example: Create Project with HubSpot Reference ```bash curl -X POST "https://app.propstreet.com/api/v1/projects" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "Nordic Office Portfolio", "isPortfolio": true, "propertyCount": 3, "externalRefs": [{ "namespace": "hubspot", "id": "12345678" }] }' ``` ### Example: Lookup Project by HubSpot ID ```bash curl "https://app.propstreet.com/api/v1/projects/external/hubspot/12345678" \ -H "Authorization: Bearer $TOKEN" ``` ### Batch Operations Both platforms support batch operations for efficient sync: **Propstreet:** Add up to 100 prospects in a single request: ```bash POST /api/v1/projects/{id}/prospects:batch ``` **HubSpot:** Create up to 100 associations per request: ```bash POST /crm/v4/associations/deal/contact/batch/create ``` ## API References - [Propstreet Contacts API](../../api-reference#tag/contacts) - [Propstreet Companies API](../../api-reference#tag/companies) - [Propstreet Projects & Prospects Guide](../projects-prospects) - [Propstreet Webhooks Guide](../webhooks) - [HubSpot Contacts API](https://developers.hubspot.com/docs/api/crm/contacts) - [HubSpot Companies API](https://developers.hubspot.com/docs/api/crm/companies) - [HubSpot Deals API](https://developers.hubspot.com/docs/api/crm/deals) - [HubSpot Associations v4 API](https://developers.hubspot.com/docs/api/crm/associations) - [HubSpot Webhooks API](https://developers.hubspot.com/docs/api/webhooks) # Lime CRM Integration Sync your Lime CRM contacts and companies with Propstreet automatically. ## Overview | Feature | Support | | --------------------- | --------------------------- | | **Market** | Nordic (Sweden focus) | | **Sync Direction** | Bidirectional | | **Real-time Updates** | Yes (customer-managed sync) | | **Contacts** | ✅ Full support | | **Companies** | ✅ Full support | | **Projects** | ✅ Full support (v1.2.0) | | **Prospects** | ✅ Full support (v1.2.0) | | **Properties** | ⚠️ API available (v1.3.0) | ## Data Mapping ### Contact Fields (Person) | Lime CRM | Propstreet | Notes | | --------------- | --------------- | --------------------------------- | | First Name | First Name | | | Last Name | Last Name | | | Email | Email | Primary email synced | | Mobile Phone | Phone | Preferred phone number | | Job Title | Job Title | Synced to company connection | | Person↔Company | Connection | Links contact to company | | Categories/Tags | Tags | Mapped to Propstreet tags | | Notes | Notes/Strategy | See notes section below | | Photo | Profile Picture | Via Lime's image API if available | > **Note**: Lime CRM data models vary per application. Check your Lime interactive docs at `/{application}/api/v1/docs/` for exact field names. ### Company Fields | Lime CRM | Propstreet | Notes | | ------------------- | ------------------ | --------------------------------------- | | Company Name | Name | | | Organization Number | External Reference | Used for matching (Nordic markets) | | Country | Country Code | ISO 2-letter code | | Website | Homepage URL | If available in your Lime installation | | — | Domain | Set directly or auto-extracted from URL | | Company Logo | Profile Picture | Via Lime's image API if available | ## How Sync Works ### Lime → Propstreet You control the sync through your own integration layer: 1. Configure Lime Webhooks to send events to your middleware (e.g., Azure Logic Apps, n8n, or custom service) 2. Your middleware transforms and pushes data to Propstreet via our API 3. Records are matched using email or organization number and created/updated in Propstreet This gives you full control over sync timing, filtering, and data transformation. ### Propstreet → Lime When you update a contact in Propstreet, you have two options: **Option A: Webhooks (Recommended)** 1. Register a [Propstreet webhook](../webhooks) for `contact.*` and `company.*` events 2. Propstreet pushes changes to your endpoint in real-time 3. Your middleware transforms and pushes to Lime CRM **Option B: Polling** 1. Your integration layer polls the Propstreet API for changes (using `updated_since`) 2. Changes are transformed and pushed to Lime CRM via their API 3. Your Lime records stay in sync Webhooks provide near real-time sync without polling overhead. ### Duplicate Handling We use smart matching to prevent duplicates: - **Contacts**: Matched by email address first, then Lime object ID via `externalRefs` - **Companies**: Matched by organization number, then company name + country If we detect a potential duplicate, we update the existing record rather than creating a new one. ## Field Considerations ### Application-Specific Data Models Lime CRM allows custom data models per application. Before building your integration: 1. Access your Lime interactive docs at `https://{domain}/{application}/api/v1/docs/` 2. Review the available limetypes (e.g., `person`, `company`) 3. Map your specific fields to Propstreet's data model ### Person↔Company Relationships Lime CRM uses relations to link persons to companies. In Propstreet: - Each contact can have multiple company connections - Exactly one connection must be marked as `primary` - Job title is stored on the connection, not the contact When syncing, ensure you: - Create the company first (or find existing) - Link the person to the company with proper relationship data ### Notes vs Strategy Propstreet has two different fields for text information: - **Strategy** - Investment focus (e.g., "Office properties in Stockholm, 50-200 MSEK") - **Notes** - Activity log entries (call notes, meeting summaries, etc.) How you map Lime's note fields depends on your organization's usage: - If notes contain investment criteria → map to `strategy` field - If notes contain call logs/activity → use the Notes API endpoint - If mixed usage → consider keyword-based routing in your integration ### Phone Numbers Lime CRM may have multiple phone fields per person. We sync the mobile number to Propstreet; other phone numbers are not synced by default. ### Profile Pictures Lime CRM uses a separate image API for photos. If your Lime installation includes image support: - Retrieve images via Lime's file/image API - Upload to Propstreet using the profile picture endpoint - Propstreet accepts standard image formats (JPEG, PNG) ## Setup Requirements To build a Lime CRM integration, you'll need: 1. **Lime API Access** - Create a dedicated API user in Lime and generate an API key 2. **Propstreet API Credentials** - Generate in your [account settings](https://app.propstreet.com) 3. **Integration Layer** - Middleware to handle webhooks and API calls (Azure Logic Apps, n8n, or custom) 4. **Lime Webhooks Add-on** - Required for real-time sync from Lime (subscribe to person/company events) ### Lime API Key Best Practices - Create a **dedicated API user** for each external system - API keys have **no expiration date** but cannot be retrieved after creation - Store the key securely (environment variables or secret management) - The `x-api-key` header is used for authentication ## Getting Started 1. Generate Propstreet API credentials in your [account settings](https://app.propstreet.com) (requires login) 2. Create a dedicated API user in Lime CRM and generate an API key 3. Review your Lime interactive docs at `/{application}/api/v1/docs/` for available fields 4. Set up your integration layer to sync data between Lime and Propstreet 5. If using real-time sync, configure Lime Webhooks to send events to your middleware You're responsible for building and maintaining the integration. ## Frequently Asked Questions ### How do I authenticate with Lime CRM? Lime uses API keys passed via the `x-api-key` header. Each user can only have one API key, so create a dedicated API user for your integration. ### What are Lime's rate limits? For Lime CRM Cloud: **3000 requests per 5 minutes per IP**. Plan your sync frequency accordingly. ### How do I receive real-time updates from Lime? Use the Lime Webhooks Add-on. Subscribe to events like `person.new`, `person.update`, `company.delete`, etc. via the subscription API at `/api/v1/subscription/`. ### How do I validate Lime webhook signatures? Lime signs webhook payloads using HMAC SHA-256. The signature is in the `X-Lime-Signature` header as `sha256=[signature]`. Verify it against the raw request body using your webhook secret. ### What happens if my Lime data model differs from standard? Lime allows custom limetypes and fields per application. Access your interactive docs at `/{application}/api/v1/docs/` to see your exact schema, then adjust field mappings accordingly. ### How often does data sync? Sync frequency depends on how you configure your integration layer: - **Real-time**: Use Lime Webhooks + Propstreet Webhooks for immediate syncs - **Scheduled**: Poll both APIs on a schedule (e.g., every 15 minutes, hourly) - **Manual**: Trigger syncs on-demand ### How should I handle duplicates? Use stable cross-system IDs stored in Propstreet's `externalRefs`: ```json { "externalRefs": [{ "namespace": "lime", "id": "12345" }] } ``` Search by `externalRefs` first, then fall back to email or organization number matching. ## Projects & Prospects (v1.2.0) Sync real estate deals between Lime CRM Deal/Business objects and Propstreet Projects. Track investor prospects through your pipeline with bidirectional classification sync. > **Note**: Lime CRM data models are customizable per application. The entity may be called `deal` or `business` depending on your installation. Check your interactive docs at `/{application}/api/v1/docs/` for exact field names. ### Deal to Project Mapping | Lime CRM Field | Propstreet Project | Notes | | -------------- | ------------------ | ----------------------------------------- | | id | externalRefs | `namespace: "lime"` | | name | name | Direct mapping | | dealstatus | — | Use for display; see status mapping below | | value | — | Deal value (not mapped directly) | | company | — | Linked company reference | | \_timestamp | updatedUtc | For delta sync | > **Note**: Project `status` is **read-only** in Propstreet. It's auto-generated based on workflow state (whether teaser has been published, communicated, etc.). Use the mapping below for display purposes when syncing FROM Propstreet TO Lime. ### Deal-Person Relations to Prospect Mapping | Lime Concept | Propstreet Prospect | Notes | | -------------------------- | ------------------- | -------------------------------- | | Person linked to Deal | contactId | Via Contact external ref lookup | | Company linked to Deal | companyId | Via Company external ref lookup | | Status on deal-person link | classification | Map to Propstreet classification | ### Deal Status to Project State Mapping Propstreet uses three separate fields for project state. Map Lime deal statuses to the appropriate combination: | Lime `dealstatus` (suggested) | `status` | `classification` | `teaser.stage` | Notes | | ----------------------------- | -------- | ---------------- | -------------- | --------------------------------- | | contact / new | open | draft | — | Project created, not yet marketed | | requirement | open | active | published | Teaser published | | tender / offer | open | active | communicated | Teaser sent to investors | | won | closed | — | — | Deal completed | | lost | deleted | — | — | Project archived/cancelled | - **`status`**: Lifecycle state (`open`, `closed`, `deleted`) — read-only, set by system - **`classification`**: Work stage (`draft`, `active`, `inactive`) — settable via API - **`teaser.stage`**: Teaser progress (`property_added`, `drafting`, `published`, `verified`, `communicated`) — read-only, set by system ### Deal-Person Status to Classification Mapping Prospect `classification` is **read-write** and settable via API. Map Lime deal-person relation status to track investor engagement: | Lime Relation Status (typical) | Propstreet Classification | Notes | | ------------------------------ | ------------------------- | --------------------- | | (not set) | not_contacted | Default state | | contacted | contacted | Initial outreach made | | interested | interested | Positive response | | declined / inactive | not_interested | No longer engaged | | bidding / agreement | bidder | Active in process | > **Note**: Lime's pipeline stages and relation statuses are customizable per installation. Document the mapping per customer. ### Sync Architecture **Lime → Propstreet (using Webhooks):** 1. Subscribe to `deal.new`, `deal.update`, `deal.delete` events (or `business.*` depending on your installation) 2. Validate `X-Lime-Signature` header (HMAC SHA-256) 3. Fetch full deal record via `GET /limeobject/deal/{id}/` 4. Transform and push to Propstreet Projects API ```javascript // Handle Lime webhook async function handleLimeWebhook(payload) { const dealId = payload.id; if (payload.event === "deal.new") { const deal = await lime.get(`/limeobject/deal/${dealId}/`); const project = await propstreet.post("/api/v1/projects", { name: deal.name, externalRefs: [{ namespace: "lime", id: String(dealId) }], }); } } ``` **Propstreet → Lime (using Webhooks):** 1. Register webhooks for `project.*` and `prospect.*` events 2. Look up Lime Deal ID from `externalRefs` 3. Update Lime via REST API ```javascript // Handle Propstreet webhook async function handlePropstreetWebhook(payload) { const limeId = payload.data.externalRefs?.find( (ref) => ref.namespace === "lime" )?.id; if (payload.event === "project.updated" && limeId) { await lime.put(`/limeobject/deal/${limeId}/`, { name: payload.data.name, }); } } ``` ### Webhook Events Subscribe to these Propstreet events for deal sync: | Event | Description | Lime Action | | ---------------- | ----------------------------------- | --------------------------- | | project.created | New project created | Create Deal/Business | | project.updated | Project fields modified | Update Deal | | project.deleted | Project deleted | Update dealstatus: lost | | prospect.created | Investor added to prospect list | Add Deal-Person relation | | prospect.updated | Classification changed | Update relation status | | prospect.deleted | Investor deleted from prospect list | Remove Deal-Person relation | ### Example: Create Project with Lime Reference ```bash curl -X POST "https://app.propstreet.com/api/v1/projects" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "Stockholm Office Building", "externalRefs": [{ "namespace": "lime", "id": "98765" }] }' ``` ### Example: Lookup Project by Lime ID ```bash curl "https://app.propstreet.com/api/v1/projects/external/lime/98765" \ -H "Authorization: Bearer $TOKEN" ``` ### Lime-Specific Considerations **Schema Discovery Required:** - Field names vary per installation (`dealstatus` vs `status`, `deal` vs `business`) - Always check `/{application}/api/v1/docs/` before building - Consider making field mappings configurable per Lime tenant **Deal-Person Junction:** - Lime may use a separate limetype (e.g., `dealcontact`, `participant`) for deal-person relationships - Or use direct relations on the deal/person limetypes - Query approach varies: check if there's a `hasmany` relation or junction table **Rate Limits:** - Lime Cloud: **3000 requests per 5 minutes per IP** - Plan initial sync carefully; use exponential backoff ## API References - [Propstreet Contacts API](../../api-reference#tag/contacts) - [Propstreet Companies API](../../api-reference#tag/companies) - [Propstreet Projects & Prospects Guide](../projects-prospects) - [Propstreet Webhooks Guide](../webhooks) - [Lime CRM REST APIs](https://docs.lime-crm.com/integration/rest-apis/start) - [Lime CRM Limetypes](https://platform.docs.lime-crm.com/en/latest/development/tutorials/concepts/limetypes-and-limeobjects/) - [Lime Webhooks Documentation](https://platform.docs.lime-crm.com/projects/webhooks/en/latest/) - [Lime API Key Generator](https://platform.docs.lime-crm.com/en/latest/configuration/api-key-generator/) # SuperOffice Integration Sync your SuperOffice CRM contacts and companies with Propstreet automatically. ## Overview | Feature | Support | | --------------------- | --------------------------- | | **Market** | Norway (Nordic-wide) | | **Sync Direction** | Bidirectional | | **Real-time Updates** | Yes (customer-managed sync) | | **Contacts** | ✅ Full support | | **Companies** | ✅ Full support | | **Projects** | ✅ Full support (v1.2.0) | | **Prospects** | ✅ Full support (v1.2.0) | | **Properties** | ⚠️ API available (v1.3.0) | ## Data Mapping ### SuperOffice Terminology SuperOffice uses different terminology than most CRMs: | SuperOffice | Propstreet | What It Represents | | ----------- | ---------- | ------------------ | | **Person** | Contact | An individual | | **Contact** | Company | A company/firm | ### Person Fields (Individual) | SuperOffice | Propstreet | Notes | | ------------ | ------------ | ---------------------------- | | Firstname | First Name | | | Lastname | Last Name | | | Email | Email | Primary email synced | | Mobile Phone | Phone | Preferred phone number | | Title | Job Title | Synced to company connection | | ContactId | Connection | Links person to company | | Category | Tags | Mapped to Propstreet tags | | PersonId | External Ref | Used for record matching | > **Note**: SuperOffice may have multiple phone fields. We sync the mobile phone to Propstreet's phone field. ### Contact Fields (Company) | SuperOffice | Propstreet | Notes | | ----------- | ------------------ | --------------------------------------- | | Name | Name | | | OrgNr | External Reference | Used for matching (Norwegian org.nr) | | Country | Country Code | ISO 2-letter code | | URL | Homepage URL | Full website URL | | — | Domain | Set directly or auto-extracted from URL | ## How Sync Works ### SuperOffice → Propstreet You control the sync through your own integration layer: 1. Configure SuperOffice Webhooks to send events to your middleware (e.g., Azure Logic Apps, n8n, or custom service) 2. Your middleware transforms and pushes data to Propstreet via our API 3. Records are matched using email or organization number and created/updated in Propstreet This gives you full control over sync timing, filtering, and data transformation. ### Propstreet → SuperOffice When you update a contact in Propstreet, you have two options: **Option A: Webhooks (Recommended)** 1. Register a [Propstreet webhook](../webhooks) for `contact.*` and `company.*` events 2. Propstreet pushes changes to your endpoint in real-time 3. Your middleware transforms and pushes to SuperOffice **Option B: Polling** 1. Your integration layer polls the Propstreet API for changes (using `updated_since`) 2. Changes are transformed and pushed to SuperOffice via their API 3. Your SuperOffice records stay in sync Webhooks provide near real-time sync without polling overhead. ### Duplicate Handling We use smart matching to prevent duplicates: - **Persons**: Matched by email address first, then SuperOffice Person ID via `externalRefs` - **Companies**: Matched by organization number (OrgNr), then domain, then company name SuperOffice also provides built-in duplicate detection that your integration can leverage: - Domain-based duplicate checks for companies - Name-based duplicate detection - Configurable duplicate rules If we detect a potential duplicate, we update the existing record rather than creating a new one. ## Field Considerations ### Foreign Keys for Reliable Sync SuperOffice supports **foreign keys** as a first-class feature for integrations. Store Propstreet IDs in SuperOffice's foreign key fields, and store SuperOffice IDs in Propstreet's `externalRefs`: ```json { "externalRefs": [{ "namespace": "superoffice", "id": "12345" }] } ``` This enables deterministic matching without relying on name or email matching. ### Person↔Company Relationships In SuperOffice, each Person has a primary company association via `ContactId`. In Propstreet: - Each contact can have multiple company connections - Exactly one connection must be marked as `primary` - Job title is stored on the connection, not the contact When syncing, ensure you: - Create or find the company first - Link the person to the company with the appropriate job title ### Notes vs Strategy Propstreet has two different fields for text information: - **Strategy** - Investment focus (e.g., "Office properties in Oslo, 50-200 MNOK") - **Notes** - Activity log entries (call notes, meeting summaries, etc.) How you map SuperOffice's note/activity fields depends on your organization's usage: - If notes contain investment criteria → map to `strategy` field - If notes contain call logs/activity → use the Notes API endpoint ### Phone Numbers SuperOffice has multiple phone fields per person (Mobile, Direct, etc.). We sync the mobile number to Propstreet; other phone numbers are not synced by default. ### Organization Number (Norwegian) SuperOffice has native support for organization numbers via the `OrgNr` field. This is ideal for Norwegian company matching and deduplication. ## Setup Requirements To build a SuperOffice integration, you'll need: 1. **SuperOffice Account** with API access 2. **Development Tools License** - Required for webhook support (real-time sync from SuperOffice) 3. **Propstreet API Credentials** - Generate in your [account settings](https://app.propstreet.com) 4. **Integration Layer** - Middleware to handle webhooks and API calls (Azure Logic Apps, n8n, or custom) ### Webhook License Note SuperOffice webhooks require the "Development Tools" license, which is an additional cost per site. Without this license, you can still integrate using polling (checking for changes on a schedule). ### SuperOffice API Best Practices - Use the `/api/v1/{Entity}/default` endpoint to get correctly initialized entities before creating - Store external IDs using SuperOffice's **foreign keys** feature - Use duplicate detection endpoints before creating new records ## Getting Started 1. Generate Propstreet API credentials in your [account settings](https://app.propstreet.com) (requires login) 2. Set up API access in SuperOffice and configure OAuth 3. Review SuperOffice's [REST API documentation](https://docs.superoffice.com/en/api/reference/restful/rest/index.html) 4. Set up your integration layer to sync data between SuperOffice and Propstreet 5. If using real-time sync, configure SuperOffice Webhooks (requires Development Tools license) You're responsible for building and maintaining the integration. ## Frequently Asked Questions ### How do I authenticate with SuperOffice? SuperOffice uses OAuth 2.0 for authentication. Register your application in the SuperOffice Developer Portal, configure redirect URIs, and implement the standard OAuth flow. ### Do I need a special license for webhooks? Yes. SuperOffice webhooks require the "Development Tools" license. Without this license, you can use polling instead (checking for changes every 5-15 minutes). ### How do I receive real-time updates from SuperOffice? With the Development Tools license, register webhooks for events like `person.created`, `person.changed`, `contact.created`, etc. SuperOffice sends a POST to your endpoint with event details. ### How do I validate SuperOffice webhook signatures? If you configure a secret when registering the webhook, SuperOffice sends an `X-SuperOffice-Signature` header containing an HMAC SHA256 signature (base64 encoded) of the request body. ### What's the difference between Contact and Person in SuperOffice? SuperOffice uses non-standard terminology: - **Contact** = Company/Firm (what most CRMs call "Account" or "Company") - **Person** = Individual (what most CRMs call "Contact") ### How often does data sync? Sync frequency depends on how you configure your integration layer: - **Real-time**: Use SuperOffice Webhooks (requires license) + Propstreet Webhooks for immediate syncs - **Scheduled**: Poll both APIs on a schedule (e.g., every 15 minutes, hourly) - **Manual**: Trigger syncs on-demand ### How should I handle duplicates? Use stable cross-system IDs stored in Propstreet's `externalRefs` and SuperOffice's foreign keys: ```json { "externalRefs": [{ "namespace": "superoffice", "id": "12345" }] } ``` Search by `externalRefs` first, then fall back to email or organization number matching. SuperOffice also provides duplicate detection endpoints you can use before creating records. ### How should I handle merge conflicts? Design your integration layer to handle conflicts. Common approaches: - **Last write wins**: Use timestamps (`UpdatedDate` vs `updatedUtc`) to determine the most recent change - **Source of truth**: Designate one system as authoritative for specific fields - **Manual review**: Flag conflicts for human review ## Projects & Prospects (v1.2.0) Sync real estate deals between SuperOffice Sales and Propstreet Projects. Track investor prospects through your pipeline with bidirectional classification sync. > **Terminology Reminder**: SuperOffice "Contact" = Company, "Person" = Individual, "Sale" = Deal/Opportunity ### Sale to Project Mapping | SuperOffice Sale | Propstreet Project | Notes | | ---------------- | ------------------ | ----------------------------------- | | SaleId | externalRefs | `namespace: "superoffice"` | | Heading | name | Direct mapping | | Status | status | See status mapping below | | Amount | — | Deal value (custom field if needed) | | Saledate | — | Expected close date | | SaleType | — | Deal categorization | ### SaleStakeholder to Prospect Mapping | SuperOffice SaleStakeholder | Propstreet Prospect | Notes | | --------------------------- | ------------------- | -------------------------------- | | StakeholderId | externalRefs | `namespace: "superoffice"` | | PersonId | contactId | Individual investor | | ContactId | companyId | Company investor | | StakeholderRole | — | Role in the deal (metadata) | | (derived from activity) | classification | Map engagement to classification | ### Sale Status to Project State Mapping Propstreet uses three separate fields for project state. Map SuperOffice sale statuses to the appropriate combination: | SuperOffice Status | `status` | `classification` | `teaser.stage` | Notes | | ------------------ | -------- | ---------------- | -------------- | ------------------- | | Open (early stage) | open | draft | — | Preparation phase | | Open (active) | open | active | published | Active marketing | | Open (outreach) | open | active | communicated | Investor engagement | | Sold | closed | — | — | Deal completed | | Lost | deleted | — | — | Archive lost deals | | Stalled | open | draft | — | Parked/paused | - **`status`**: Lifecycle state (`open`, `closed`, `deleted`) — read-only, set by system - **`classification`**: Work stage (`draft`, `active`, `inactive`) — settable via API - **`teaser.stage`**: Teaser progress (`property_added`, `drafting`, `published`, `verified`, `communicated`) — read-only, set by system ### Stakeholder Activity to Classification Mapping | SuperOffice Activity/Stage | Propstreet Classification | Notes | | ------------------------------ | ------------------------- | ----------------- | | Stakeholder added, no activity | not_contacted | Default state | | Initial outreach made | contacted | After first touch | | Responded positively | interested | Engaged prospect | | Declined/no response | not_interested | Archived | | Submitted bid/offer | bidder | Active in process | ### Sync Architecture **SuperOffice → Propstreet (using Webhooks):** > **Note**: Webhooks require the "Development Tools" license (additional cost per site). 1. Subscribe to `sale.created`, `sale.changed`, `sale.deleted` events 2. Validate `X-SuperOffice-Signature` header (HMAC SHA256, base64 encoded) 3. Fetch full sale entity including stakeholders 4. Transform and push to Propstreet Projects API ```javascript // Handle SuperOffice webhook async function handleSuperOfficeWebhook(payload) { const saleId = payload.PrimaryKey; if (payload.Event === "sale.created") { const sale = await superoffice.get(`/api/v1/Sale/${saleId}`); const project = await propstreet.post("/api/v1/projects", { name: sale.Heading, externalRefs: [{ namespace: "superoffice", id: String(saleId) }], }); // Store Propstreet ID in SuperOffice using foreign keys await superoffice.patch(`/api/v1/Sale/${saleId}`, { ForeignKeys: [{ Application: "propstreet", Key: project.id }], }); } } ``` **Propstreet → SuperOffice (using Webhooks):** 1. Register webhooks for `project.*` and `prospect.*` events 2. Look up SuperOffice Sale ID from `externalRefs` 3. Update SuperOffice via REST API ```javascript // Handle Propstreet webhook async function handlePropstreetWebhook(payload) { const soId = payload.data.externalRefs?.find( (ref) => ref.namespace === "superoffice" )?.id; if (payload.event === "project.updated" && soId) { await superoffice.patch(`/api/v1/Sale/${soId}`, { Heading: payload.data.name, }); } } ``` **Fallback: Polling (Without Development Tools License)** ```http GET /api/v1/Sale?$filter=UpdatedDate gt 2026-02-05T10:00:00Z ``` Poll every 5-15 minutes and track checkpoint timestamp. ### Webhook Events Subscribe to these Propstreet events for deal sync: | Event | Description | SuperOffice Action | | ---------------- | ----------------------------------- | --------------------------- | | project.created | New project created | Create Sale | | project.updated | Project fields modified | Update Sale | | project.deleted | Project deleted | Set Status: Lost | | prospect.created | Investor added to prospect list | Add SaleStakeholder | | prospect.updated | Classification changed | Update stakeholder/activity | | prospect.deleted | Investor deleted from prospect list | Remove SaleStakeholder | ### Example: Create Project with SuperOffice Reference ```bash curl -X POST "https://app.propstreet.com/api/v1/projects" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "Oslo Office Building", "externalRefs": [{ "namespace": "superoffice", "id": "12345" }] }' ``` ### Example: Lookup Project by SuperOffice ID ```bash curl "https://app.propstreet.com/api/v1/projects/external/superoffice/12345" \ -H "Authorization: Bearer $TOKEN" ``` ### SuperOffice-Specific Considerations **Foreign Keys for Reliable Sync:** SuperOffice has first-class support for storing external IDs: ```json { "ForeignKeys": [ { "Application": "propstreet", "DeviceId": "integration", "Key": "project-uuid-here" } ] } ``` **Licensing:** - REST API access: Standard CRM license - Webhooks: **Development Tools license** (additional cost) - Without webhooks, use polling (5-15 minute intervals) **SaleStage MDO Lists:** - Stages are linked to `SaleType` (each sale type can have different stages) - Query via: `GET /api/v1/MDOList/salestage?additionalInfo=SaleTypeID=123` ## API References - [Propstreet Contacts API](../../api-reference#tag/contacts) - [Propstreet Companies API](../../api-reference#tag/companies) - [Propstreet Projects & Prospects Guide](../projects-prospects) - [Propstreet Webhooks Guide](../webhooks) - [SuperOffice REST API](https://docs.superoffice.com/en/api/reference/restful/rest/index.html) - [SuperOffice Person API](https://docs.superoffice.com/en/api/reference/restful/rest/Person/index.html) - [SuperOffice Contact API](https://docs.superoffice.com/en/api/reference/restful/rest/Contact/index.html) - [SuperOffice Sale API](https://docs.superoffice.com/en/api/reference/restful/rest/Sale/index.html) - [SuperOffice Webhooks](https://docs.superoffice.com/en/automation/webhook/index.html) - [SuperOffice SaleStage MDO Provider](https://docs.superoffice.com/en/api/netserver/mdo-providers/reference/salestage.html) # Pipedrive Integration Sync your Pipedrive CRM contacts and companies with Propstreet automatically. ## Overview | Feature | Support | | --------------------- | --------------------------- | | **Market** | Global (Nordic-wide) | | **Sync Direction** | Bidirectional | | **Real-time Updates** | Yes (customer-managed sync) | | **Contacts** | ✅ Full support | | **Companies** | ✅ Full support | | **Projects** | ✅ Full support (v1.2.0) | | **Prospects** | ✅ Full support (v1.2.0) | | **Properties** | ⚠️ API available (v1.3.0) | ## Data Mapping ### Contact Fields | Pipedrive | Propstreet | Notes | | ---------------- | --------------- | ------------------------------- | | Name | First/Last Name | Parsed from single name field | | Email | Email | Primary email synced | | Phone | Phone | Primary phone synced | | Organization | Connection | Links contact to company | | Job Title | Job Title | Synced to company connection | | Labels | Tags | Mapped as tags | | Notes | Notes | Via Notes API | | Custom: Strategy | Strategy | Investment focus (custom field) | > **Note**: Pipedrive supports multiple emails and phones per person. We sync the primary value to Propstreet's single-value fields. ### Company Fields | Pipedrive | Propstreet | Notes | | ------------------ | ------------------ | ------------------------ | | Name | Name | | | Address (country) | Country Code | ISO 2-letter code | | Custom: Org Number | External Reference | For Nordic deduplication | | Custom: Domain | Domain | For company matching | ## How Sync Works ### Pipedrive → Propstreet You control the sync through your own integration layer: 1. Configure Pipedrive Webhooks v2 to send events to your middleware (e.g., Zapier, Make, Azure Logic Apps, n8n, or custom service) 2. Your middleware transforms and pushes data to Propstreet via our API 3. Records are matched using email or company domain and created/updated in Propstreet Pipedrive Webhooks v2 provide near real-time delivery with retry logic—any 2XX response counts as successful delivery. ### Propstreet → Pipedrive When you update a contact in Propstreet, you have two options: **Option A: Webhooks (Recommended)** 1. Register a [Propstreet webhook](../webhooks) for `contact.*` and `company.*` events 2. Propstreet pushes changes to your endpoint in real-time 3. Your middleware transforms and pushes to Pipedrive **Option B: Polling** 1. Your integration layer polls the Propstreet API for changes (using `updated_since`) 2. Changes are transformed and pushed to Pipedrive via their API 3. Your Pipedrive records stay in sync Propstreet recommends polling every 5–15 minutes for delta sync. ### Duplicate Handling We use smart matching to prevent duplicates: - **Contacts**: Matched by email address first, then Pipedrive person ID via `externalRefs` - **Companies**: Matched by domain, then organization number (if configured) Store Pipedrive IDs in Propstreet's `externalRefs` array and Propstreet IDs in Pipedrive custom fields for reliable bidirectional sync. ## Field Considerations ### Multiple Emails and Phones Pipedrive supports multiple emails and phone numbers per person. Propstreet syncs the primary value only; additional values remain in Pipedrive but are not synced. ### Job Title and Organization Pipedrive stores persons with an organization association. In Propstreet, job title is part of the company connection. When syncing: - The person's organization maps to a company connection in Propstreet - Job title (if stored as custom field or note) maps to the connection's job title field ### Notes vs Strategy Pipedrive has a notes feature for activity logging. For commercial real estate investors: - **Investment criteria** ("Looking for office buildings, €10M+") → sync to Propstreet's `strategy` field via a custom Pipedrive field - **Activity logs** ("Called Jan 15, discussed pipeline") → sync to Propstreet's Notes API Create a custom Pipedrive field `investor_strategy` to separate investment focus from activity notes. ### Website and Domain Pipedrive organizations don't have a native website field. For company matching: - Create a custom field `domain` in Pipedrive to store the company's web domain - Use this for deduplication alongside organization number ### Organization Number (Nordic) Pipedrive doesn't have a built-in organization number field. For Nordic markets, create a custom field in Pipedrive to store: - **Norway**: Organisasjonsnummer - **Sweden**: Organisationsnummer - **Denmark**: CVR-nummer - **Finland**: Y-tunnus This enables accurate company matching via Propstreet's `externalRefs` field. ## Setup Requirements To build a Pipedrive integration, you'll need: 1. **Pipedrive Account** - Any plan; Advanced or higher recommended for more API tokens 2. **Propstreet API Credentials** - Generate in your [account settings](https://app.propstreet.com) 3. **Integration Layer** - Middleware to handle webhooks and API calls (Zapier, Make, Azure Logic Apps, n8n, or custom) 4. **Pipedrive Custom Fields** - Create fields for: `propstreet_contact_id`, `propstreet_company_id`, `investor_strategy`, `domain`, and optionally `org_number` ## Getting Started 1. Generate Propstreet API credentials in your [account settings](https://app.propstreet.com) (requires login) 2. Review the [Propstreet API documentation](../../api-reference) for endpoints and data models 3. Set up your integration layer to sync data between Pipedrive and Propstreet 4. Create custom fields in Pipedrive for Propstreet IDs and investor strategy 5. Configure Pipedrive Webhooks v2 for person and organization events 6. Test with a small dataset before full rollout You're responsible for building and maintaining the integration. ## Frequently Asked Questions ### Which Pipedrive plans support the integration? The integration works with all Pipedrive plans. However, API token budgets vary by plan—Advanced and higher tiers provide more daily API tokens. ### How does Pipedrive rate limiting work? Pipedrive uses token-based rate limiting with daily budgets (30,000 base tokens × plan multiplier × seats) and burst limits (10 requests per 2 seconds for Search API). Design your integration to: - Use webhooks instead of polling where possible - Avoid heavy Search API usage—use stable IDs and a mapping database instead - Implement exponential backoff on 429 responses ### How often does data sync? Sync frequency depends on how you configure your integration layer: - **Real-time**: Use Pipedrive Webhooks v2 to trigger immediate syncs - **Scheduled**: Poll Propstreet API every 5–15 minutes (using `updated_since`) - **Manual**: Trigger syncs on-demand ### What happens to data if I stop syncing? Your data remains in both systems. No data is deleted. Use `externalRefs` to maintain record linkage if you resume syncing later. ### Can I choose which contacts to sync? Yes. Your integration layer controls which records to sync. Common filtering approaches: - Pipedrive filters or custom fields - Labels (e.g., only sync persons with "Investor" label) - Organization associations ### How should I handle merge conflicts? Design your integration layer to handle conflicts. Common approaches: - **Last write wins**: Compare `updatedUtc` (Propstreet) and `update_time` (Pipedrive) to determine the most recent change - **Source of truth**: Designate one system as authoritative for specific fields (e.g., CRM for identity, Propstreet for investment strategy) - **Manual review**: Flag conflicts for human review Propstreet supports ETags for optimistic concurrency—use `If-Match` headers to prevent lost updates. ## Projects & Prospects (v1.2.0) Sync real estate deals between Pipedrive Deals and Propstreet Projects. Track investor prospects through your pipeline with bidirectional classification sync. ### Deal to Project Mapping | Pipedrive Deal | Propstreet Project | Notes | | ----------------------- | ------------------ | ---------------------------------- | | id | externalRefs | `namespace: "pipedrive"` | | title | name | Direct mapping | | stage_id | status | Map to pipeline stages (see below) | | pipeline_id | — | Use to filter which deals to sync | | status (open/won/lost) | status | won/lost → closed/deleted | | (custom) is_portfolio | isPortfolio | Create custom field | | (custom) property_count | propertyCount | Create custom field | | add_time | createdUtc | Read-only in Propstreet | | update_time | updatedUtc | For delta sync | ### Deal Participants to Prospect Mapping | Pipedrive | Propstreet Prospect | Notes | | ----------------------- | ------------------- | -------------------------------- | | person_id (primary) | contactId | Primary contact becomes prospect | | Participant person_id | contactId | Each participant → prospect | | org_id | companyId | Alternative for company-level | | (custom) classification | classification | Track via custom field | > **Note**: Pipedrive webhooks v2 do **not** trigger on participant changes. Poll the participants endpoint every 5-15 minutes to sync prospect additions/removals. ### Stage Mapping Propstreet uses three separate fields for project state. Map Pipedrive deal stages to the appropriate combination: | Pipedrive Stage (example) | `status` | `classification` | `teaser.stage` | Notes | | ------------------------- | -------- | ---------------- | -------------- | ------------------------ | | Lead In | open | draft | — | Initial deal setup | | Property Added | open | draft | — | Property details added | | Teaser Drafting | open | active | published | Marketing preparation | | Investor Outreach | open | active | communicated | Active outreach | | Due Diligence | open | active | communicated | Deep investor engagement | | Closing | open | active | communicated | Final negotiations | | Won | closed | — | — | Deal completed | | Lost | deleted | — | — | Deal fell through | - **`status`**: Lifecycle state (`open`, `closed`, `deleted`) — read-only, set by system - **`classification`**: Work stage (`draft`, `active`, `inactive`) — settable via API - **`teaser.stage`**: Teaser progress (`property_added`, `drafting`, `published`, `verified`, `communicated`) — read-only, set by system ### Custom Fields to Create in Pipedrive Create these custom fields for reliable bidirectional sync: | Field Name | Field Type | Purpose | | ----------------------- | ---------------------- | --------------------- | | `propstreet_project_id` | Text | Propstreet Project ID | | `is_portfolio` | Single option (Yes/No) | Portfolio deal flag | | `property_count` | Number | Number of properties | | `property_type` | Multiple options | Office, Retail, etc. | ### Sync Architecture **Pipedrive → Propstreet (using Webhooks v2):** 1. Subscribe to deal events via Webhooks v2 2. Transform and push to Propstreet Projects API 3. Store Propstreet IDs in custom fields 4. Poll participants endpoint for prospect sync ```javascript // Handle Pipedrive webhook v2 async function handlePipedriveWebhook(payload) { const { meta, data } = payload; if (meta.action === "added" && meta.object === "deal") { const project = await propstreet.post("/api/v1/projects", { name: data.title, externalRefs: [{ namespace: "pipedrive", id: String(data.id) }], }); // Store Propstreet ID in Pipedrive await pipedrive.put(`/v1/deals/${data.id}`, { propstreet_project_id: project.id, }); } } ``` **Propstreet → Pipedrive (using Webhooks):** 1. Register webhooks for `project.*` and `prospect.*` events 2. Look up Pipedrive Deal ID from `externalRefs` 3. Update Pipedrive via REST API ```javascript // Handle Propstreet webhook async function handlePropstreetWebhook(payload) { const pipedriveId = payload.data.externalRefs?.find( (ref) => ref.namespace === "pipedrive" )?.id; if (payload.event === "project.updated" && pipedriveId) { await pipedrive.put(`/v1/deals/${pipedriveId}`, { title: payload.data.name, }); } } ``` ### Webhook Events Subscribe to these Propstreet events for deal sync: | Event | Description | Pipedrive Action | | ---------------- | ----------------------------------- | ----------------------- | | project.created | New project created | Create Deal | | project.updated | Project fields modified | Update Deal | | project.deleted | Project deleted | Mark as Lost | | prospect.created | Investor added to prospect list | Add Deal Participant | | prospect.updated | Classification changed | Update custom field | | prospect.deleted | Investor deleted from prospect list | Remove Deal Participant | ### Pipedrive Webhooks v2 Setup ```bash curl -X POST "https://{company}.pipedrive.com/v1/webhooks" \ -H "x-api-token: $PIPEDRIVE_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "subscription_url": "https://your-middleware.com/pipedrive/webhooks", "event_action": "*", "event_object": "deal", "version": "2.0" }' ``` ### Example: Create Project with Pipedrive Reference ```bash curl -X POST "https://app.propstreet.com/api/v1/projects" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "Stockholm Office Building", "isPortfolio": false, "externalRefs": [{ "namespace": "pipedrive", "id": "12345" }] }' ``` ### Example: Lookup Project by Pipedrive ID ```bash curl "https://app.propstreet.com/api/v1/projects/external/pipedrive/12345" \ -H "Authorization: Bearer $TOKEN" ``` ### Participant Sync (Polling Required) Since Pipedrive webhooks don't fire on participant changes, poll periodically: ```javascript // Poll every 5-15 minutes async function syncParticipants(pipedriveProjectMapping) { for (const [dealId, projectId] of pipedriveProjectMapping) { const participants = await pipedrive.get( `/v1/deals/${dealId}/participants` ); const prospects = await propstreet.get( `/api/v1/projects/${projectId}/prospects` ); // Find new participants to add as prospects const existingContactIds = new Set(prospects.data.map((p) => p.contactId)); const newParticipants = participants.data.filter( (p) => !existingContactIds.has(lookupPropstreetContactId(p.person_id)) ); if (newParticipants.length > 0) { await propstreet.post(`/api/v1/projects/${projectId}/prospects:batch`, { prospects: newParticipants.map((p) => ({ contactId: lookupPropstreetContactId(p.person_id), externalRefs: [{ namespace: "pipedrive", id: String(p.id) }], })), }); } } } ``` ### Batch Operations Propstreet supports adding up to 100 prospects in a single request: ```bash POST /api/v1/projects/{id}/prospects:batch { "prospects": [ { "contactId": "123", "externalRefs": [{ "namespace": "pipedrive", "id": "456" }] }, { "contactId": "124", "externalRefs": [{ "namespace": "pipedrive", "id": "457" }] } ] } ``` ## API References - [Propstreet Contacts API](../../api-reference#tag/contacts) - [Propstreet Companies API](../../api-reference#tag/companies) - [Propstreet Projects & Prospects Guide](../projects-prospects) - [Propstreet Webhooks Guide](../webhooks) - [Pipedrive Persons API](https://developers.pipedrive.com/docs/api/v1/Persons) - [Pipedrive Organizations API](https://developers.pipedrive.com/docs/api/v1/Organizations) - [Pipedrive Deals API](https://developers.pipedrive.com/docs/api/v1/Deals) - [Pipedrive Deal Participants API](https://developers.pipedrive.com/docs/api/v1/DealParticipants) - [Pipedrive Webhooks v2 Guide](https://pipedrive.readme.io/docs/guide-for-webhooks-v2) - [Pipedrive Rate Limiting](https://pipedrive.readme.io/docs/core-api-concepts-rate-limiting) # Salesforce Integration Sync your Salesforce CRM contacts and accounts with Propstreet automatically. ## Overview | Feature | Support | | --------------------- | --------------------------- | | **Market** | Global | | **Sync Direction** | Bidirectional | | **Real-time Updates** | Yes (customer-managed sync) | | **Contacts** | ✅ Full support | | **Companies** | ✅ Full support | | **Projects** | ✅ Full support (v1.2.0) | | **Prospects** | ✅ Full support (v1.2.0) | | **Properties** | ⚠️ API available (v1.3.0) | ## Data Mapping ### Contact Fields | Salesforce | Propstreet | Notes | | -------------------------- | ---------- | ------------------------------- | | FirstName | First Name | | | LastName | Last Name | | | Email | Email | Primary email synced | | MobilePhone / Phone | Phone | Mobile preferred | | Title | Job Title | Synced to company connection | | AccountId | Connection | Links contact to company | | Propstreet_Contact_Id\_\_c | ID | External ID field (create this) | | Custom: Investor Strategy | Strategy | Investment focus (custom field) | > **Note**: Salesforce supports multiple phone fields. We recommend syncing MobilePhone to Propstreet's phone field; if mobile is empty, use the main Phone. ### Account Fields (Company) | Salesforce | Propstreet | Notes | | --------------------------- | ------------------ | --------------------------------------- | | Name | Name | | | Website | Homepage URL | Full URL synced | | — | Domain | Set directly or auto-extracted from URL | | BillingCountry | Country Code | Map to ISO 2-letter code | | Propstreet_Company_Id\_\_c | ID | External ID field (create this) | | Custom: Organization Number | External Reference | For Nordic deduplication | ## How Sync Works ### Salesforce → Propstreet You control the sync through your own integration layer: 1. Configure Salesforce Change Data Capture (CDC) or Platform Events to send changes to your middleware 2. Your middleware transforms and pushes data to Propstreet via our API 3. Records are matched using email, domain, or External ID and created/updated in Propstreet Salesforce CDC provides near real-time change notifications for create, update, delete, and undelete events. ### Propstreet → Salesforce When you update a contact in Propstreet, you have two options: **Option A: Webhooks (Recommended)** 1. Register a [Propstreet webhook](../webhooks) for `contact.*` and `company.*` events 2. Propstreet pushes changes to your endpoint in real-time 3. Your middleware transforms and pushes to Salesforce using External ID upsert **Option B: Polling** 1. Your integration layer polls the Propstreet API for changes (using `updated_since`) 2. Changes are transformed and pushed to Salesforce via their REST API 3. Your Salesforce records stay in sync Propstreet recommends polling every 5–15 minutes for delta sync. ### Duplicate Handling Salesforce's **External ID upsert** is the most reliable way to prevent duplicates: 1. Create External ID fields in Salesforce: - `Propstreet_Contact_Id__c` (Text, External ID, Unique) - `Propstreet_Company_Id__c` (Text, External ID, Unique) 2. Store Salesforce IDs in Propstreet's `externalRefs`: ```json { "externalRefs": [{ "namespace": "salesforce", "id": "003xx000001234ABC" }] } ``` 3. Use Salesforce upsert by External ID: ```http PATCH /services/data/v62.0/sobjects/Contact/Propstreet_Contact_Id__c/{propstreetId} ``` This approach ensures deterministic matching without relying on name or email alone. ## Field Considerations ### External ID Strategy Store cross-system IDs to enable reliable bidirectional sync: **In Salesforce:** - Create `Propstreet_Contact_Id__c` as External ID (unique) on Contact - Create `Propstreet_Company_Id__c` as External ID (unique) on Account **In Propstreet:** - Use `externalRefs` with namespace `"salesforce"` containing the Salesforce record ID ### Multi-Company Relationships Commercial real estate investors often have multiple company affiliations (fund manager, board member, personal investments). Propstreet models this as Links with their own API endpoints. For Salesforce, you have two options: **Option A: Primary Company Only (Simpler)** - Use the standard `Contact.AccountId` for the primary company - Accept that secondary affiliations won't sync **Option B: Full Relationship Fidelity (Recommended)** - Create a custom junction object like `Investor_Affiliation__c`: - `Contact__c` (Lookup to Contact) - `Account__c` (Lookup to Account) - `Job_Title__c` (Text) - `Is_Primary__c` (Checkbox) - `Propstreet_Link_Id__c` (External ID) - Sync Propstreet Links to this object ### Notes vs Strategy Propstreet has two different fields for text information: - **Strategy** — Investment focus (e.g., "Office properties, €10–50M, Nordics") - **Notes** — Activity log entries (call notes, meeting summaries) Create a custom Salesforce field for `Investor_Strategy__c` to separate investment criteria from general notes and activity history. ### Phone Numbers Salesforce has separate fields for Phone, MobilePhone, HomePhone, etc. We recommend: - Sync MobilePhone to Propstreet's phone field - If MobilePhone is empty, fall back to Phone - Other phone fields remain in Salesforce only ### Organization Number (Nordic) Salesforce doesn't have a built-in organization number field. For Nordic markets, create a custom field on Account to store: - **Norway**: Organisasjonsnummer - **Sweden**: Organisationsnummer - **Denmark**: CVR-nummer - **Finland**: Y-tunnus Use this with Propstreet's `externalRefs` for accurate company matching. ## Conflict Resolution For bidirectional sync, design your integration to handle conflicts. Recommended approaches: **Field Ownership Matrix:** - Identity fields (name, email, phone): **Salesforce wins** - Network enrichment (tags, strategy): **Propstreet wins** - Relationship data (primary company): **Propstreet wins** - Compliance/consent flags: **Salesforce wins** **Loop Prevention:** - Check the `change_initiator` field in Propstreet webhooks to skip events your integration triggered - Filter Salesforce CDC events made by your integration user Propstreet supports ETags for optimistic concurrency—use `If-Match` headers to prevent lost updates. ## Setup Requirements To build a Salesforce integration, you'll need: 1. **Salesforce Account** — Professional or higher recommended; Enterprise+ for Change Data Capture 2. **Connected App** — Configure OAuth 2.0 for server-to-server authentication (JWT bearer flow recommended) 3. **Propstreet API Credentials** — Bot User + Client Credentials for production 4. **Integration Layer** — Middleware to handle events and API calls (MuleSoft, Azure Logic Apps, n8n, or custom) 5. **Custom Fields** — Create External ID fields and any additional fields (organization number, investor strategy) ### Change Data Capture Note Salesforce CDC requires Enterprise Edition or higher. Without CDC, you can integrate using: - Scheduled polling of Salesforce REST API - Apex triggers with outbound messages - Platform Events (with custom implementation) ## Getting Started 1. Generate Propstreet API credentials in your [account settings](https://app.propstreet.com) (requires login) 2. Create a Connected App in Salesforce Setup and configure OAuth 3. Create External ID custom fields on Contact and Account 4. Review the [Propstreet API documentation](../../api-reference) for endpoints and data models 5. Set up your integration layer to sync data between Salesforce and Propstreet 6. If using real-time sync, configure Salesforce CDC and/or Propstreet webhooks 7. Test with a small dataset before full rollout You're responsible for building and maintaining the integration. ## Frequently Asked Questions ### Which Salesforce editions support the integration? The integration works with all Salesforce editions. However: - **Change Data Capture** requires Enterprise Edition or higher - **API limits** vary by edition—higher tiers have more generous limits ### How does Salesforce rate limiting work? Salesforce uses concurrent and daily API call limits based on your edition and number of user licenses. For high-volume sync: - Use Composite API to batch up to 25 operations per call - Use sObject Collections to upsert up to 200 records per call - Use Bulk API 2.0 for initial loads (async, handles millions of records) ### How often does data sync? Sync frequency depends on how you configure your integration layer: - **Real-time**: Use Salesforce CDC + Propstreet webhooks for immediate syncs - **Scheduled**: Poll Propstreet API every 5–15 minutes (using `updated_since`) - **Manual**: Trigger syncs on-demand ### What happens to data if I stop syncing? Your data remains in both systems. No data is deleted. Use External ID fields and `externalRefs` to maintain record linkage if you resume syncing later. ### Can I choose which contacts to sync? Yes. Your integration layer controls which records to sync. Common filtering approaches: - Salesforce record types or custom fields - Reports or SOQL queries (e.g., only sync contacts with certain tags) - Account-based filtering (sync contacts linked to specific accounts) ### How should I handle merge conflicts? Design your integration layer to handle conflicts: - **Last write wins**: Compare `updatedUtc` (Propstreet) and `LastModifiedDate` (Salesforce) - **Source of truth**: Designate one system as authoritative for specific fields - **Manual review**: Flag conflicts for human review Propstreet supports ETags for optimistic concurrency—use `If-Match` headers to prevent lost updates. ### How do I handle Salesforce Person Accounts? If your org uses Person Accounts: - Person Accounts map to Propstreet Contacts - Business Accounts map to Propstreet Companies - The mapping is conceptually similar, but field names differ slightly (e.g., `PersonEmail` vs `Email`) ## Projects & Prospects (v1.2.0) Sync real estate deals between Salesforce Opportunities and Propstreet Projects. Track investor prospects through your pipeline with bidirectional classification sync. ### Opportunity to Project Mapping | Salesforce Opportunity | Propstreet Project | Notes | | ---------------------------- | ------------------ | ----------------------------------------- | | Id | externalRefs | `namespace: "salesforce"` | | Name | name | Direct mapping | | StageName | — | Use for display; see status mapping below | | (custom) Is_Portfolio\_\_c | isPortfolio | Create custom checkbox field | | (custom) Property_Count\_\_c | propertyCount | Create custom number field | | CreatedDate | createdUtc | Read-only in Propstreet | | LastModifiedDate | updatedUtc | For delta sync | > **Note**: Project `status` is **read-only** in Propstreet. It's auto-generated based on workflow state (whether teaser has been published, communicated, etc.). Use the mapping below for display purposes when syncing FROM Propstreet TO Salesforce. ### OpportunityContactRole to Prospect Mapping | Salesforce OCR | Propstreet Prospect | Notes | | -------------- | ------------------- | ---------------------------------------- | | Id | externalRefs | `namespace: "salesforce"` for OCR ID | | ContactId | contactId | Via Contact external ref lookup | | Role | classification | Map roles to classifications (see below) | | IsPrimary | — | Not directly supported; use capabilities | ### Stage Mapping Propstreet uses three separate fields for project state. Map Salesforce opportunity stages to the appropriate combination: | Salesforce Stage (suggested) | `status` | `classification` | `teaser.stage` | Notes | | ---------------------------- | -------- | ---------------- | -------------- | -------------------------- | | Prospecting / Qualification | open | draft | — | Project setup | | Needs Analysis | open | active | published | Teaser published | | Proposal/Quote / Negotiation | open | active | communicated | Teaser sent to investors | | Closed Won | closed | — | — | Deal completed | | Closed Lost | deleted | — | — | Project archived/cancelled | - **`status`**: Lifecycle state (`open`, `closed`, `deleted`) — read-only, set by system - **`classification`**: Work stage (`draft`, `active`, `inactive`) — settable via API - **`teaser.stage`**: Teaser progress (`property_added`, `drafting`, `published`, `verified`, `communicated`) — read-only, set by system ### Contact Role to Classification Mapping Prospect `classification` is **read-write** and settable via API. | Salesforce Role | Propstreet Classification | Rationale | | --------------- | ------------------------- | -------------------------- | | (not assigned) | not_contacted | Default state | | Evaluator | contacted | Reviewing the deal | | Influencer | contacted | Engaged but not deciding | | Economic Buyer | interested | Financial decision maker | | Decision Maker | interested | Primary decision authority | | (custom) Bidder | bidder | Actively participating | > **Tip**: Create a custom "Bidder" picklist value for OpportunityContactRole to map directly to Propstreet's bidder classification. ### External ID Fields Create these custom fields in Salesforce for reliable bidirectional sync: **On Opportunity:** - `Propstreet_Project_Id__c` (Text, External ID, Unique) **On OpportunityContactRole:** - `Propstreet_Prospect_Id__c` (Text, External ID, Unique) ### Sync Architecture **Salesforce → Propstreet (using CDC):** 1. Enable Change Data Capture for Opportunity and OpportunityContactRole 2. Subscribe to `/data/OpportunityChangeEvent` in your middleware 3. Transform and push to Propstreet Projects API 4. Store Propstreet IDs in custom External ID fields ```javascript // Handle Salesforce CDC event async function handleOpportunityChange(event) { const { changeType, recordIds } = event.ChangeEventHeader; const sfOppId = recordIds[0]; if (changeType === "CREATE") { const project = await propstreet.post("/api/v1/projects", { name: event.Name, externalRefs: [{ namespace: "salesforce", id: sfOppId }], }); // Update Salesforce with Propstreet ID await salesforce.update("Opportunity", sfOppId, { Propstreet_Project_Id__c: project.id, }); } } ``` **Propstreet → Salesforce (using Webhooks):** 1. Register webhooks for `project.*` and `prospect.*` events 2. Look up Salesforce ID from `externalRefs` 3. Upsert to Salesforce by External ID ```javascript // Handle Propstreet webhook async function handlePropstreetWebhook(payload) { const sfId = payload.data.externalRefs?.find( (ref) => ref.namespace === "salesforce" )?.id; if (payload.event === "prospect.updated" && sfId) { const sfRole = mapClassificationToRole(payload.data.classification); await salesforce.update("OpportunityContactRole", sfId, { Role: sfRole }); } } ``` ### Webhook Events Subscribe to these Propstreet events for deal sync: | Event | Description | Salesforce Action | | ---------------- | ----------------------------------- | ----------------------------- | | project.created | New project created | Create Opportunity | | project.updated | Project fields modified | Update Opportunity | | project.deleted | Project deleted | Close as Lost | | prospect.created | Investor added to prospect list | Create OpportunityContactRole | | prospect.updated | Classification changed | Update Role | | prospect.deleted | Investor deleted from prospect list | Delete OpportunityContactRole | ### Example: Create Project with Salesforce Reference ```bash curl -X POST "https://app.propstreet.com/api/v1/projects" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "Stockholm Office Portfolio", "isPortfolio": true, "propertyCount": 5, "externalRefs": [{ "namespace": "salesforce", "id": "0064V00000abc123" }] }' ``` ### Example: Lookup Project by Salesforce ID ```bash curl "https://app.propstreet.com/api/v1/projects/external/salesforce/0064V00000abc123" \ -H "Authorization: Bearer $TOKEN" ``` ## API References - [Propstreet Contacts API](../../api-reference#tag/contacts) - [Propstreet Companies API](../../api-reference#tag/companies) - [Propstreet Projects & Prospects Guide](../projects-prospects) - [Propstreet Webhooks Guide](../webhooks) - [Salesforce REST API](https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/) - [Salesforce Contact sObject](https://developer.salesforce.com/docs/atlas.en-us.object_reference.meta/object_reference/sforce_api_objects_contact.htm) - [Salesforce Account sObject](https://developer.salesforce.com/docs/atlas.en-us.object_reference.meta/object_reference/sforce_api_objects_account.htm) - [Salesforce Opportunity sObject](https://developer.salesforce.com/docs/atlas.en-us.object_reference.meta/object_reference/sforce_api_objects_opportunity.htm) - [Salesforce OpportunityContactRole sObject](https://developer.salesforce.com/docs/atlas.en-us.object_reference.meta/object_reference/sforce_api_objects_opportunitycontactrole.htm) - [Salesforce Change Data Capture](https://developer.salesforce.com/docs/atlas.en-us.change_data_capture.meta/change_data_capture/) - [Salesforce External ID Upsert](https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/resources_sobject_upsert_patch.htm) # Product Releases > Short updates on the Propstreet app features, improvements, and fixes that are worth your time. This page highlights customer-visible product updates for brokers, investors, and teams using Propstreet. For integration-specific changes, see the API changelog in the Documentation section. ## 2026 # October 16, 2026 Your own register of real-estate deals, with analysis, a map and an Excel export, built from the projects you close and the market deals you hear about. ## New: Transactions Transactions collects every deal your organisation knows about in one place: the property, its address and type, the date, the price and yield, the buyer and seller, and the advisors on each side. Filter by property type, period and area, and hide a deal from the list for the whole team. Analysis shows median price per square metre and yield per property type, deal volume per quarter, and the most active buyers and sellers. The map shows every deal as a pin, and you can choose to show only the area of a deal whose address is not public. Export the current selection to Excel for a valuation or a pitch. Transactions is part of the Professional plan; other plans see an example page. Where to find it: Transactions in the main menu. ## New: Closing a project as sold records the deal When you close a project as sold, you now record the deal in the same step: its name, address, signing and completion dates, seller, buyer and advisors. The address field suggests addresses as you type, here and when you add or change a deal, and picking one fills in the street and number. The deal is saved on every plan, so your history is ready the day you upgrade. If something was wrong, correct the close afterwards and the deal follows. ## New: Record market deals and where you heard them Add a deal you read about or heard of, on one of your own properties or on another property, and note where you know it from: a news link, a note in your activity log, or a person or company in your network. A queue of deals that are missing details, such as a price or a confirmed completion date, helps you complete the picture over time. ## Improved: Deals on profiles and in Propstreet Agent Company and contact profiles list the deals they took part in, and Propstreet Agent can find, read and record deals for you, including setting the picture of a deal's property. # October 14, 2026 Put a removed prospect back on the list with its history, see who holds each next step at a glance, and get a more dependable connection to your AI assistant. ## New: Restore a removed prospect Ask Propstreet Agent to put a prospect you removed back on the project's list. It comes back with its notes, tasks and history intact, and if it had a seat in a Case Room, that seat comes back too. When the agent lists prospects, it leaves the ones you removed out unless you ask for them, and it can filter by status: active, declined, closed or removed. ## Improved: Who holds each next step The Next step column on the prospect list now names who holds the task, in words and the same way on every row: a colleague's first name, "no one" when nobody holds it, and nothing when it is yours. Before, only some rows showed a small picture of the assignee. ## Improved: Profiles show when they are still loading On a person's or company's profile, the to-do, project and people sections now say they are loading until they have the answer, instead of briefly looking empty. ## Improved: Propstreet in your AI assistant When you connect Propstreet to an outside AI assistant, each action now states clearly whether it can change or overwrite your data, so your assistant can check with you first. When something goes wrong, you get a message that says what to do next instead of a generic error. Some existing connections may ask you to sign in again once. ## Fixed: Fewer refusals when the agent adds people and companies Propstreet Agent now accepts `no` for Norwegian and language codes with a region, such as `sv-SE`. Creating a company is no longer blocked by a different company with a similar name, only by the same duplicate check the app uses, and the agent tells you which existing company matched and why. # October 10, 2026 Faster prospect lists and Tasks, and a Propstreet Agent that can add pictures, manage your tasks and update many prospects at once. ## Improved: Faster prospect lists and Tasks Opening a project now shows its header at once, and the prospects fill in as soon as they arrive, with no full-page wait. Going back to a project you just had open is instant: the list is shown right away and quietly refreshed, and rows that changed in the meantime are marked. The prospect stage loads much faster on large projects. Coming back to Tasks within a minute shows your list right away while it refreshes. Tasks also keeps your rows while a new filter or search loads, and no longer empties at midnight. ## Improved: Lists stay current on their own If the connection to Propstreet drops, the app now reconnects by itself and refreshes your lists. The prospect list and the Tasks page show when they were last updated, and warn you if they may be out of date while you are offline. If a colleague changed a task you were editing, Tasks tells you which one. ## New: More for Propstreet Agent to do Ask the agent to add a photo, logo or project cover picture from a web link you choose. It can now list your follow-up tasks with the same filters as the Tasks page, and move, edit, cancel or reopen them. Across up to 100 prospects in one go, it can set the owner, add or remove project tags, add a follow-up task, or remove those prospects from the project, and when it sets interest or classification on many prospects it can log a note on each. It tells you exactly what changed. ## Fixed: News stays apart from your notes When you ask the agent to save news it found on the web, it now files it as news on the company or person instead of as a note. Strategy suggestions keep your recent notes in view next to company news, and give more weight to a strategy you set or accepted recently than to a single headline. # October 8, 2026 Project cards in a grid with your own cover pictures, a tool to merge duplicate people and companies, and a nudge to plan the next step. ## Improved: Project cards in a grid, with your own cover pictures Project cards are now laid out in a grid, three per row on a laptop, two on a tablet and one on a phone. Prefer the compact list? Switch with the grid/list toggle, and Propstreet remembers your choice. You can now add your own picture of the property to a project. It shows on the card and at the top of the project page, which now uses the same design as the card. Searching within a project has its own button in the tab bar. Where to find it: Projects. To add a picture, open a project and use the picture control at the top of the page. ## New: Merge duplicates When two records turn out to be the same person or company, you can now merge them. Choose which record to keep, compare the two side by side, and check a summary of what will move before you confirm. Notes, tasks, files and tags all move to the record you keep. If both were prospects on the same project, they become one prospect. Where the two records disagree, such as job title or primary company, you choose which to keep. Propstreet now also flags companies whose people share a work email domain or whose names are very similar. Where to find it: Network → ⋯ → Tidy up, or the possible-duplicate notice on a profile. ## Improved: A nudge to plan the next step When you complete a task, the follow-up card now asks once before closing if you haven't planned a next step on the card, so a deal doesn't go quiet by accident. Picking the prospect for a task stays fast on projects with hundreds of prospects. ## Improved: Clearer, calmer screens Successful saves now show on the screen itself, as a checkmark on the button or an updated row, instead of a pop-up. Pop-ups are now mostly kept for problems and undo. Each task type has its own colour, people and companies get the same avatar everywhere, and you can upload a photo or logo straight from the profile. News is now shown apart from your notes in the activity log, and empty tabs explain what will appear there. # October 4, 2026 A rebuilt Network, a new Tasks page for every follow-up, and a profile that shows everything about a person or company on one screen. ## New: Tasks All your follow-ups now live on one page, across every project and your whole network. You can add tasks to people and companies as well as prospects. Filter by owner, due date, type or project. Complete a task with one click and undo if you change your mind. Select several tasks to complete, reschedule or reassign them together, or move a single task to another person, company or prospect. When you complete a task, a short follow-up card asks how it went and suggests the next step. Investors can keep their own private tasks on the projects they receive. Where to find it: Tasks in the main menu. ## New: A faster, smarter Network Network now loads your contacts a page at a time, so it opens quickly even with thousands of people and companies. Each card shows when your team last spoke to the person and which projects they are part of. Search finds names, emails, companies and phone numbers, with or without spaces, dashes or the country code. AI search finds people by what they are looking for and quotes the line from their strategy or notes that matched. You can filter by tags, last contact, project and country, and switch between cards and a compact list. Tick rows to tag them, add them to a project, or export them to Excel with the columns you choose. Where to find it: Network. ## New: Profile panel Click a person or company to see everything on one screen. The panel shows their open tasks, the projects they are in and how each one ended, recent notes and calls, and their investment strategy. A quick-note box under the name lets you log a call in seconds. Suggested strategy updates can be reviewed, edited and approved right in the panel. ## New: Duplicate warnings and clean-up When you add a person or company that may already exist, Propstreet shows the possible match so you can open it instead of creating a duplicate. A company can be suggested from a work email address. On the Network page, the Tidy up menu lists possible duplicates and people without a company, so you can keep your network clean. Where to find it: Network → ⋯ → Tidy up. # October 1, 2026 Network search that keeps up with your typing, clearer follow-up tasks, and tighter control over who sees deal material. ## Improved: Network search while you type Results now stay on screen while you type, instead of clearing on every keystroke. The previous matches stay visible, slightly dimmed, until the new ones arrive, and your full network no longer flashes in between. A typed search starts from two characters; press Enter to search for anything shorter. If you press Enter and keep typing, the results always match what's in the search box. Picking a seller or a prospect no longer jumps the page back to the top. Where to find it: Network. ## Improved: Clearer follow-up tasks A task due today now says "today" next to its time, and an overdue task is marked as overdue in the task editor as well as in the prospect list. Task notes now show on up to two lines instead of one, and the remove button now says "Remove task". ## Improved: One access rule for each Case Room Opening a Case Room, its files, search, downloads and Q&A now all follow the same access rule. When you revoke an investor, they lose the room and its files right away. Each invitation opens only the room it was sent for. A room with downloads turned off can't be downloaded from anywhere else either. Files attached to your team's project and prospect notes are visible only to the project owner and your organisation's admins. Files attached to an investor's notes on a prospect are visible to the people on the investor's side who can open that prospect. ## Fixed: Contacts and companies You can now save a contact that has no email address. When you delete a company, it no longer stays behind as its contacts' primary company. If you clear a contact's primary company, it stays cleared. # September 23, 2026 A project list where every project is a card that says what it needs, follow-up tasks with an exact time, and a faster project overview. ## New: Project cards Every project on the project list is now a card. It shows a picture for the property type, the project name and the project owner, where the property is and what it is priced at, and who the seller is. Under To do, the card shows up to three things waiting on you: questions, teaser replies, a teaser ready to send, overdue follow-ups, your next task and its time, or a project that has gone quiet. Each one opens the place where you deal with it, and uses the same sentence as the project overview, so the two always agree. A bar shows how many prospects are at each stage. A closed project says in one line how it ended: who bought it, when and for how much, as far as those were recorded, or that no sale was recorded. Newest projects come first, and new projects appear under In progress as soon as they are created. On a team, the list opens on your own projects; clear the filter to see everyone's. Where to find it: Projects. ## Improved: Follow-up tasks with a time Calls, meetings and emails in a prospect's next step now have a time as well as a date. The time shows wherever the task does, including the prospect grid, the activity log, the project card, the overview and the export. A task counts as overdue once its time has passed, not at the start of the day. A new task starts as a call at the next quarter hour, and a task note shows on its own line. ## Improved: A Seller Portal with the first seller Adding a seller to a project creates its Seller Portal right away, and you stay in the picker to add the next one. When a project is set to inactive, the overview stops prompting you to follow up with investors, and keeps showing anything someone is waiting on you for. ## Improved: Faster project overview and prospect list The project overview and the prospect grid open noticeably faster, most of all on projects without Case Rooms. The project list shows the cards first and adds the stage bars a moment later. ## Fixed: Notices that would not clear The notice that an investor answered your teaser now clears once you handle the answer, including when you remove or close it, without reloading the page. The "News from an investor" notice counts only messages, files and calls, the same things your inbox shows. # September 11, 2026 A new project overview that tells you where each deal stands, a spreadsheet-style prospect grid with stage, interest and follow-up tasks, and a rebuilt project list ordered by what needs your attention. ## New: Project overview Every project now opens on an Overview tab that answers two questions at a glance: where the deal stands and what to do next. One attention card names the most important thing right now, for example a teaser approved and ready to send, interest submissions waiting for an answer, or overdue follow-up tasks, with a button to act on it. Below it, a short story written from the project's own data says what has happened so far, and every prospect count in it links to the prospects it counts. Sections for prospects, property data, seller, Propstreet Network, Case Rooms and reports each open with their key fact and colour-coded rows, so you can see at once what is your move, what is done and what is waiting. A project that is not set up yet shows the one thing to do next, including adding a property straight from the card. Where to find it: open any project. The old Property, Sellers and Reports tabs have moved into the overview; old links land in the right place. ## New: Spreadsheet-style prospect grid The prospect list is now a grid that works the way a spreadsheet does: arrow keys to move, Shift to select a range, Enter to edit, and copy rows straight into Excel. The single classification is replaced by three columns you can reason about. Stage follows what has actually happened, from not contacted through teaser, information memorandum, data room and bid, and can be corrected by hand, with the correction logged. Interest is your own judgement: interested, maybe or not interested, with a note. Next step holds dated call, meeting and email tasks with an assignee, and shows when one is overdue. You can also label a prospect with tags that belong to this project only, filter by them, and set stage, interest, tasks or tags for many prospects at once. Every change is saved with confirmation, and if a colleague edits a row you have open, the grid tells you. The export includes the new columns. The previous list is still available from the project menu if you prefer it. ## New: Project list ordered by attention The project list now shows one line per project saying what it needs: a teaser ready to send, interest submissions waiting, a stalled deal, or news from an investor. Projects that need you rise to the top, and the order stays put while you work through them. A summary line counts how many are waiting for you or your team, and team leads can narrow the list to one colleague. Projects you close leave the list right away. ## Improved: Adding a property and publishing a teaser The property form is one clear column that reveals itself step by step when you add a property, with the address shown on the map as soon as you pick a suggestion. The publishing steps use one vocabulary throughout, the price is asked for where it is needed, teaser options are grouped by theme, and the last step shows exactly what investors will see. Taking a published teaser back is now one clearly named action, available wherever you see the teaser, with a confirmation that says what happens. ## Improved: Dictate a prospect note, and a smoother path for pre-market sellers You can dictate a note on a prospect right after a call in browsers that support speech input. Investors publishing a pre-market deal are now returned to their property page after editing the teaser, instead of hitting an error. # August 29, 2026 Connect your Microsoft account to Propstreet Agent, company news monitoring returns, and a clearer way to add companies. ## New: Connect your Microsoft account to Propstreet Agent Propstreet Agent can now work with your own mail, calendar and saved contacts — not just what already lives in Propstreet. Connect a Microsoft 365 work account or a personal Outlook account, and the agent can search your mailbox, open a specific message or a whole thread, list events on your primary calendar, and look up people you have saved in your contacts. So you can ask what a counterparty said about price in a thread last week, or what your week looks like before proposing next steps, and get a real answer. Access is read-only and there is no background sync — the agent reads only when you ask it something that needs it. Before you connect, you see exactly which permissions are requested and how your conversations are retained. You can review the granted permissions at any time, switch accounts, or disconnect in one click, and revoke access from Microsoft's side too. Administrators can turn personal integrations off for the whole organisation. Where to find it: Settings → Agent. ## New: Company news monitoring is back, and more accurate News monitoring for companies in your network is running again, with a change that makes the results noticeably more reliable: a company is monitored once it has a website on file. Searching by company name alone too often surfaced news about a different firm with a similar name. Anchoring on the website removes that noise. Companies waiting on a website now say so directly in the sidebar, and adding the website resumes monitoring on its own — nothing to switch back on. Companies that already had a homepage on file were picked up automatically. Your organisation settings show how many of your companies are currently eligible. ## Improved: A clearer way to add a company When you search for a company while adding a contact, the results panel now separates the two things you might want to do. Companies already in your network appear as ordinary, clearly readable rows under their own heading. Creating a new company is a distinct button below a divider, labelled with the name you typed. You can move through the results with the arrow keys and select with Enter. If your search exactly matches a company you already have, the create option disappears, so it is harder to end up with duplicates by accident. ## Improved: Faster answers from Propstreet Agent Everyday chat now responds noticeably faster, while heavier work — reports, prospect classification, multi-step tasks — keeps the same depth of reasoning as before. Processing continues to run inside the EU. # June 20, 2026 A clearer way to run a Case Room, live invite counts, and a more grounded Propstreet Agent. ## New: Case Room setup-to-metrics header Every Case Room now shows a header strip that doubles as a setup guide and a live dashboard — visible to you as the broker while editing the room. On a brand-new room it reads as a checklist: invite members, add a files section and documents, add an NDA, open Q&A. As the deal fills in, those same tiles turn into live metrics — members accepted versus invited, number of files, open questions, and recent counterparty activity. Tiles that don't apply yet stay hidden, so you always see the next useful step rather than a wall of empty stats. Each tile is one click to the right place. Where to find it: at the top of any Case Room you're editing. ## Improved: Propstreet Agent understands the page you're on When you ask the agent for help on a Case Room or Seller Portal page, it now grounds itself in verified facts about that page and your access to it. That means more relevant help — a Seller Portal is treated as a seller workspace, and a Case Room as a Case Room — and the agent won't reference details you don't have access to. ## Fixed: Case Room invite counts update instantly After you invite a seller to a Case Room, the room list now updates "Total invited" and "Awaiting response" right away, instead of waiting until the seller accepted or you reloaded the page. # June 18, 2026 This release makes invited collaboration clearer and gives brokers safer ways to clean up project work without losing important history. ## Improved: Invited guests land in the right place Guests who join Propstreet through an invitation now see a clearer welcome and are guided to Pipeline, where pending invitations are easier to find and act on. Where to find it: Sign in from an invitation link or open Pipeline as an invited guest. ## Improved: Invitation work is easier to follow Pipeline now makes pending invitations and seller-side collaboration easier to distinguish from active buying opportunities. Users can see what needs a response without hunting across unrelated views. Where to find it: Open Pipeline and review the Invitations and Selling views. ## Improved: Project deletion is safer Delete is now reserved for true drafts. If a project has already reached counterparties or has active Case Room members, Propstreet guides brokers to Close instead, preserving the process history. Where to find it: Open a project's more-actions menu. ## New: Deleted drafts can be restored Draft projects removed by mistake can be restored within 30 days. Brokers also get an immediate Undo option after deleting a draft. Where to find it: Use Undo after deleting a draft, or open Recently deleted from the Projects page. # June 11, 2026 This release helps brokers keep Case Room work moving: Propstreet Agent can help prepare Q&A drafts, files show when they are ready to search, and prospect notes are easier to scan from the list. ## New: Propstreet Agent can help with Case Room Q&A Propstreet Agent can now help brokers review open Case Room questions, summarize threads, and prepare draft answers. Brokers stay in control of what is shared and when. Where to find it: Open Propstreet Agent from a project with an active Case Room and ask about open Q&A threads. ## Improved: File search readiness is clearer Files now show clearer search status, so brokers can see when documents are being read, prepared, or ready to search. Re-uploaded files that Propstreet already recognizes can become searchable much faster. Where to find it: Open the file list in a Case Room. ## Improved: Prospect notes are easier to scan The prospect list now brings the latest relevant note forward, whether it comes from prospect notes, classification feedback, or network notes. Brokers can scan the list without opening every sidebar and still jump back to the source when they need more context. Where to find it: Open a project's prospect list and show the Notes column. ## Fixed: Agent progress feels more trustworthy Longer Agent runs now show clearer progress timing. The activity label keeps counting up instead of restarting or jumping around. # June 10, 2026 This release focuses on two daily broker workflows: managing prospects at scale and running Case Rooms without leaving the room. ## New: A more useful prospect list The prospect list now behaves more like a working table. Brokers can see key columns together, sort the list, bring notes forward, and use a responsive layout on smaller screens. Where to find it: Open the Prospects tab on a project. ## New: Document requests can collect multiple files Document requests can now collect a set of related files instead of forcing sellers or brokers into one-file workarounds. Brokers can review the bundle as one request, while new uploads keep review status honest. Where to find it: Open document requests in a Seller Portal. ## Improved: Case Room work is easier from inside the room Brokers can invite more people from the Case Room members area without navigating away, and Seller Portals open with the core files and document-request areas ready to use. Where to find it: Open a Case Room or Seller Portal and go to Members or Files. ## New: Propstreet Agent understands more room context Propstreet Agent can now answer more useful questions about Case Rooms, including room activity, members, files, and document requests. That makes it easier to understand what has happened in a room without manually checking every tab. # June 5, 2026 Case Room gained a structured Q&A workflow for controlled deal communication, plus stronger room-entry and audit controls. ## New: Case Room Q&A Brokers can now manage questions and answers inside Case Room instead of tracking Q&A separately. Questions can be organized, assigned, and answered in context, and shared answers can be handled consistently across the right audience. Where to find it: Open a Case Room and go to the Q&A area. ## New: Disclosure history for room answers Case Room now keeps a clearer record of released answers, including who received them and when. This helps brokers maintain a clean process when several investors are reviewing the same opportunity. Where to find it: Open the answer log in a Case Room. ## Improved: Secure room entry controls Brokers have more control over how people enter and use a room, including room-level agreements, access expiry, two-factor requirements, and view-only document access. Where to find it: Open Case Room settings. ## New: Broker activity export Brokers can export room activity when they need a practical audit trail for a process, review, or closing file. Where to find it: Open the activity log in a Case Room. # May 27, 2026 This release makes uploaded files more useful by letting brokers search and ask questions across the documents they already keep in Propstreet. ## New: Ask questions across uploaded files Propstreet Agent can now help find answers in uploaded project and Case Room documents. Brokers can ask natural questions and use Propstreet to locate relevant material without opening every PDF, spreadsheet, image, or document by hand. Where to find it: Open Propstreet Agent from a project or Case Room with uploaded files. ## Improved: More file types become searchable Propstreet can prepare a broader set of uploaded files for search, including common document and spreadsheet formats. This makes document-heavy workflows easier to handle inside the platform. ## Improved: Citations make answers easier to trust When Propstreet Agent uses uploaded files, answers can point back to the material they came from. Brokers can review the source instead of relying only on a summary. ## Fixed: Document search is more resilient File search handles difficult files more gracefully, so one problematic document is less likely to block progress on the rest of a room. # May 20, 2026 Case Room became a more complete workspace for collecting material, reviewing activity, and keeping members organized. ## New: Document requests Brokers can now ask sellers or other room participants for specific documents and track the request inside the room. The request stays visible until the right material has been uploaded and reviewed. Where to find it: Open a Case Room or Seller Portal and go to Document Requests. ## Improved: Members and activity in one place The room sidebar now brings member management and activity context closer together, making it easier to see who is in the room and what has happened recently. Where to find it: Open the Case Room sidebar. ## Improved: Room navigation feels more complete Files, activity, members, and requests are easier to reach from the room itself, reducing the need to jump back to the project overview while managing an active process. # May 18, 2026 This release expanded how brokers can prepare teasers, manage seller-side work, and use Propstreet Agent across more of the deal workflow. ## New: Seller-side workspace Brokers can now use a dedicated seller-side workspace to collect material and coordinate with the seller before or during a process. It gives the seller a clearer place to respond without mixing seller work into investor-facing room activity. Where to find it: Create or open a Seller Portal from a project. ## Improved: Teaser workflows are easier to control Teaser setup became more structured, with clearer paths for preparing what investors see before they receive fuller access. This helps brokers move from preparation to outreach with fewer manual checks. Where to find it: Open teaser setup for a project or property. ## New: Propstreet Agent can help across more project work Propstreet Agent gained more project context, making it easier to ask for help with teasers, prospects, and room preparation without manually collecting every detail first. # May 3, 2026 This release polished the first days in Propstreet and made Propstreet Agent output easier to read, trust, and act on. ## Improved: Onboarding is clearer for new users The home page and onboarding steps now guide new users more directly toward useful first actions, including account verification and adding contacts or companies. Where to find it: Open the home page as a new or recently invited user. ## Improved: Registration is smoother The registration experience now presents sign-in options more clearly and reduces repeated typing when a user arrives from a personalized signup link. Where to find it: Open a signup or registration link. ## New: Propstreet Agent can draft email handoffs When a broker asks Propstreet Agent to draft an email to a contact, Propstreet can show a structured draft with recipient, subject, and body. The user stays in control and sends from their own email client. Where to find it: Ask Propstreet Agent to draft a follow-up email to a known contact. ## Improved: Long Agent answers are easier to read The Agent panel can expand for longer answers and reports, making deeper analysis easier to review without fighting a narrow side panel. # April 20, 2026 Propstreet Agent became more useful on both sides of the marketplace, helping brokers work faster and helping investors understand relevant opportunities. ## New: Propstreet Agent gets broader deal context Brokers can ask Propstreet Agent more practical questions about their network, projects, prospects, and activity. The experience moves beyond simple chat into a working assistant for day-to-day dealmaking. Where to find it: Open Propstreet Agent from the app sidebar. ## New: Investor-facing Agent support Investors can use Agent assistance to understand relevant opportunities and navigate their own Propstreet work with less friction. ## Improved: Agent actions are easier to follow The Agent experience now makes active work clearer, so users can better understand when it is searching, preparing an answer, or taking an action. # April 9, 2026 The broker publishing workflow was rebuilt into a guided experience for preparing a property, designing a teaser, and publishing a project. ## New: Guided project publishing Brokers now move through the publishing process in a clearer step-by-step flow. The experience brings setup, teaser design, and publication into one more coherent path. Where to find it: Start publishing a project from the project workflow. ## Improved: Teaser preparation is easier to review The new flow makes it easier to understand what investors will see before a teaser is shared, reducing uncertainty before outreach. ## Improved: Fewer dead ends while preparing a project The publishing experience now keeps the next step visible, so brokers spend less time figuring out where to go after completing each part of setup. # March 30, 2026 Propstreet Agent launched inside the app, giving brokers a new way to work across their data and daily workflows. ## New: Propstreet Agent in the app sidebar Users can now open Propstreet Agent from the app and ask questions in natural language. The assistant can help navigate Propstreet, summarize context, and support common broker tasks. Where to find it: Open Propstreet Agent from the app sidebar. ## New: Voice input and file-aware help Propstreet Agent can support richer input, including voice-driven prompts and help that uses available Propstreet context. This makes it quicker to ask for help while staying in the workflow. ## Improved: A persistent workspace for AI help Propstreet Agent is designed as a workspace panel rather than a one-off chat box, so users can keep it nearby while moving through the app. # March 25, 2026 This release added stronger multi-market support and better controls for teams connecting Propstreet to external systems. ## New: UK market support Propstreet now supports UK-style market work more completely, including GBP currency handling where it matters in project and property workflows. ## New: Admin controls for integrations Company admins can manage access used by integrations, giving teams clearer control over which automated users can connect to Propstreet data. Where to find it: Open company administration settings. ## Improved: Agent usability Propstreet Agent received improvements that make answers and actions easier to work with during broker workflows. # February 25, 2026 The projects page received a search and navigation overhaul for brokers managing many active and historical projects. ## New: Faster project search Brokers can now find projects more quickly using a redesigned search experience built around how project work is actually organized. Where to find it: Open the Projects page. ## Improved: Project navigation Search results and project context are easier to scan, helping brokers move from a broad list to the right project with fewer clicks. ## Improved: Better handling of larger project lists The redesigned experience makes it more practical to work with a growing project history instead of relying on manual scrolling or memory. # February 21, 2026 Company data enrichment helps teams keep investor and company profiles more useful over time. ## New: Company data enrichment Companies can now be enriched with fresher public information, helping brokers keep network profiles more complete without manually checking every company one by one. Where to find it: Open a company profile in the network sidebar. ## Improved: Cleaner network records Better company data makes it easier to review investor organizations, understand context, and prepare outreach with fewer manual lookups. ## Improved: More useful CRM imports Organizations that bring in company and contact data from external systems benefit from cleaner, more complete records once those companies are active in Propstreet.