Comps
Market transactions: the deals of your closed projects and the deals your team records — each on a property with its facts as of the deal, with parties, sources, history and analysis.
A comp is one market transaction your organisation knows of. Every closed project of yours becomes a comp automatically (the close is the deal); every other comp is recorded by your team — from the press, from a market contact, from a call. A comp is anchored on exactly one property: one of your own properties, or a property recorded for the deal alone (somebody else's building, which never appears on /api/v1/properties). A portfolio is one record with the portfolio's totals and a property count.
Comps are visible to your whole organisation and are part of the Professional plan: every comp route answers 403 with the problem type /help/plan-required for an organisation on another plan (see Errors & Retries). A member of several organisations reaches the comps of their active organisation only — the one they act for in Propstreet — and the plan is that organisation's; a comp of another of their organisations answers 404 until they switch to it.
Quick Reference
| Feature | Details |
|---|---|
| Resource | /api/v1/comps |
| Sync | GET /api/v1/comps — cursor-paged, oldest change first; updated_since, include_deleted (deleted and voided deals); hidden deals are always in the feed |
| Search | POST /api/v1/comps:search — the app's filters plus locality, country, area, price, yield, party, advisor and project; limit / offset with total |
| Analysis | POST /api/v1/comps:summary — the same filters → counts, latest deal, yield and price-per-square-metre medians with what they leave out, deals and volume per quarter, top buyers and sellers |
| Property search | POST /api/v1/comps/properties:search — your own properties and the properties recorded for your deals, by name, street or locality, to anchor another deal on the same record |
| Read | GET /api/v1/comps/{id} (with ETag), POST /api/v1/comps:batchGet, GET .../{id}/parties, GET .../{id}/sources, GET .../{id}/property, GET .../{id}/actions |
| Write | POST /api/v1/comps (idempotent, duplicate check), PATCH .../{id} (If-Match), DELETE .../{id}, POST .../{id}:restore, POST .../{id}:hide / :unhide, POST /api/v1/comps:merge (idempotent), POST/DELETE parties and sources, PUT .../{id}/property, PUT .../{id}/property/facts, PUT .../{id}/property/picture, POST .../{id}/notes |
| Vocabulary | A deal's project is a project; a party or a source names a contactId or a companyId; every enum is a word (sale, confirmed, seller_advisor, own_project, …) |
| Currency | Prices are never converted. Every read works in one request currency (your own when omitted); price filters and sorts compare only deals priced in it |
| Authorization | Broker organisations on the Professional plan |
How Comps Work
Closed project ──(the close)──▶ Comp ◀──(recorded)── Your team
│
├── property one record, facts as of the deal (propertyId; ownProperty tells your own from one recorded for a deal)
├── parties[] seller, buyer, seller_advisor, buyer_advisor → contactId | companyId | your own firm
├── sources[] article | activity (a note) | contact | company, each under a channel: news | market_contact
└── actions the history: every change with old and new values
- One date.
dealDateis the completion date unless it is only planned, else the signing date, else the planned completion. Period filters and the analysis always use it;signedOnandcompletedOnare also sortable. - Honest numbers.
priceis in full currency units withpriceCertainty(confirmed,reported,estimated) andpriceApproximate;priceUnknown: truemeans the price is known to be unknown.yieldis a plain number in percent (5.0 = 5%), withyieldCertaintyandyieldApproximatebeside it — a number here, unliketransactions[].yieldon a project, which is a value-or-range object. Land has a price but never a yield or apricePerSqm. - Sources, not recorders.
sources[]say where your team learned about the deal;createdViasays who recorded it (app,api,agent,system). Your own closed project is not a source — it isproject. - Source channels.
sourceChannels[]lists every channel the deal counts under in the source filter:own_project(a closed project of yours),news(an article, or a news note),market_contact(a contact, a company, or one of their calls, meetings, e-mails or notes), ornone. A deal that came from an article and was confirmed by a call counts under both. - What is missing.
missing[]names what keeps the deal from being complete (price,buyer,seller,property,location,address,property_count,facts_at_deal_date,completion_confirmation).completion_confirmationmeans the planned completion date has passed: confirm the completion withcompletedIsPlanned: false(a closed project's deal: correct the project's close) or move the date. Treatmissing[]as open-ended — a new value may be added. - Hidden deals. A deal from a closed project can be hidden from the list for the whole organisation (
hidden,hiddenUtc). The search leaves hidden deals out unless you sendincludeHidden; the analysis never counts them; the change feed always carries them, so a hide reaches your copy. - Access to the project. Everybody in the organisation sees the deal; the project behind it keeps its own access rule. When the caller may not open the project,
projectcomes back withaccessible: falseand noidorname. The same holds for anactivitysource on such a project:accessible: false, noactivityIdand no text.
Sync: GET /api/v1/comps
Streams every comp of your organisation in the order it last changed, oldest first, as a PagedResponse of comps. Follow page.nextCursor until hasMore is false; store the newest updatedUtc you saw and pass it back as updated_since next time.
GET /api/v1/comps?updated_since=2026-10-01T00:00:00Z&include_deleted=true&page_size=200
| Parameter | Description |
|---|---|
updated_since |
Only deals changed on or after this UTC timestamp; with include_deleted, the deletions since then too |
include_deleted |
Also deleted and voided deals. A deleted deal carries deletedUtc and removalReason: "deleted"; a deal whose project was corrected to no sale carries deletedUtc (= voidedUtc) and removalReason: "voided" — treat both as gone |
kind |
sale (the only kind today) |
page_size |
1–500 (default 500) |
cursor |
The nextCursor of the previous page |
A change to a deal's parties, sources, property, notes or hidden state moves its updatedUtc, so a delta sync sees it. Hidden deals are in the feed with hidden: true. Clocks and in-flight writes differ: on each run, re-read with an updated_since a few minutes before the newest updatedUtc you hold and treat a deal you already have as an update (dedupe by id); nothing is lost that way, and nothing is counted twice.
Search: POST /api/v1/comps:search
Filters and pages through deals as a grid, with the total. Voided deals are never listed.
{
"category": ["office"],
"period": "last_24_months",
"sourceChannel": ["own_project", "news"],
"locality": ["Stockholm", "Solna"],
"priceMin": 100000000,
"currency": "SEK",
"sort": "-dealDate",
"limit": 50,
"offset": 0
}
| Filter | Description |
|---|---|
query |
Free text over the deal's name, its street address, the property, the place and the parties' names (at most 100 characters) |
category[] |
Property types: residential, office, retail, industrial, social_infrastructure, hotel, land, special_purpose |
period, from, to |
On the deal date: last_12_months, last_24_months, last_5_years, all (default) or custom with from and to (inclusive dates) |
sourceChannel[] |
own_project, news, market_contact, none — any of them |
within |
A GeoJSON Polygon or MultiPolygon ([longitude, latitude]), as on the property and mandate searches; only deals whose property's address lies inside match (an area-only location never does). Not with near |
near + radius |
A point { latitude, longitude } and a radius in metres (at most 100000); the pair is required together |
incomplete |
Only deals with something in missing[] |
includeHidden |
Also hidden deals (search only — the summary never counts them) |
locality[], country |
Localities (any of them) and an ISO 3166-1 alpha-2 country code |
areaMin, areaMax |
Leasable area, in square metres |
priceMin, priceMax |
In full units of the request currency; deals priced in another currency never match |
pricePerSqmMin, pricePerSqmMax |
In the request currency |
currency |
ISO 4217; your own when omitted. Echoed as currency on the response |
yieldMin, yieldMax |
In percent |
partyId + partyRole |
A contact or company id that is a party (or a party's contact person), in any role or the given one (seller, buyer, seller_advisor, buyer_advisor) |
advisedById |
A company id that advised on either side |
projectId |
The deal of one project of yours |
sort |
Omitted, the newest deal comes first. dealDate, signedOn, completedOn, price, yield, pricePerSqm, leasableArea; a name alone sorts ascending, prefix - for descending (-dealDate is the default order). Deals without the value sort last; on a price sort, deals in another currency come after those in the request currency |
limit, offset |
1–100 (default 100) and 0–10,000 |
The response is a SearchResponse of comps (results, total, hasMore, offset, limit) plus currency.
Analysis: POST /api/v1/comps:summary
Takes the same filters and answers the numbers the Propstreet app shows for that selection:
| Field | Description |
|---|---|
count, withoutPrice, landCount |
Deals in the selection, those without a known price, and land deals |
latest |
The latest deal by deal date, whether or not its price is known |
yield, pricePerSqm |
A median with min, max, count and approximateCount, computed only over the deals that carry the value — your own estimates, land and portfolios (a portfolio's figures blend its property types) are left out and counted under excluded (withoutValue, estimated, land, otherCurrency, portfolio, otherMainType). A deal of several property types counts under its main type (the largest area) only: in another type's median it is left out and counted as otherMainType. Medians need exactly one property type; otherwise reason is select_one_category (not_applicable_for_land when only land is selected) |
quarters[] |
Per quarter of the deal date: count (every deal) and volume (the summed price of the pricedCount deals priced in the request currency) |
topBuyers[], topSellers[] |
The most active contacts and companies in your network — contactId for a person, companyId for a company — with their deal count and volume; your own organisation and removed records are not ranked |
reason |
too_many when the selection holds more than 5,000 deals: only count, withoutPrice and landCount are answered; latest is empty, both medians carry reason: too_many, and quarters, topBuyers and topSellers are empty lists — narrow the filters |
Reading a Comp
A comp carries, besides the fields described above: id, uri (an opaque propstreet://comp/{id} deep link, not a web address), kind (sale), name (the deal's own name, else the property's), category and type (the main property type, and the type with its sub-type as the property endpoints spell it, e.g. industrial/warehouse), streetAddress and locationPrecision, dealDate, signedOn, completedOn, completedIsPlanned, the price and yield fields with pricePerSqm, project, property (with factsAsOf, the date of the facts locked when the deal was recorded), parties[], sources[] (each with archived when its note was archived, and createdBy), sourceChannels[], missing[], hidden / hiddenUtc, createdVia, createdBy, createdUtc, updatedUtc, etag, and on a removed deal deletedUtc, voidedUtc and removalReason.
GET /api/v1/comps/{id} answers the comp with its ETag; POST /api/v1/comps:batchGet takes { "ids": [...] } (at most 100) and answers found and notFound. A voided deal answers 404, as a deleted one does.
The sub-resources read one part of the deal:
GET /api/v1/comps/{id}/parties— the parties. A party is acontactId(a person) or acompanyIdin your network, or your own firm (ownFirm: true, no id). A company party may carry its contact person — the person your team dealt with there — ascontactIdnext tocompanyId, withcontactName; it is recorded with the party, so it stays when the person later moves to another company.fromProject: truemarks a party that came with the project's closing.removed: truemarks a record that was removed from your network; the party keeps pointing at it.GET /api/v1/comps/{id}/sources— the sources. Anarticlecarriesurl,title,publisherandpublishedOn; anactivitycarries the note'sactivityId(its current version on the activity timelines) and its text asname; acontactorcompanycarries the id and the name. Every source has achanneland may carry acomment. The app names two kinds of source: a news item — anarticle, or anactivityfrom the news watch (channelnews) — and a person or company — acontactorcompany, or anactivitythat is one of their calls, meetings, e-mails or notes (channelmarket_contact).GET /api/v1/comps/{id}/property— the property with its facts as of the deal:propertyId(send it asproperty.propertyIdto anchor another deal on the same record) andownProperty(truefor one of your own properties, also readable on/api/v1/properties/{id};falsefor a property recorded for a deal, read and changed through its deals only),isPortfolioandpropertyCount,pictureIdandpictureUrl(its download address) when it has a picture, the name, locality and country,location,categories[]with rented and vacant area,leasableArea,parcelArea,buildingRights,yearBuilt,rentedPercent,contractLengthYears,rentalIncome,netOperatingIncomeandtotalCosts.rentalIncomeandnetOperatingIncomeare in the deal's currency; a deal with an unknown price states them in the property's own currency, and a deal dated before the exchange rates go back leaves them out. A closed project without a property answers404.GET /api/v1/comps/{id}/actions— the history, newest first, as aPagedResponsein the shape of the other action timelines: each entry carries an opaquecursor,page_sizetakes 1–100 (default 100), andpage.nextCursorcontinues the read (a cursor that is not one answers400). Each entry is anote(its text indetails.message,details.kind: "note") or anauditwith aneventin the webhook vocabulary (comp.created,comp.updated,comp.hidden,comp.unhidden,comp.deleted,comp.restored), theuser,via(app,api,agent,system) andchanges[]— every changed deal field witholdValueandnewValueunder its public name (price,currency,signed_on, …) — pluspropertyChanges[]for the property's facts edited through the deal.
Recording a Deal
POST /api/v1/comps records a deal your team learned about. Send an Idempotency-Key to make a retry safe (see Idempotency).
{
"property": {
"name": "Kv. Verkstaden 3",
"locality": "Sundbyberg",
"country": "SE",
"location": { "latitude": 59.3808, "longitude": 17.9358 },
"categories": [
{
"type": "office",
"rentedArea": { "value": 9000, "unit": "sqm" },
"vacantArea": { "value": 1000, "unit": "sqm" }
}
],
"yearBuilt": 1998
},
"name": "Verkstaden",
"streetAddress": "Industrivägen 12",
"signedOn": "2026-03-01",
"price": { "value": 180, "currency": "SEK", "scale": "millions" },
"priceCertainty": "reported",
"priceApproximate": true,
"yield": 5.1,
"parties": [
{ "role": "buyer", "companyId": "4711" },
{ "role": "seller_advisor", "ownFirm": true }
],
"sources": [
{
"kind": "article",
"url": "https://example.com/news/verkstaden",
"title": "Verkstaden sold",
"publisher": "Fastighetsbladet"
}
]
}
- The property. Either
propertyId— one of your own properties, or a property recorded for another deal of yours; find either withPOST /api/v1/comps/properties:search(below) — or a new record for somebody else's building:locality,countryandlocationare required; send the locality's centre when only the district is known. A portfolio isisPortfolio: truewithpropertyCount(at least two) and the portfolio's totals. For land, sendparcelArea(andbuildingRights) instead of areas, and no yield. - The address.
streetAddressandlocationPrecisionare the deal's own, not the property's:locationPrecision: "area"shows only the deal's area (its address is not public, the map draws a ring). A deal without a street address listsaddressundermissinguntil it has one; one that shows only its area does not. Both are read on the deal (streetAddress,locationPrecision) and changed withPATCH, also on a deal from a closed project. - The numbers. A signing or a completion date is needed; a completion before the signing answers
422(the same holds for a project's close and its correction); no date is too far ahead — a signing, completion or planned completion in the future is recorded as sent.pricetakesscale; withoutpriceCertaintya price isconfirmed.priceUnknown: truerecords that the price is known to be unknown. - Rules are answered with
422: land takes no yield, a portfolio needs its count, a new property needs its location, locality and country,rentalIncomeneeds a property type incategories(it is recorded per type), an amount must stay within range once scaled, a party must be a contact or company in your network (orownFirm), a contact person goes only with a company, a source carries exactly the link its kind needs (anactivitysource itsactivityIdor anote; only anactivitysource files a note; a source names acontactIdor acompanyId, never both). At most 50partiesand 50sourcesper request; a source'scommentis at most 2,000 characters and an article'surlat most 2,048. A malformed request — an unknown word, an id that is not one, a coordinate off the globe — is400. A source may file a note on a contact or company (note, see below); notes are filed only once the deal has passed every rule and the duplicate check, so a refused or held-back deal files nothing. - Duplicates. When the deal looks like one your organisation already has, nothing is recorded and
409carriespossibleDuplicates[]— each withcompId,reason(same_project,same_property_within_180_days,same_parties_within_30_days,same_name_and_locality,similar_locality_category_date_area) anddifferingFields. Send the request again withallowDuplicate: trueto record it anyway.
The answer is 201 with the deal, its ETag and its absolute address in Location.
Finding the property: POST /api/v1/comps/properties:search
Before recording a deal on a building your team may already know, search for it: { "query": "Verkstaden" } (1–100 characters, matched against the name, the street address of its deals and locality) answers results[] — at most 20, best match first — each with propertyId, ownProperty, name, streetAddress (of its latest deal that shows one), locality, isPortfolio and propertyCount (of its latest deal) and dealCount. Send the propertyId as property.propertyId on POST /api/v1/comps or PUT .../{id}/property, so the second deal shares the record instead of recording the building again. A property recorded for a deal is found only here and on its deals; it never appears on /api/v1/properties.
Changing a Deal
PATCH /api/v1/comps/{id}changes the deal's own fields: omit a field to keep it, sendnullto clear it;priceis cleared withnulland set with{ value, currency, scale }. Send the deal'setagasIf-Matchto refuse a change on a stale version (412). On a deal from a closed project the project's fields — price,priceUnknown,priceApproximate, yield,yieldApproximate, the dates, the portfolio pair and the property — are refused with409(priceCertaintyandyieldCertainty, the name, the address and its precision stay the deal's own): correct them withPOST /api/v1/projects/{id}/close/correct(see Projects & Prospects). Every change is one entry in the history, with the old and new value of each field.POST .../{id}/partiesandDELETE .../{id}/parties/{partyId}add and remove parties. AcontactIdof someone who works at a company (their primary company in your network) is recorded as that company with the contact as its contact person; sendcompanyIdandcontactIdtogether to name the contact person at a company yourself; a contact without a company is the party alone. The same rule applies toparties[]onPOST /api/v1/compsand to the parties of a project's close; a party that came with the project's closing is corrected withPOST /api/v1/projects/{id}/close/correct(409).POST .../{id}/sourcesandDELETE .../{id}/sources/{sourceId}add and remove sources. A child write changes the deal's version, so every one of them answers the deal: a POST with201, the deal as the body, its newETagand the child's address inLocation(.../parties/{partyId},.../sources/{sourceId}; a note'sLocationis the history); a DELETE with200, the deal and its newETag. Anactivitysource may file a new note on a contact or company in the same call —note: { contactId | companyId, type: call | meeting | note, text }(text1–10,000 characters) — which becomes the source and also shows on their timeline; the note is filed only once every rule has passed, together with the source (a refused source files no note). These POSTs take anIdempotency-Key; every one of them answers the whole deal with its new version.PUT .../{id}/propertyanchors the deal on another property (an existing record bypropertyId, or a new one); a deal from a closed project keeps the project's property (409, correct the close instead).PUT .../{id}/property/factscorrects the facts as of the deal — the whole set replaces the previous one as a new dated fact row, logged in the history. A deal's facts are locked when it is recorded, so editing a property on/api/v1/properties/{id}never changes them. On a property recorded for a deal, the facts are restated for that deal; on one of your own properties, only its owner or an admin may correct them (403otherwise), the correction applies to every deal recorded with the same facts, and the property's name and location stay. Facts that are not a valid property (no location, no property type, a portfolio of fewer than two) answer422. Both takeIf-Match.PUT .../{id}/property/picturesets the property's picture —{ "pictureId": "…" }with an image id fromPOST /api/v1/images(purpose: "project", the cover size), or{ "pictureId": null }to remove it — on your own property or one recorded for a deal; every deal on the property shows it asproperty.pictureId, with its download address inproperty.pictureUrland logs it in its history (picture). It takesIf-Match; an id that is not a picture you may use answers400. The picture of one of your own properties is changed by the property's owner or an admin (403otherwise), as on the property itself.POST .../{id}/notesadds a note (text, 1–4,000 characters) to the deal's history; it takes anIdempotency-Key.POST .../{id}:hidehides a deal from a closed project for the whole organisation (see Hidden deals above);POST .../{id}:unhideshows it again. Only the project's owner or an admin may (403); a recorded deal is deleted instead (409). Repeating either changes nothing. Both answer the deal with its newETag.POST /api/v1/comps:mergefolds two deals that are one into one:{ "survivorId": "…", "otherId": "…", "takeFromOther": ["price", "signed_on"] }. The survivor keeps its values except the fields named intakeFromOther(public field names, as in the history'schanges[]; a field takes its companions — a price its currency and qualifiers, a date the other dates,propertythe portfolio pair and facts), gains the other's parties and sources (each once), and both record the merge in their history; the other is deleted for good and cannot be restored — the change feed reports it deleted and it emitscomp.deleted. A deal from a closed project is always the one kept, whichever you named, and keeps the project's fields (409whentakeFromOthernames one); two deals from closed projects are never merged (409). The answer is the deal that stays, with itsETag. Send anIdempotency-Keyto make a retry safe.DELETE /api/v1/comps/{id}deletes a recorded deal (204);POST .../{id}:restorebrings it back. A deal from a closed project cannot be deleted (409) — correct the project's close instead (POST /api/v1/projects/{id}/close/correct); a correction to no sale voids it (see delta sync above).
The deal on the project
A closed project's transactions[] entry points at its deal: compId (left out of the JSON while your active organisation is not on the Professional plan, or the deal belongs to another of your organisations), signedOn, completedOn, completedIsPlanned, seller, sellerAdvisor and buyerAdvisor; the buyer, seller and advisor parties carry contactId or companyId when they are records in your network, named as the deal names them. The transaction reports what the close recorded: a party your team later adds to the deal is on the deal, not on the project. See Projects & Prospects.
Related Documentation
- Projects & Prospects — closing a project
- Sync Patterns — delta sync and cursors
- Errors & Retries —
/help/plan-required