Plat Listings API
DocsRequest access

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.

Sample data is invented. Addresses pair real Austin street names with made-up house numbers; brokerages and agents are fictional.

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.

POST/v1/token
ParameterRequiredDescription
grant_typeYesAlways client_credentials.
client_idYesIssued with your key.
client_secretYesIssued once, when your key is created. It cannot be retrieved later, only replaced.
Request
curl -X POST "$PLAT_HOST/v1/token" \
  -d grant_type=client_credentials \
  -d client_id="$PLAT_CLIENT_ID" \
  -d client_secret="$PLAT_CLIENT_SECRET"
Response
{
  "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

GET/v1/listings/data/{Resource}

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.

Request
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"
Response
{
  "@odata.context": "$metadata#Property",
  "@odata.count": 12,
  "value": [
    {
      "ListingKey": "SAMPLE-0001",
      "UnparsedAddress": "1104 W Lynn St, Austin, TX 78703",
      "ListPrice": 749000,
      "BedroomsTotal": 3
    }
  ]
}
Response headerMeaning
OData-VersionAlways 4.01.
X-Plat-Feed-ModelThe licence tier the response was filtered for: IDX, VOW or BBO.
X-Plat-SandboxPresent and true for sandbox keys.

Resources

ResourceStatusContents
PropertyLiveListings, one row per listing, keyed by ListingKey.
MediaSoonPhotos and tours. Reachable today through $expand on Property.
OpenHouseSoonScheduled showings. Reachable today through $expand on Property.
MemberSoonAgents.
OfficeSoonBrokerages.
PropertyChangeSoonField-level history: old value, new value, time of change.
MlsProfileSoonPer-market logos, disclaimers and display rules.

A resource marked Soon returns 501 not_implemented until it is served.

Query options

OptionDefaultBehavior
$filterNonePredicate over RESO fields. See $filter.
$selectAll fieldsComma-separated field names. Expanded collections are always kept.
$expandNoneMedia, PreferredMedia or OpenHouse. See $expand.
$orderbyUnorderedField asc or Field desc, comma-separated. Rows missing the field sort last in both directions.
$top500Page size. The maximum is 5000; larger values are treated as 5000.
$skip0Rows to skip, for paging.
$countfalseWhen 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.

KindSyntax
Comparisoneq ne gt ge lt le
Logicaland or not
String functionscontains startswith endswith, all case-insensitive
Other functionstolower 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.

An unknown function is rejected with 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.

Examples
# 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.

$expand

ExpansionReturns
MediaEvery photo and tour for the listing, in display order.
PreferredMediaThe first two, which is enough for a results card and much faster.
OpenHouseScheduled 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.

TierWithheld fields
IDXMemberEmail MemberPreferredPhone BuyerAgentKey BuyerAgentFullName ClosePrice CloseDate, plus every field beginning plat_
VOWEvery field beginning plat_
BBONone

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
{
  "error": {
    "code": "invalid_query",
    "message": "Unterminated string literal at 20"
  }
}
StatusCodeWhen
400invalid_queryThe query options could not be parsed or evaluated. The message says where.
400invalid_requestA token request is missing credentials or has an unreadable body.
400unsupported_grant_typeAny grant other than client_credentials.
401unauthorizedThe bearer token is missing, invalid or expired.
401invalid_clientThe client id or secret is wrong.
404unknown_resourceThe resource name is not one this service defines.
501not_implementedThe resource is defined but not served yet.
502upstream_errorThe data store could not be reached. Retry with backoff.
503deployment_not_configuredThe service is not connected to its data store yet.