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. dealDate is the completion date unless it is only planned, else the signing date, else the planned completion. Period filters and the analysis always use it; signedOn and completedOn are also sortable.
  • Honest numbers. price is in full currency units with priceCertainty (confirmed, reported, estimated) and priceApproximate; priceUnknown: true means the price is known to be unknown. yield is a plain number in percent (5.0 = 5%), with yieldCertainty and yieldApproximate beside it — a number here, unlike transactions[].yield on a project, which is a value-or-range object. Land has a price but never a yield or a pricePerSqm.
  • Sources, not recorders. sources[] say where your team learned about the deal; createdVia says who recorded it (app, api, agent, system). Your own closed project is not a source — it is project.
  • Source channels. sourceChannels[] lists every channel the deal counts under in the source filter: own_project (a closed project of yours), news (an article, or a news note), market_contact (a contact, a company, or one of their calls, meetings, e-mails or notes), or none. A deal that came from an article and was confirmed by a call counts under both.
  • What is missing. missing[] names what keeps the deal from being complete (price, buyer, seller, property, location, address, property_count, facts_at_deal_date, completion_confirmation). completion_confirmation means the planned completion date has passed: confirm the completion with completedIsPlanned: false (a closed project's deal: correct the project's close) or move the date. Treat missing[] as open-ended — a new value may be added.
  • Hidden deals. A deal from a closed project can be hidden from the list for the whole organisation (hidden, hiddenUtc). The search leaves hidden deals out unless you send includeHidden; the analysis never counts them; the change feed always carries them, so a hide reaches your copy.
  • Access to the project. Everybody in the organisation sees the deal; the project behind it keeps its own access rule. When the caller may not open the project, project comes back with accessible: false and no id or name. The same holds for an activity source on such a project: accessible: false, no activityId and no text.

Sync: GET /api/v1/comps

Streams every comp of your organisation in the order it last changed, oldest first, as a PagedResponse of comps. Follow page.nextCursor until hasMore is false; store the newest updatedUtc you saw and pass it back as updated_since next time.

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 a contactId (a person) or a companyId in your network, or your own firm (ownFirm: true, no id). A company party may carry its contact person — the person your team dealt with there — as contactId next to companyId, with contactName; it is recorded with the party, so it stays when the person later moves to another company. fromProject: true marks a party that came with the project's closing. removed: true marks a record that was removed from your network; the party keeps pointing at it.
  • GET /api/v1/comps/{id}/sources — the sources. An article carries url, title, publisher and publishedOn; an activity carries the note's activityId (its current version on the activity timelines) and its text as name; a contact or company carries the id and the name. Every source has a channel and may carry a comment. The app names two kinds of source: a news item — an article, or an activity from the news watch (channel news) — and a person or company — a contact or company, or an activity that is one of their calls, meetings, e-mails or notes (channel market_contact).
  • GET /api/v1/comps/{id}/property — the property with its facts as of the deal: propertyId (send it as property.propertyId to anchor another deal on the same record) and ownProperty (true for one of your own properties, also readable on /api/v1/properties/{id}; false for a property recorded for a deal, read and changed through its deals only), isPortfolio and propertyCount, pictureId and pictureUrl (its download address) when it has a picture, the name, locality and country, location, categories[] with rented and vacant area, leasableArea, parcelArea, buildingRights, yearBuilt, rentedPercent, contractLengthYears, rentalIncome, netOperatingIncome and totalCosts. rentalIncome and netOperatingIncome are in the deal's currency; a deal with an unknown price states them in the property's own currency, and a deal dated before the exchange rates go back leaves them out. A closed project without a property answers 404.
  • GET /api/v1/comps/{id}/actions — the history, newest first, as a PagedResponse in the shape of the other action timelines: each entry carries an opaque cursor, page_size takes 1–100 (default 100), and page.nextCursor continues the read (a cursor that is not one answers 400). Each entry is a note (its text in details.message, details.kind: "note") or an audit with an event in the webhook vocabulary (comp.created, comp.updated, comp.hidden, comp.unhidden, comp.deleted, comp.restored), the user, via (app, api, agent, system) and changes[] — every changed deal field with oldValue and newValue under its public name (price, currency, signed_on, …) — plus propertyChanges[] for the property's facts edited through the deal.

Recording a Deal

POST /api/v1/comps records a deal your team learned about. Send an Idempotency-Key to make a retry safe (see Idempotency).

