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.


[Unreleased]

Added

  • The category[] filter of POST /api/v1/comps:search and :summary (and category on the MCP list_comps tool) also takes a sub-type in the form a deal's type reads — retail/grocery, industrial/warehouse, … — matching only the deals with a property type of that sub-type, while a main type such as retail still matches all of them. Sub-types of one main type count as that type for the medians. A sub-type of another main type (office/grocery) answers 400 (MCP: invalid_parameter) naming category.
  • A deal's property carries pictureUrl, the download address of its picture, next to pictureId, on every comp read (V1 and MCP).
  • MCP: set_comp_property_picture sets or removes the picture of a deal's property — from a public pictureUrl (fetched and stored) or a pictureFileId uploaded with the REST API; 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. The comps tools are now seventeen.
  • MCP: the property tools (get_property, list_properties, update_property, delete_property) state that they reach your organisation's own properties only, and the id of a property recorded for a deal answers not_found naming the comp tools that reach it (get_comp, update_comp_facts, move_comp).

Changed

  • A correction of a closed project's close (POST /api/v1/projects/{id}/close/correct, MCP correct_project_close) now always emits project.updated with transactions in changed_fields, also when it changes only the price, the parties or the dates. Before, project.updated was sent only when the correction changed a project field such as is_portfolio or property_count.
  • The history row of a deal merged into another (GET /api/v1/comps/{id}/actions) reads event: "comp.deleted", the event its webhook sends; it read comp.updated. The deal that stays still reads comp.updated.

Fixed

  • The comp.updated webhook of the deal that stays after a merge no longer carries removal_reason: "deleted"; removal_reason is sent on comp.deleted only.
  • A test delivery (POST /api/v1/webhooks/{id}/test) of a comp.* event carries resource_type: "comp"; it carried "unknown".
  • A sub-type in the property-type filter (category=retail/grocery on properties, mandates and projects, and the same words on the MCP list and search tools) now also finds a record whose property type carries that sub-type among others; it matched only a type with exactly that one sub-type.

[1.10.0] - October 2026

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.

  • 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 on every deal on the property and opened with GET /api/v1/files/{id}. 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.

  • 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.

  • 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.

  • 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: sixteen 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), 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: 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, and project.updated fires only when a project field such as is_portfolio or property_count changes.

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 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.
  • 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 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. 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 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 for complete documentation. See the Webhooks Guide 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 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 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 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: