Documentation
The Plat listings API.
Plat serves MLS listing data through one OData 4.01 endpoint, with fields named as the RESO Data Dictionary names them. Every request carries an OAuth2 bearer token, and every response is filtered to the fields your licence tier allows.
Examples use $PLAT_HOST, which is https://plat.blacktreegrouptx.com. All paths below are relative to it.
Sandbox
Sandbox keys return invented listings on the same endpoints and the same schema as production, so an integration can be finished before any market is licensed. Sandbox rows carry OriginatingSystemName SAMPLE, and every sandbox response includes the header X-Plat-Sandbox: true.
Authentication
Exchange your client credentials for a bearer token with the OAuth2 client credentials grant. The endpoint accepts a form-encoded body, a JSON body, or the pair in an HTTP Basic header.
| Parameter | Required | Description |
|---|---|---|
grant_type | Yes | Always client_credentials. |
client_id | Yes | Issued with your key. |
client_secret | Yes | Issued once, when your key is created. It cannot be retrieved later, only replaced. |
curl -X POST "$PLAT_HOST/v1/token" \
-d grant_type=client_credentials \
-d client_id="$PLAT_CLIENT_ID" \
-d client_secret="$PLAT_CLIENT_SECRET"{
"access_token": "plat_at_3f9c...",
"token_type": "Bearer",
"expires_in": 28800,
"scope": "IDX"
}Tokens last 8 hours (expires_in: 28800). They are opaque strings, not JWTs, so revoking a key stops its tokens immediately. scope reports your licence tier. A wrong client id and a wrong secret return the same 401 invalid_client, so the endpoint cannot be used to discover which ids exist.
Requests and responses
Send the token as Authorization: Bearer <token>. Responses are JSON in the OData envelope: rows in value, and @odata.count when you ask for it with $count=true.
curl -G "$PLAT_HOST/v1/listings/data/Property" \
-H "Authorization: Bearer $PLAT_TOKEN" \
--data-urlencode "\$filter=StandardStatus eq 'Active' and ListPrice le 750000" \
--data-urlencode "\$select=ListingKey,UnparsedAddress,ListPrice,BedroomsTotal" \
--data-urlencode "\$orderby=ListPrice desc" \
--data-urlencode "\$top=25" \
--data-urlencode "\$count=true"{
"@odata.context": "$metadata#Property",
"@odata.count": 12,
"value": [
{
"ListingKey": "SAMPLE-0001",
"UnparsedAddress": "1104 W Lynn St, Austin, TX 78703",
"ListPrice": 749000,
"BedroomsTotal": 3
}
]
}| Response header | Meaning |
|---|---|
OData-Version | Always 4.01. |
X-Plat-Feed-Model | The licence tier the response was filtered for: IDX, VOW or BBO. |
X-Plat-Sandbox | Present and true for sandbox keys. |
Resources
| Resource | Status | Contents |
|---|---|---|
Property | Live | Listings, one row per listing, keyed by ListingKey. |
Media | Soon | Photos and tours. Reachable today through $expand on Property. |
OpenHouse | Soon | Scheduled showings. Reachable today through $expand on Property. |
Member | Soon | Agents. |
Office | Soon | Brokerages. |
PropertyChange | Soon | Field-level history: old value, new value, time of change. |
MlsProfile | Soon | Per-market logos, disclaimers and display rules. |
A resource marked Soon returns 501 not_implemented until it is served.
Query options
| Option | Default | Behavior |
|---|---|---|
$filter | None | Predicate over RESO fields. See $filter. |
$select | All fields | Comma-separated field names. Expanded collections are always kept. |
$expand | None | Media, PreferredMedia or OpenHouse. See $expand. |
$orderby | Unordered | Field asc or Field desc, comma-separated. Rows missing the field sort last in both directions. |
$top | 500 | Page size. The maximum is 5000; larger values are treated as 5000. |
$skip | 0 | Rows to skip, for paging. |
$count | false | When true, adds @odata.count: the total matching rows before paging. |
$filter
Operators bind in this order, loosest first: or, and, not, then comparisons. Parentheses override it.
| Kind | Syntax |
|---|---|
| Comparison | eq ne gt ge lt le |
| Logical | and or not |
| String functions | contains startswith endswith, all case-insensitive |
| Other functions | tolower toupper trim length year |
| Literals | 'text' (write a quote inside as ''), numbers, true, false, null |
Missing values
A comparison against a field the row does not have is false, except eq null (true) and ne null (false). ClosePrice gt 0 never matches an active listing, and neither does ClosePrice le 0.
StandardStatus
Always one of: Active, Active Under Contract, Canceled, Closed, Coming Soon, Delete, Expired, Hold, Incomplete, Pending, Withdrawn. The value the MLS sent is kept in MlsStatus.
400 invalid_query rather than ignored, so a typo cannot silently widen your results.Geospatial
geo.distance measures great-circle distance in metres. geo.intersects tests whether a listing falls inside a polygon; additional rings are treated as holes. Coordinates are WKT, longitude before latitude.
# Within 5 km of a point. WKT order is longitude, then latitude.
$filter=geo.distance(Location, geography'POINT(-97.7404 30.2747)') le 5000
# Inside a drawn boundary. The ring must close on its first point.
$filter=geo.intersects(Location, geography'POLYGON((-97.775 30.24, -97.735 30.24, -97.735 30.27, -97.775 30.27, -97.775 30.24))')Listings without coordinates never match a geospatial predicate. Coordinates of exactly 0, 0 are treated as missing when a feed is ingested.
Text search
search.text(Field, 'terms', minMatches) matches on whole terms, case insensitive, with punctuation ignored. It is not a geocoder.
| Field | Default match |
|---|---|
RawAddressSearchText | 40% of the terms, rounded up, at least one |
KeywordSearchText | Every term |
ListAgentSearchText | Every term |
ListOfficeSearchText | Every term |
Pass minMatches to override the default. Values above the number of terms mean every term.
# Partial or misspelled address: 2 of these 3 terms must match
$filter=search.text(RawAddressSearchText, '2214 barton spring')
# Require every term
$filter=search.text(RawAddressSearchText, '2214 barton springs', 1000)
# Remarks keyword search, all terms by default
$filter=search.text(KeywordSearchText, 'pool renovated')$expand
| Expansion | Returns |
|---|---|
Media | Every photo and tour for the listing, in display order. |
PreferredMedia | The first two, which is enough for a results card and much faster. |
OpenHouse | Scheduled showings for the listing. |
Licence rules apply to expanded rows exactly as they apply to the listing.
Licence tiers
Your key carries one tier. Fields the tier does not cover are removed before the response is sent, on every row and every expanded row. An unrecognized tier is treated as IDX, the narrowest.
| Tier | Withheld fields |
|---|---|
IDX | MemberEmail MemberPreferredPhone BuyerAgentKey BuyerAgentFullName ClosePrice CloseDate, plus every field beginning plat_ |
VOW | Every field beginning plat_ |
BBO | None |
plat_* fields describe provenance: the source feed, when the row was ingested, and the raw values the MLS sent. They are served under BBO only.
Freshness and history
Each licensed feed is replicated every 15 minutes, walking ModificationTimestamp forward from where the last run stopped. A record older than the stored version is ignored, so listings never move backwards in time.
When one of these fields changes, the old and new values are recorded as a change event: StandardStatus, MlsStatus, ListPrice, ClosePrice, CloseDate, PhotosCount, BedroomsTotal, BathroomsTotalInteger, LivingArea.
Status codes are mapped to StandardStatus only when the mapping is unambiguous. A code that means different things in different markets stays unmapped until that market has an explicit rule, because a guess would put wrong listings behind StandardStatus eq 'Active'.
Errors
Every error uses the same envelope.
{
"error": {
"code": "invalid_query",
"message": "Unterminated string literal at 20"
}
}| Status | Code | When |
|---|---|---|
| 400 | invalid_query | The query options could not be parsed or evaluated. The message says where. |
| 400 | invalid_request | A token request is missing credentials or has an unreadable body. |
| 400 | unsupported_grant_type | Any grant other than client_credentials. |
| 401 | unauthorized | The bearer token is missing, invalid or expired. |
| 401 | invalid_client | The client id or secret is wrong. |
| 404 | unknown_resource | The resource name is not one this service defines. |
| 501 | not_implemented | The resource is defined but not served yet. |
| 502 | upstream_error | The data store could not be reached. Retry with backoff. |
| 503 | deployment_not_configured | The service is not connected to its data store yet. |