{
  "property": {
    "name": "Kv. Verkstaden 3",
    "locality": "Sundbyberg",
    "country": "SE",
    "location": { "latitude": 59.3808, "longitude": 17.9358 },
    "categories": [
      {
        "type": "office",
        "rentedArea": { "value": 9000, "unit": "sqm" },
        "vacantArea": { "value": 1000, "unit": "sqm" }
      }
    ],
    "yearBuilt": 1998
  },
  "name": "Verkstaden",
  "streetAddress": "Industrivägen 12",
  "signedOn": "2026-03-01",
  "price": { "value": 180, "currency": "SEK", "scale": "millions" },
  "priceCertainty": "reported",
  "priceApproximate": true,
  "yield": 5.1,
  "parties": [
    { "role": "buyer", "companyId": "4711" },
    { "role": "seller_advisor", "ownFirm": true }
  ],
  "sources": [
    {
      "kind": "article",
      "url": "https://example.com/news/verkstaden",
      "title": "Verkstaden sold",
      "publisher": "Fastighetsbladet"
    }
  ]
}
  • The property. Either propertyId — one of your own properties, or a property recorded for another deal of yours; find either with POST /api/v1/comps/properties:search (below) — or a new record for somebody else's building: locality, country and location are required; send the locality's centre when only the district is known. A portfolio is isPortfolio: true with propertyCount (at least two) and the portfolio's totals. For land, send parcelArea (and buildingRights) instead of areas, and no yield.
  • The address. streetAddress and locationPrecision are the deal's own, not the property's: locationPrecision: "area" shows only the deal's area (its address is not public, the map draws a ring). A deal without a street address lists address under missing until it has one; one that shows only its area does not. Both are read on the deal (streetAddress, locationPrecision) and changed with PATCH, also on a deal from a closed project.
  • The numbers. A signing or a completion date is needed; a completion before the signing answers 422 (the same holds for a project's close and its correction); no date is too far ahead — a signing, completion or planned completion in the future is recorded as sent. price takes scale; without priceCertainty a price is confirmed. priceUnknown: true records that the price is known to be unknown.
  • Rules are answered with 422: land takes no yield, a portfolio needs its count, a new property needs its location, locality and country, rentalIncome needs a property type in categories (it is recorded per type), an amount must stay within range once scaled, a party must be a contact or company in your network (or ownFirm), a contact person goes only with a company, a source carries exactly the link its kind needs (an activity source its activityId or a note; only an activity source files a note; a source names a contactId or a companyId, never both). At most 50 parties and 50 sources per request; a source's comment is at most 2,000 characters and an article's url at most 2,048. A malformed request — an unknown word, an id that is not one, a coordinate off the globe — is 400. A source may file a note on a contact or company (note, see below); notes are filed only once the deal has passed every rule and the duplicate check, so a refused or held-back deal files nothing.
  • Duplicates. When the deal looks like one your organisation already has, nothing is recorded and 409 carries possibleDuplicates[] — each with compId, reason (same_project, same_property_within_180_days, same_parties_within_30_days, same_name_and_locality, similar_locality_category_date_area) and differingFields. Send the request again with allowDuplicate: true to record it anyway.

The answer is 201 with the deal, its ETag and its absolute address in Location.

Finding the property: POST /api/v1/comps/properties:search

Before recording a deal on a building your team may already know, search for it: { "query": "Verkstaden" } (1–100 characters, matched against the name, the street address of its deals and locality) answers results[] — at most 20, best match first — each with propertyId, ownProperty, name, streetAddress (of its latest deal that shows one), locality, isPortfolio and propertyCount (of its latest deal) and dealCount. Send the propertyId as property.propertyId on POST /api/v1/comps or PUT .../{id}/property, so the second deal shares the record instead of recording the building again. A property recorded for a deal is found only here and on its deals; it never appears on /api/v1/properties.

Changing a Deal

  • PATCH /api/v1/comps/{id} changes the deal's own fields: omit a field to keep it, send null to clear it; price is cleared with null and set with { value, currency, scale }. Send the deal's etag as If-Match to refuse a change on a stale version (412). On a deal from a closed project the project's fields — price, priceUnknown, priceApproximate, yield, yieldApproximate, the dates, the portfolio pair and the property — are refused with 409 (priceCertainty and yieldCertainty, the name, the address and its precision stay the deal's own): correct them with POST /api/v1/projects/{id}/close/correct (see Projects & Prospects). Every change is one entry in the history, with the old and new value of each field.
  • POST .../{id}/parties and DELETE .../{id}/parties/{partyId} add and remove parties. A contactId of someone who works at a company (their primary company in your network) is recorded as that company with the contact as its contact person; send companyId and contactId together to name the contact person at a company yourself; a contact without a company is the party alone. The same rule applies to parties[] on POST /api/v1/comps and to the parties of a project's close; a party that came with the project's closing is corrected with POST /api/v1/projects/{id}/close/correct (409). POST .../{id}/sources and DELETE .../{id}/sources/{sourceId} add and remove sources. A child write changes the deal's version, so every one of them answers the deal: a POST with 201, the deal as the body, its new ETag and the child's address in Location (.../parties/{partyId}, .../sources/{sourceId}; a note's Location is the history); a DELETE with 200, the deal and its new ETag. An activity source may file a new note on a contact or company in the same call — note: { contactId | companyId, type: call | meeting | note, text } (text 1–10,000 characters) — which becomes the source and also shows on their timeline; the note is filed only once every rule has passed, together with the source (a refused source files no note). These POSTs take an Idempotency-Key; every one of them answers the whole deal with its new version.
  • PUT .../{id}/property anchors the deal on another property (an existing record by propertyId, or a new one); a deal from a closed project keeps the project's property (409, correct the close instead). PUT .../{id}/property/facts corrects the facts as of the deal — the whole set replaces the previous one as a new dated fact row, logged in the history. A deal's facts are locked when it is recorded, so editing a property on /api/v1/properties/{id} never changes them. On a property recorded for a deal, the facts are restated for that deal; on one of your own properties, only its owner or an admin may correct them (403 otherwise), the correction applies to every deal recorded with the same facts, and the property's name and location stay. Facts that are not a valid property (no location, no property type, a portfolio of fewer than two) answer 422. Both take If-Match. PUT .../{id}/property/picture sets the property's picture — { "pictureId": "…" } with an image id from POST /api/v1/images (purpose: "project", the cover size), or { "pictureId": null } to remove it — on your own property or one recorded for a deal; every deal on the property shows it as property.pictureId, with its download address in property.pictureUrl and logs it in its history (picture). It takes If-Match; an id that is not a picture you may use answers 400. The picture of one of your own properties is changed by the property's owner or an admin (403 otherwise), as on the property itself.
  • POST .../{id}/notes adds a note (text, 1–4,000 characters) to the deal's history; it takes an Idempotency-Key.
  • POST .../{id}:hide hides a deal from a closed project for the whole organisation (see Hidden deals above); POST .../{id}:unhide shows it again. Only the project's owner or an admin may (403); a recorded deal is deleted instead (409). Repeating either changes nothing. Both answer the deal with its new ETag.
  • POST /api/v1/comps:merge folds two deals that are one into one: { "survivorId": "…", "otherId": "…", "takeFromOther": ["price", "signed_on"] }. The survivor keeps its values except the fields named in takeFromOther (public field names, as in the history's changes[]; a field takes its companions — a price its currency and qualifiers, a date the other dates, property the portfolio pair and facts), gains the other's parties and sources (each once), and both record the merge in their history; the other is deleted for good and cannot be restored — the change feed reports it deleted and it emits comp.deleted. A deal from a closed project is always the one kept, whichever you named, and keeps the project's fields (409 when takeFromOther names one); two deals from closed projects are never merged (409). The answer is the deal that stays, with its ETag. Send an Idempotency-Key to make a retry safe.
  • DELETE /api/v1/comps/{id} deletes a recorded deal (204); POST .../{id}:restore brings it back. A deal from a closed project cannot be deleted (409) — correct the project's close instead (POST /api/v1/projects/{id}/close/correct); a correction to no sale voids it (see delta sync above).

The deal on the project

A closed project's transactions[] entry points at its deal: compId (left out of the JSON while your active organisation is not on the Professional plan, or the deal belongs to another of your organisations), signedOn, completedOn, completedIsPlanned, seller, sellerAdvisor and buyerAdvisor; the buyer, seller and advisor parties carry contactId or companyId when they are records in your network, named as the deal names them. The transaction reports what the close recorded: a party your team later adds to the deal is on the deal, not on the project. See Projects & Prospects.