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 ofPOST /api/v1/comps:searchand:summary(andcategoryon the MCPlist_compstool) also takes a sub-type in the form a deal'stypereads —retail/grocery,industrial/warehouse, … — matching only the deals with a property type of that sub-type, while a main type such asretailstill 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) answers400(MCP:invalid_parameter) namingcategory. - A deal's
propertycarriespictureUrl, the download address of its picture, next topictureId, on every comp read (V1 and MCP). - MCP:
set_comp_property_picturesets or removes the picture of a deal's property — from a publicpictureUrl(fetched and stored) or apictureFileIduploaded with the REST API; an emptypictureFileIdremoves it. Every deal on the property shows it; on one of your own properties only its owner or an admin may change it; honoursifMatchEtag. 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 answersnot_foundnaming 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, MCPcorrect_project_close) now always emitsproject.updatedwithtransactionsinchanged_fields, also when it changes only the price, the parties or the dates. Before,project.updatedwas sent only when the correction changed a project field such asis_portfolioorproperty_count. - The history row of a deal merged into another (
GET /api/v1/comps/{id}/actions) readsevent: "comp.deleted", the event its webhook sends; it readcomp.updated. The deal that stays still readscomp.updated.
Fixed
- The
comp.updatedwebhook of the deal that stays after a merge no longer carriesremoval_reason: "deleted";removal_reasonis sent oncomp.deletedonly. - A test delivery (
POST /api/v1/webhooks/{id}/test) of acomp.*event carriesresource_type: "comp"; it carried"unknown". - A sub-type in the property-type filter (
category=retail/groceryon 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/compsstreams every deal your organisation knows of — the deals of your closed projects and the deals your team records — oldest change first, withupdated_sinceandinclude_deleted(a deleted deal and a deal voided by a correction of its project both come back withdeletedUtcandremovalReason); hidden deals are in the feed withhidden: true.POST /api/v1/comps:searchfilters 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 requestcurrency).POST /api/v1/comps:summaryanswers 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}(withETag),POST /api/v1/comps:batchGet, andGET .../{id}/parties,.../sources,.../propertyand.../actions(the history with old and new values, paged and shaped like the other action timelines: a note's text indetails, an opaquecursorper entry) read a deal and its parts. See Comps. -
POST /api/v1/compsrecords a deal (idempotent withIdempotency-Key; a likely duplicate is answered409withpossibleDuplicates[]unlessallowDuplicateis sent),PATCH /api/v1/comps/{id}changes its own fields withIf-Match,DELETEandPOST .../{id}:restoredelete and restore a recorded deal,POST/DELETEon.../partiesand.../sourcesadd and remove parties and sources (anactivitysource can file a new note on a contact or company in the same call) — a child POST answers201with the deal, its newETagand the child'sLocation, a child DELETE200with the deal —PUT .../{id}/propertyre-anchors the deal,PUT .../{id}/property/factscorrects 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,403otherwise, and the correction applies to every deal recorded with the same facts), andPOST .../{id}/notesadds a note (the child POSTs and the note take anIdempotency-Key);POST .../{id}:hideandPOST .../{id}:unhidehide a deal from a closed project for the organisation and show it again, andPOST /api/v1/comps:mergefolds two deals that are one into one (idempotent withIdempotency-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,rentalIncomewithout a property type, an amount out of range) answers422; a malformed one400. Creates answer an absoluteLocation. -
A deal's property may carry a picture:
PUT /api/v1/comps/{id}/property/picturewith{ "pictureId": … }(an image fromPOST /api/v1/images, ornullto remove it), read back asproperty.pictureIdon every deal on the property and opened withGET /api/v1/files/{id}. The change is in each deal's history (picture) and emitscomp.updatedwithchanged_fields: ["property"]. -
A deal's company party may carry its contact person:
contactIdnext tocompanyId, withcontactName, recorded with the party. AcontactIdof someone who works at a company is recorded as that company with the contact as its contact person; sendcompanyIdandcontactIdtogether to name the contact person yourself. ThepartyIdsearch filter also finds the deals a contact was the contact person on. -
The close and its correction take the deal's
priceCertainty,priceApproximate,yieldCertaintyandyieldApproximate(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}/closetakes the deal's parties and dates:buyerCompanyId,sellerContactIdorsellerCompanyId,sellerAdvisorContactIdorsellerAdvisorCompanyId,buyerAdvisorContactIdorbuyerAdvisorCompanyId,signedOn,completedOnandcompletedIsPlanned— 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), andpriceUnknown: truefor a deal whose price is not known (not withprice). A party, date orpriceUnknownwithbuyerType: "none"answers422(the certainty, "ca" anddealNamefields are ignored there), andcompletedIsPlannedneedscompletedOn(422). A sale (buyerType: "external") closes without a buyer too; its deal reportsmissing: ["buyer"]. -
POST /api/v1/projects/{id}/close/correctcorrects 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,nullclears it) andIf-Match. The project's deal follows the correction; a correction to no sale voids it. An open project answers409. See Projects & Prospects. -
Webhooks
comp.created,comp.updated(withchanged_fields),comp.deleted(withremoval_reason:deletedorvoided, andmerged_intofor a deal merged into another),comp.restored,comp.hiddenandcomp.unhidden, delivered while your organisation is on the Professional plan. Closing a project as a sale emitsproject.updatedandcomp.created; editing a property emitscomp.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 answers409— read the project again and retry. A deal with an unknown price states its property'srentalIncomeandnetOperatingIncomein 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 (403otherwise). -
POST /api/v1/comps/properties:searchfinds the property a deal is about among your own properties and the properties recorded for your deals; send a hit'spropertyIdasproperty.propertyIdto anchor another deal on the same record. A deal'spropertycarriespropertyIdandownPropertyfor both kinds. -
Every comp route answers
403with/help/plan-requiredfor 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 gainscompId(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,sellerAdvisorandbuyerAdvisor;buyer,sellerand the advisors carrycontactIdorcompanyIdwhen they are records in your network, and a party that is your own organisation has the new partytypeown_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_compsandget_compto read;create_comp(a likely duplicate is aconflictlisting the existing deals,force=truerecords 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_compandrestore_compto write. Deal fields are named in camelCase throughout (takeFromOther: ["signedOn"]). The writes run in conversations only, never on a scheduled run.correct_project_closecorrects a closed project's outcome (the buyer asbuyerCompanyRef/buyerContactRef,ifMatchEtag,preview);create_comp,add_comp_party,add_comp_sourceandadd_comp_notetake anidempotencyKey.close_projecttakes the deal's parties, dates,dealName,priceUnknown, certainty and "ca" (priceApproximate,yieldApproximate), and answers the deal'scompId(empty without the Professional plan or for a no-sale close);find,list_projects,get_projectandsearch_projectscarry a closed project's deal on its transaction;list_activitiesnames the deals an activity is a source of (sourceOfComps). A tool refused for the organisation's plan answers the new error codeplan_required. -
project.close_correctedon the project activity timeline (GET /api/v1/projects/{id}/actions, anauditrow): 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'scomp.updated(comp.deletedfor a correction to no sale; for a correction from no sale to a sale,comp.restoredwhen the deal was voided by an earlier correction, orcomp.createdwhen the project was closed without a sale and never had one) reports it, andproject.updatedfires only when a project field such asis_portfolioorproperty_countchanges.
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) ordeleted(listed only withinclude_deleted=true). Filter the list withstatus=. The list still returns removed prospects by default. -
POST /api/v1/projects/{projectId}/prospects/{id}/restoreandPOST /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 reportedunchanged, so a retry is safe. -
A restore is refused with
409(:batchRestore: underfailed) 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:batchmarks 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
externalRefsyou send, or not at all: when another prospect of the project holds one of them,POST .../prospectsanswers409(duplicate external reference) andprospects:batchlists the item underfailedwitherror: "duplicate_external_ref". Any other failure to store the references likewise leaves the prospect removed, so a retry does the whole add again.POST .../prospectsanswers a restored prospect withchangeType: "update", and with422when it cannot be read back — the prospect's id inprospectIdand aLocationheader to read it from (it never reports a made-up new prospect); a retry with the sameIdempotency-Keythen answers409(idempotency-outcome-unknown) instead of adding again. -
prospect.updatedwebhooks for a removal and for a restore liststatusinchanged_fields; a restore fires one (before, it fired none). -
MCP:
restore_prospect(one or many, withpreview);list_prospectsgains astatusfilter and leaves removed prospects out unlessstatus: "removed";remove_prospectreports an already-removed prospect asunchanged;add_prospectreports a prospect it put back asrestored. -
type=newson every action timeline —GET /api/v1/network/{contacts|companies}/{id}/actions,GET /api/v1/projects/{id}/actions,GET /api/v1/projects/{projectId}/prospects/{id}/actionsandGET /api/v1/offers/{id}/actions— selects the news items alone. Before,newswas not a known value and the whole timeline came back.type=notesis 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}/moveandPOST /api/v1/projects/{projectId}/prospects/{id}/tasks/{taskId}/movemove a task to another contact, company or prospect. The body is the newTaskMoveRequest,{ targetType, targetId, assignee }withtargetTypeascontact/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 ownid. The assignee comes along while they belong to the organisation that acts on the target, otherwise the task lands unassigned; sendassignee(a user id orme) to choose who it lands with. A completed or canceled task, and a target that is the task's own record, return400; a task or target you cannot reach returns404.POST .../tasks/{taskId}/replaceon the same three routes restates a task'stype,dueDate,assigneeornote(the newTaskReplaceRequest). A field you omit keeps the task's own value; sendnullto 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 newid, and the original is canceled. A replace asked twice finds its original canceled and returns400.- Both verbs take an optional
If-Matchheader — theetagof the task's row on the activity list — and refuse with412when the task has changed since; without it, a change that lands while the request runs is refused with409. Send anIdempotency-Keyheader 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 with422. GET /api/v1/taskslists follow-up tasks across contacts, companies and prospects — yours by default. Filter byassignee(me,nonefor unassigned, a team member's user id, orany),window(overdue,today,next_7_days,later, ordone7for tasks completed in the last seven days; default the first three),type,contact_id,company_id,project_idand free textq;tznames the IANA time zone whose midnights cut the day windows (default your own);sortisdue(default),createdortype, prefixed-for descending;page_size1-100 (default 50) andcursorpage through it. Each item is the newTaskListItem: theTaskfields plussubject— the record the task hangs off (contact,company,prospect, orofferfor an offer you received) —project(a prospect's project),contact,company,note,createdUtcand, 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 (aprospecttarget is a prospect on your own project; anything else is 404). An empty filter value is ignored;tztakes at most 64 characters. A blankassigneeon a move or replace is refused — sendnull(replace) to unassign, or omit it — and so isassignee: "none"on an add, move or replace (it is no user id; sendnullor omit it).TaskReplaceRequest.noteadvertises its 1-10000 character bound in the schema. - The
GET /api/v1/tasksparameters publish the limits the list enforces: every repeatable filter (assignee,window,type,contact_id,company_id,project_id) carriesmaxItems50,qmaxLength100,tzmaxLength64, andsortitspattern. A request over a limit is refused with400.Task.id(and soProspectTask.idandTaskListItem.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, onecontactRef/companyRef/projectRef, free text) and one tool per task change —cancel_task,reopen_task,move_task(to another contact, company or prospect) andedit_task— each on a task named by its record (networkRef, orprojectRef+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_taskandcomplete_prospect_tasknow 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:fileandpurpose=contact/company/project— the word, as in every enum on this API; a number is refused with400) stores a picture and returns{ id, width, height, contentType, url }with201.POST /api/v1/images:importtakes{ url, purpose }(urlat 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 is429). PNG, JPEG, GIF and WebP up to 10 MB are accepted (a larger upload is answered413), 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.profilePictureIdon contact and company create andPATCH(the imageid;nullonPATCHremoves the picture), andcoverImageIdon project create andPATCH. The project response gainscoverImageUrl.- MCP:
create_contact,create_company,create_project,update_contact,update_companyandupdate_projectacceptpictureUrl(a public https address of a picture the user chose) orpictureFileId(an imageid); 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:batchUpdatesetsinterest,stage(withstageReason),classification,note,owner,addProjectTagsandremoveProjectTagson up to 100 prospects. The answer lists every requested id once, inupdated,unchangedorfailed({ prospectId, error, message, partiallyUpdated }, witherrorone ofvalidation_error,not_found_or_no_access,conflict— another request changed that prospect at the same time — andinternal_error). An owner a prospect cannot have fails that prospect, with nothing written to it, instead of a400. Send anIdempotency-Keyheader 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 whoseerroris retryable is resent in a new request under a new key.partiallyUpdatedistrueon a failed prospect whose earlier fields — anoteamong them — were already saved, whatever itserror; resend only what it is missing, leaving out the note (the same key with that new body is refused with422). An id list holding anullis refused with400, as is a field the request does not know — on the body or insideset(PATCH'sprojectTags,accessExpiryandexternalRefsare not batch fields).prospects:batchRemoveandprospects/tasks:batchrefuse an unknown field the same way. A prospect you cannot reach fails withnot_found_or_no_access, whichever verb finds it out of reach.POST /api/v1/projects/{projectId}/prospects:batchRemoveremoves up to 100 prospects asPOST .../prospects/{id}/removedoes, with the same answer shape; an already removed prospect isunchanged.POST /api/v1/projects/{projectId}/prospects/tasks:batchadds the same follow-up task to up to 100 prospects and answers each prospect's newtaskId. AcceptsIdempotency-Key, with the same retry rule for a failed prospect as the update.POST /api/v1/projects/{projectId}/prospects/classifyadds acodefield to each failed item (validation_error,not_found_or_no_access,conflict,internal_error), so a server fault can be told from a refusal;errorstill carries the reason in words. The schema marksprospectId,errorandcoderequired on each failed item, as every failed item has always carried them.- MCP:
update_prospectacceptsprospectRefs(up to 100) instead ofprospectRefto setowner,addProjectTagsandremoveProjectTagson many prospects at once;accessExpiry,tagsandexternalRefsstay one prospect at a time.remove_prospectacceptsprospectRefstoo, withpreview=true.prospectRefis no longer required on either tool; send exactly one of the two. The bulk answers countupdated/removed,unchanged(already as asked, or already removed) andfailed; a prospect another request changed at the same time fails with a message saying it is safe to retry. Each failure of a bulkupdate_prospectcarriespartiallyUpdated,truewhen the owner or tags landed before a later field failed, so the agent resends only what is missing.
Deprecated
- The prospect's
classificationfield and theclassification=list filter. They report only the last classification written through that field and are not updated byinterest/stagewrites; readinterestandstage. MCP no longer servesclassificationon prospects, andlist_prospectsno longer takes aclassificationfilter. Theclassificationquery parameter onGET /api/v1/projects/{projectId}/prospectsis markeddeprecated: truein the OpenAPI document. - The
classificationfilter onGET /api/v1/network/contacts/{id}/projectsandGET /api/v1/network/companies/{id}/projects, and theprospectClassificationfield on the projects they list. Both carry the same legacy single-value classification, whichinterest/stagewrites do not update; read the prospect (prospectId) for itsinterestandstage. The filter is markeddeprecated: truein the OpenAPI document.
Changed
- Breaking change for MCP clients:
change_taskis split intocancel_task,reopen_task,move_taskandedit_task, andupdate_activityintoarchive_activity,unarchive_activityandrevise_note(formerlyaction: supersede). Each tool does one thing, carries its own annotations (reopen_taskandunarchive_activityare no longer marked destructive;cancel_task,reopen_task,archive_activityandunarchive_activityareidempotentHint: 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 removedactionfield (revise_noterequirescontent). Answers: the task tools as before withoutaction;archive_activity/unarchive_activityansweractivityId,isArchivedandarchivedAtUtc(always present, null when active);revise_noteanswersactivityIdandpredecessorId. - MCP: the server is its own OAuth resource,
https://app.propstreet.com/mcp— the protected-resource metadata at/.well-known/oauth-protected-resource/mcpnames it, and/mcpaccepts only tokens issued for it or for Propstreet as a whole (personal access tokens, and tokens requested without aresource). A connection made before this change gets401once and signs in again; an OAuth client registered without any resource permission no longer reaches/mcpuntil one is added. - MCP tool annotations: every tool that can overwrite, clear or remove data is
destructiveHint: true(allupdate_*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 areopenWorldHint: true. - MCP: an unexpected server error answers
internal_errorwith what to do next, instead of a bare "An error occurred"; a missing or malformed argument answersinvalid_parameter. - MCP descriptions:
create_link,delete_mandate,get_meand thepictureUrlinputs say exactly what they change and what a retry repeats;set_prospect_contact_personis titled "Set Prospect Contact Person"; the server instructions name only tools every caller has. POST /api/v1/projects/{projectId}/prospects/{id}/removedocuments its409 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-duplicateslooks 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_activitynow takes the same path as the task endpoints (un-cancelling a task on a contact or company that has since been deleted isnot_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}andPATCH /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 MCParchive_activity/unarchive_activitydo: archiving the completion of a canceled task is refused (400, "The task is canceled."), and a lost race on a task row answers409in the task's words.POST /api/v1/projects/{projectId}/prospects/{id}/tasks/{taskId}/canceland.../reopenrecord the request'sIdempotency-Keylike the contact and company task verbs.- MCP:
add_contact_taskandadd_company_tasklook up the contact or company before theidempotencyKey, 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 aftertomorrowhas 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_prospectnow removes the prospect the way the app andPOST .../prospects/{id}/removedo: it leaves the shortlist with its timeline history kept, emitsprospect.updatedwithaction.type: "removed", and stays readable. Before, it deleted the prospect likeDELETE .../prospects/{id}(aprospect.deletedwebhook and a404afterwards). Its preview now reportsreversible: true. PATCH /api/v1/projects/{projectId}/prospects/{id}with the owner the prospect already has writes nothing, soupdatedUtcand theETagstay as they were.- An
owneror project-tag change on a prospect your side removed is refused (400, "Prospect is removed") onPATCH .../prospects/{id},prospects:batchUpdateand MCPupdate_prospect; before, the owner of a removed prospect could still be changed.PATCHand MCPupdate_prospectdecide this — and a project-tag list past the limit — before anything is written, so a request that also setsexternalRefs(or, on MCP,ownerortags) no longer saves those fields and then answers400. - 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) andremove_prospectanswers count a reference that did not resolve infailed, sofailedalways matches theerrorslisted; a prospect that failed after earlier fields of the call were saved says so in its error. - MCP:
log_activitydescribestype=newsas news about the company or contact, and atype=newson a project, prospect or offer is refused with a pointer tonetworkRefon the company or contact instead of totype=note. - Language codes (
primaryLanguageon contacts) accept any case and a region subtag (sv-SE→sv), andnofor Norwegian (stored asnb). An unknown code is refused with400(validation-failed, listing the accepted codes) on create,PATCH, upsert and per item in batch upsert. - Before a teaser is first saved (
id"0"), itscreatedUtcandupdatedUtcare the creation time of the project, Case Room or property it belongs to; before, they were the time of the read, soupdatedUtcappeared to move on every read. Theteasersummary on a project now carries theid, save timestamps andetagof the teaser the teaser endpoint serves (before, it always readid"0"). - A teaser that belongs only to a Case Room no longer puts its project at teaser stage
drafting— on the project'steasersummary and theteaser_stagefilter — 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 answered200and cleared the stored language; it is now refused with400like create andPATCH. profilePictureUrlon contacts and companies was alwaysnull; it now carries the download URL when the record has a picture, and that URL — like the team and/auth/mepicture URLs — downloads the picture instead of answering404.- File download URLs (
urlon note attachments and files, picture URLs) are/api/v1/files/{id}again. POST /api/v1/filesin the schema is now a multipart upload with a binaryfilefield. It was described as a form-encoded object with the upload's internal properties, so generated clients could not call it. Thefilefield is marked required on it and onPOST /api/v1/images, as both endpoints always answered400without one; the endpoints themselves are unchanged.UpdateContactRequestin the schema now matches what the endpoint accepts. It advertisedblockedand an integerprofilePictureId, neither of whichPATCH /api/v1/network/contacts/{id}ever applied (the values were ignored).blockedis gone from the schema and is still ignored if sent;profilePictureIdis now a real field and, like every id on this API, a string — a client that sends it as an integer now gets400.- Batch prospect requests in the schema now state their one-item minimum.
prospectIdsonPOST .../prospects/classifyanditemsonPOST .../prospects:batchcarryminItems: 1, anditemsalsomaxItems: 100, matching what the endpoints already enforced: an empty list was, and still is, refused with400. The new bulk endpoints declare the same bounds onprospectIds. - An owner change now emits
prospect.updatedwithchanged_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 itsowner. The Changed Fields reference now also listsproject_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}/tasksandPOST /api/v1/network/companies/{id}/tasksadd a task (typeascall/meeting/email,dueDateastoday/tomorrow/7d/ an ISO date / an ISO datetime — a date without a time is due by the end of that day, UTC — plus optionalassigneeandnote) and return it with201. The response is the newTaskobject,{ id, type, dueDateUtc, assignee, overdue }, and the body is the newCreateTaskRequest— field for field the prospect task'sProspectTaskandAddProspectTaskRequest, which keep their names and fields. AdueDatein the past is accepted and records a task that is already overdue, and an entry can carry any number of open tasks. Send anIdempotency-Keyheader 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 with422. The entry is looked up first, so a retry on an entry that has since been deleted returns404. If the first request ended without a known outcome (its commit timed out), a retry under the same key is not run again: it returns409with 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}/completecompletes 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}/cancelcancels 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}/reopenundoes a task's completion (204), so the task is open again. A task that is not completed, or that was canceled, returns400.- Task ids. Every task verb takes either the task's
id— the value returned when it was added — or theidof itstask_addedrow on the entry's action list, so a caller syncing fromGET .../actions?type=taskscan 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 returns404. A contact id on a companies route (or the reverse) also returns404. - New action-timeline events. On the per-entry list
GET /api/v1/network/{contacts|companies}/{id}/actions, a task and its completion aretype=auditrows carryingevent: "task_added"andevent: "task_completed"; the note a task was filed with rides indetails.message.type=auditon 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 itstask_addedrow and reopening one archives itstask_completedrow (details.archivedAt, listed withinclude_archived=true);eventtells them apart, since both kinds carrytype: "audit". On the rolled-up broker timelines, where a row can come from either family, the same rows are namedcontact.task_added,contact.task_completed,company.task_addedandcompany.task_completed. Atask_completedrow names the task it closed indetails.taskActivityId: theidof that task'stask_addedrow on the same list, which the task verbs also accept. The prospect timelines carry the same field onprospect.task_completedrows. type=taskson the network action lists selects just the task rows.- With
include_archived=true, a canceled task and an undone completion come back withdetails.archivedAt(when it was archived); active task rows carry none, as on the prospect timelines. - MCP:
add_contact_task,complete_contact_task,add_company_taskandcomplete_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.idnames the complete, cancel and reopen endpoints, andAddProspectTaskRequest.assigneeasks 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 the400detail 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 document409 Conflict: when two verbs race on one task (a cancel against a completion), the one that commits second is refused with409rather than leaving the task both canceled and completed. Fetch the task again and retry. - An
Idempotency-Keyon a prospect create or a prospect task names its target as well as the body.POST /api/v1/projects/{projectId}/prospectshashes the project and the body;POST .../prospects/{id}/taskshashes the prospect and the body (the project is checked before the key is looked at, so a prospect outside the project in the URL is404). 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 with422instead 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 with422; nothing is created twice. - MCP:
add_prospect_task,add_contact_taskandadd_company_taskhash 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 aftertomorrowhas come to mean another day or a prospect has left the project, and two batches that refuse different references are two requests (422under 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:
intereston the prospect response — your judgement of the prospect's appetite:interested,maybe,not_interested;nullwhen none is recorded.maybeis new: it had no word in the old vocabulary.stageon 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.stageOverrideon the prospect response — the latest manual stage correction,{ stage, setBy, setAtUtc, reason }wheresetByis{ id, name }; omitted when the stage was never overridden.PATCH /api/v1/projects/{projectId}/prospects/{id}acceptsinterest(sendnullto clear),stage(a manual correction, returned asstageOverride; later events can still raise it) andstageReason(only together withstage).DELETE /api/v1/projects/{projectId}/prospects/{id}/stage-overridewithdraws the manual stage correction (204, also when there is none). Every correction on the prospect is withdrawn, sostageis derived from what has happened again andstageOverrideis omitted.GET /api/v1/projects/{projectId}/prospectsacceptsinterest=(interested,maybe,not_interested, ornonefor prospects without a judgement) andstage=filters. They combine with each other and withcapability/tags/classification.- New action-timeline events on
GET .../prospects/{id}/actionsand the project / offer timelines:prospect.interest_changed(an interest judgement recorded or cleared) andprospect.stage_overridden(a manual stage correction;details.messagecarries the reason when one was given). Both aretype=auditrows. - MCP:
set_prospect_interest(interestinterested/maybe/not_interested, ornoneto clear; optional note) andoverride_prospect_stage(stage + optional reason);list_prospectsgainsinterestandstagefilters;get_prospectreturns the three new fields.
Follow-up tasks on prospects — dated calls, meetings and emails you can complete, cancel and reopen:
nextTaskon the prospect response — the nearest-due open follow-up task,{ id, type, dueDateUtc, assignee, overdue }; omitted when the prospect has no open task.typeiscall,meetingoremail;overdueis true once the due instant (dueDateUtc) has passed.openTaskCounton the prospect response — how many open follow-up tasks the prospect carries.POST /api/v1/projects/{projectId}/prospects/{id}/tasksadds a task (type,dueDateastoday/tomorrow/7d/ an ISO date / an ISO datetime — a date without a time is due by the end of that day, UTC — plus optionalassigneeandnote) and returns it with201. AdueDatein the past is accepted and records a task that is already overdue. A prospect can carry any number of open tasks. Send anIdempotency-Keyheader 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}/completecompletes 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}/cancelcancels a follow-up task (204). The task no longer counts innextTask/openTaskCountand 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}/reopenundoes 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, returns400.- Task ids. Every task verb accepts either the task's
id— returned when it was added and served asnextTask.id— or the id of itsprospect.task_addedrow 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 returns404. - Canceling a task, reopening one and withdrawing a stage correction change what the prospect answers, so they also advance the prospect's
updatedUtcandETag: a delta sync onupdated_sincereturns the prospect, and a staleIf-Matchis refused. - A canceled task, an undone completion and a withdrawn correction leave the default activity timeline;
include_archived=truestill returns them asauditrows carryingdetails.archivedAt— when the entry was archived, the same field archived notes carry. Active rows carry noarchivedAt. GET /api/v1/projects/{projectId}/prospectsacceptstask_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, indetails.message) andprospect.task_completed;type=taskslists just the task rows. - MCP:
add_prospect_task(type, due date, optional assignee and note, one or many prospects) andcomplete_prospect_task(by the task's id or its timeline id, looked up on that prospect only;not_foundotherwise);list_prospectsgains ataskDuefilter;get_prospectreturnsnextTaskandopenTaskCount.
Project-level tags on prospects — your labels on an investor in one deal, separate from the contact's own tags:
projectTagson 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 existingtagsfield keeps serving the contact's or company's network-wide tags.PATCH /api/v1/projects/{projectId}/prospects/{id}acceptsprojectTags— the whole list (send[]to clear). Values are trimmed and de-duplicated (case-sensitive) and a multi-word value stays one label.tagsis untouched.GET /api/v1/projects/{projectId}/prospectsacceptsproject_tags=(repeat the parameter; OR semantics, case-insensitive). It combines with every other filter, includingtags=.- Webhooks:
prospect.updatedfires when the project tags change, withproject_tagsinchanged_fields. - MCP:
update_prospectgainsaddProjectTags/removeProjectTags(the existingtagsparam still replaces the contact's or company's network tags);list_prospectsgains aprojectTagsfilter;get_prospectreturnsprojectTags.
Classification comments on the action timelines:
details.messageonprospect.classifiedrows — the comment the broker wrote with the classification. Present only when a comment was written; a classification without one keeps omittingdetailsentirely.
Changed
- New projects are created with an explicit
activeclassification.POST /api/v1/projectsand MCPcreate_projectrecord anactiveclassification on the project as part of creating it, so the created project readsclassification: activeand appears underclassification=activeright 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)activeonce the teaser is published, anddraftbefore 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)classificationis 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/classifyand MCPclassify_prospectsaccept the same optionalnoteas 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 — anoterow carrying the text indetails.message, and theprospect.classifiedaudit row, which no longer repeats it. The note behaves like every other prospect note: it has its own id andETag, appears undertype=notes, and can be archived or superseded throughPATCH /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
failedwith the note's reason instead of being counted inclassified, 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=notesnow also returns commented classifications. OnGET /api/v1/projects/{id}/actions,GET /api/v1/projects/{projectId}/prospects/{id}/actions, andGET /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 —typestaysaudit,eventstaysprospect.classified— and is still returned bytype=audit, so the two filters overlap on it. It is the same row in both: dedupe byidif you union the filters, and read the comment fromdetails.message.- MCP:
update_activitynow names the record the row belongs to —networkReffor a contact or company,projectRefalone for a project note,projectRef+prospectReffor a prospect, orofferReffor an offer, the same scopelog_activitytakes. 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
nameyou set through the API survives edits made in the Propstreet app.PATCH /api/v1/properties/{id}and MCPupdate_propertywrite 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
classificationkeeps its meaning and its values. It is still the single-value classification you last wrote (not_contactedby default), read back exactly as written, andclassification=still filters on it; the two new axes are additive beside it. Writingclassificationnever lowers astageor clears aninterest— use thestage/interestfields for that. One addition: writingbidderalso recordsinterested, since a bid is interest. When a PATCH sendsclassificationtogether withinterest/stage, the two axes win.- The mapping runs one way only — the two axes never write back to
classification. Settinginterestor overridingstageleavesclassificationexactly as you last wrote it, so the two drift apart as soon as you use the axes. A prospect you classifiedinterestedin the spring and markednot_interestedlast week still answersclassification=interested, and a prospect whose recorded interest isinterestedcan still carry the classificationnot_contacted. Filter, report and route oninterestandstage; readclassificationas the last word that was written, not as a summary of where the prospect stands today. prospectClassificationon 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 forinterestandstage.
[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. Returns409when 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; sendnullto 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 (otherwise400); sendnullto clear a scheduled expiry.- The prospect response (
ProspectPublicDto) now includes a read-onlyownerobject ({ id, name }) when an owner is assigned.
Optional note on batch classification:
POST /api/v1/projects/{projectId}/prospects/classifyaccepts an optionalnote, 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 afileobject (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: setcategoryand/orpriority, or route it to a team member viaassigneeRef.
MCP Q&A rollups:
list_roomsitems now includeopenQuestionCount.get_roomnow includes aqablock with per-status thread counts and the room's Q&A window.
Offer inbox categories:
GET /api/v1/offersand MCPlist_offersnow include acategoryon each row:buying,invitation, orselling.filter=allreturns the complete visible offer inbox across those categories.filter=invitationsreturns pending Case Room / Seller Portal invitations withcategory=invitationandstatus=pending;filter=sellingreturns accepted Seller Portal offers where the caller is the seller withcategory=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, orsupersede.- archive soft-hides the note and stamps
archivedAt. Idempotent — re-archiving an already-archived note is a no-op204. - unarchive clears
archivedAt. Returns400when 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
supersedeobject with the fields to change; unset fields inherit from the predecessor. Thesupersedeobject also acceptscontentFormat(plaintextormarkdown) on every note type — contact, company, prospect, and project notes.
- archive soft-hides the note and stamps
If-Matchis required on these four endpoints. Unlike every other V1PATCH/DELETE(whereIf-Matchis best-effort), a missing or blankIf-Matchreturns428 Precondition Required(/help/precondition-required). A staleIf-Matchstill returns412 Precondition Failed(/help/precondition-failed). Fetch the resource, copy theETag, then retry. A lost optimistic-concurrency race — a concurrent revision committed between your read and write — returns409 Conflict(/help/concurrency-conflict); re-fetch theETagand retry. These PATCH endpoints are not replay-cached byIdempotency-Key; use theETagprecondition 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):
GETaction/note responses now exposesupersedesActivityId(the predecessor a revision replaced),supersededByActivityId(the successor that replaced this note),archivedAt(when the note was archived; null when active), andcontentFormat(markdownfor markdown notes; null for plain text).NetworkAction.detailsis now a typed OpenAPI union. Usedetails.kind(noteoraudit) to discriminate the payload; generated clients expose the note revision fields on thenotedetails 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(defaultfalse) surfaces archived notes alongside theirarchivedAt, andinclude_superseded(defaultfalse) surfaces the full revision chain with the entries linked viasupersedesActivityId/supersededByActivityId. Both default off, so existing integrations see no change.
Project & prospect activity timelines (new):
GET /api/v1/projects/{id}/actionsandGET /api/v1/projects/{projectId}/prospects/{id}/actionsare new full activity timelines. Each row carries atypeofnote,chat, orauditand a typeddetailsunion discriminated bydetails.kind(which mirrorstype).chatrows 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 (ondetails.kind = chat).auditrows carry a stable dot-notationeventstring (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. (chatrows carry aneventtoo, e.g.prospect.message_sent/prospect.file_shared/prospect.call_logged.)- New query params on both endpoints:
type(all|notes|chat|audit, defaultall) filters by row type, andinclude_rollups(defaulttrue) controls cross-entity interleaving. On the project timelineinclude_rollups=trueinterleaves the project's prospect and Case Room activity (each rolled-up row carries asubjectofprospectorroom); on the prospect timeline it interleaves the prospect's Case Room activity and its linked contact/company notes (subjectofroom,contact, orcompany). Setinclude_rollups=falseto scope the timeline to the entity's own actions (nosubject). - The row
id(and thesupersedesActivityId/supersededByActivityIdlink 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 MCPlist_activities/update_activityuse.
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 nosubject, and conversation entries appear astype = chat. Only what the recipient is entitled to see appears — broker-private notes and broker-internal events never surface here. Read-only: rows carry noetag. Supports the sametype,include_rollups,include_archived, andinclude_supersededparams.
Changed
- V1
ETagheaders are now strong ("…") rather than weak (W/"…"). Strong ETags are usable withIf-Matchstrong comparison per RFC 9110. If you previously stripped a leadingW/before sendingIf-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-facingprojection.GET /api/v1/properties/{id}/teaser— pre-market teaser for a property. Returns a thinproperty_addedenvelope (withnudge.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 plusnudge.code = room_teaser_not_designedwhen 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
tagsfield — what investors see on the teaser. Send a list to write that snapshot verbatim (deduped and clamped totemplate.tags);[]writes an explicit empty snapshot;nullresets 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/ETagparity with the rest of V1 — every GET returns anETagheader andetagbody field; every PATCH supportsIf-Matchand returns412 Precondition Failedon stale; omitIf-Matchto skip the precondition check (V1 convention).- All three routes return the same teaser shape:
stage, timestamps (publishedUtc,verifiedUtc,communicatedUtc),options,template,projection, optionalnudge, and anetagfield. - 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. ReturnsprimaryTeam: nullandsecondaryTeams: []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 byrole=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 byall,active,ongoing,inactive,removed,unread, orunanswered.filter=unansweredreturns 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 return404 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(andcurrency/scalewhere applicable). Exact values appear asmin == 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 orid=/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) andnegativeTags(deal-breakers) — which are mutually exclusive. - Geographic input accepts
geography(GeoJSON Polygon / MultiPolygon),near+radius(point and radius), orlocation(free-text locality resolved server-side). Exactly one mode is allowed per request. Write responses echo aresolvedLocationhint;GETresponses do not. - Mandate responses include the standard audit/sync fields:
etag,changeType,changeOrigin, anddeletedUtc. - External reference support: attach third-party ids via
externalRefs(namespace + externalId pairs) for linking mandates to records in your CRM or other systems. PATCHis 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, andupdatedSincefilters.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.mandateis now aliased by the newproject.exclusivityfield. Both names carry identical values (exclusive,non_exclusive,other) and are populated on every read response. Webhookproject.updatedevents list both names inchangeswhen the value changes.- Write requests (
POST /api/v1/projects,PATCH /api/v1/projects/{id}) accept either field. Sending bothmandateandexclusivitywith non-null values on the same request is rejected with422 validation_failed; sending neither leaves the value unchanged. - MCP tools
create_projectandupdate_projectaccept onlyexclusivityon write. Reads still return bothmandateandexclusivityon every project object for now. mandatewill be removed entirely in a future minor version. Migrate integrations toexclusivitynow.
[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, anddeletedUtc.
Company Duplicate Detection:
POST /api/v1/companies/find-duplicates— Check for potential duplicate companies before creating. Acceptsname(required),domain,countryCode, andlinkedInUrl. Returns matching candidates ordered by relevance (same-country matches first whencountryCodeis provided). Each result includesid,name,domain, andcountryCode.
Company Fields:
linkedInUrlon 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_companynow performs duplicate detection before creation and returns a conflict with candidate details unlessforce=true. This prevents accidental duplicate company creation by AI agents.
Changed
Property Contract Clarifications:
- Area write payloads must include
unit: "sqm"onparcelArea,zoning.gfa,rentedArea, andvacantArea. Read responses remain normalized tosqm. - Property tags are documented enum-backed snake_case values; unknown tags are rejected with
400 Validation failedinstead of being silently dropped. - Property PATCH location updates preserve tri-state semantics for nested
countryandlocality, and late write collisions now return409 Conflict.
Prospect Tag Filters:
- Breaking change:
GET /api/v1/projects/{id}/prospectsnow accepts repeatabletagsquery parameters with OR semantics instead of a singletagstring. Example:?tags=vip&tags=nordic.
Property Tag Filters:
- Breaking change:
GET /api/v1/propertiesnow accepts repeatabletagsquery parameters with OR semantics instead of repeatabletag. Example:?tags=core&tags=value_add.
MCP Tool Tag Filters:
- Breaking change: MCP list tools now use array-based
tagsparameters consistently.list_companies,list_contacts, andlist_prospectsno 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}andDELETE /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.updatedwebhook withaction.type: "removed". The contact/company deletion itself firescontact.deleted/company.deleted. - Timeline history for affected projects is preserved — prospects remain visible in project activity logs.
- MCP
delete_contact/delete_companypreview (preview=true) now includesaffected_projectsshowing which projects will be affected.
Fixed
Delete Webhook Payloads:
project.deletedandprospect.deletedwebhook payloads now includeexternal_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, sinceGETreturns 404 after deletion.- Prospects are now cascade-deleted when their parent project is deleted, ensuring
prospect.deletedwebhooks 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,invitationindicate prospect status - Classification pipeline - Track prospects:
not_contacted→contacted→interested→bidder - Filtering - Query prospects by
capability,classification, ortag - Cursor-based pagination with delta sync (
updated_since,include_deleted) - Idempotency support via
Idempotency-Keyheader - ETag-based optimistic concurrency control
Associated Projects:
GET /api/v1/network/contacts/{contactId}/projects— List projects where a contact is a prospectGET /api/v1/network/companies/{companyId}/projects— List projects where a company is a prospect- Cursor-based pagination with
page_sizeandcursorparameters - Filter by
status(open/closed) andclassification(prospect pipeline stage)
Webhook Events:
project.created- Fired when a new project is createdproject.updated- Fired when project fields change (name, status, classification, etc.)project.deleted- Fired when a project is deletedprospect.created- Fired when a prospect is added to a projectprospect.updated- Fired when prospect classification or other fields changeprospect.deleted- Fired when a prospect is deleted from a project- Webhook payloads include
actionfield 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 textcontent. 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 includecontact_idandcompany_idfields in the payload. This is critical forlink.deletedevents 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.createdwebhook event. Previously, reactivating a deleted link was silently treated as an update with no webhook fired.
Changed
OpenAPI Schema Cleanup:
- Removed
Dtosuffix 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 URLdomain- Company domain, can be:- Set directly via API (e.g.,
"domain": "acme-corp.com") when only domain is known - Auto-extracted from
homepageUrlwhen URL is provided (e.g., "acme-corp.com" from "https://www.acme-corp.com/about") - When both are provided,
homepageUrltakes precedence for domain derivation
- Set directly via API (e.g.,
- Both fields now searchable and sortable via
/api/v1/network/companies:search - Domain changes tracked in audit history and webhook payloads
Profile Pictures:
profilePictureUrlon contacts and companies - Download URL when profile picture is set- Profile picture changes now tracked in webhook
changed_fieldsasprofile_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
- Note: Initially shipped as
- 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_fieldsreference 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_sinceandinclude_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:
- Deprecation notice - Announced in changelog and API responses
- Migration period - Minimum 6 months before removal
- 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 to join our developer updates list