Getting started
Introduction
Centris provides a Data Distribution REST API certified by the Real Estate Standards Organization (RESO). This API lets third-party systems get access to MLS data such as listings, real estate agents, etc.
This document describes how to access and use the API.
We strongly suggest that you read this document carefully, especially the recommended usage section.
Configuration
Test environment
| Key | Value |
|---|---|
| Data Distribution Root URL | https://stg-datadistributionqc.centristst.ca/ |
| Swagger URL | https://stg-datadistributionqc.centristst.ca/swagger |
| Scalar URL | https://stg-datadistributionqc.centristst.ca/scalar |
| Identity Provider Root URL | https://stg-accounts.centristst.ca/ |
Production environment
| Key | Value |
|---|---|
| Data Distribution Root URL | https://datadistributionqc.centris.ca/ |
| Swagger URL | https://datadistributionqc.centris.ca/swagger |
| Scalar URL | https://datadistributionqc.centris.ca/scalar |
| Identity Provider Root URL | https://accounts.centris.ca/ |
Credentials
Centris will provide keys that will let you access the API.
- API key: This is your key to access the system. (default)
- Client ID: This is equivalent to your username. (optional, only when using OpenID Connect)
- Client secret: This is equivalent to your password. (optional, only when using OpenID Connect)
Use standard security best practices when using and storing these keys.
Accessing the API
Authentication
Centris supports both authentication using an API key or using OpenID Connect.
We suggest using the API key since it’s the simplest integration of the two.
Authentication using API Key
The API is protected using an API Key authentication layer. This key can be revoked if needed and looks like cddk.WqZLFUF9WnBjGIXYGPBywre7E0Mowf4OpsjAjpEWrAzj0PBrOCf2PWkD6v7CuGrgNUEAxTlJ5IISqv4sM1Halns5sKhG.
To provide the API key, simply specify it in the Authorization header of the API request.
1
2
GET https://datadistributionqc.centris.ca/v1/odata/$metadata
Authorization: Bearer your_api_key
Authentication using OpenID Connect
The API also supports OpenID Connect (OIDC) authentication from its Identity Provider.
When a system is authenticated through the OIDC flow, an access token is returned which represents the identity of that system. This token must be provided in each request to the API. If the token is not provided, expired or invalid, the API will return a 401 - Unauthorized response.
To get an access token, you will need to use the Client Credentials flow.
1
2
3
4
POST https://accounts.centris.ca/connect/token
grant_type=client_credentials
&client_id=your_client_id
&client_secret=your_client_secret
If the credentials are valid, the Identity Provider will return a 200 - OK with an access token.
The access token is a standard JSON Web Token (JWT) that looks like eyJhbGciOiJiIsIm…
Because the access token has a specific lifetime and will expire, you must make sure to request a new one at the appropriate time. The expires_in property lets you know when (in seconds) this token will expire.
To provide the access token, simply specify it in the Authorization header of the API request.
1
2
GET https://datadistributionqc.centris.ca/v1/odata/$metadata
Authorization: Bearer your_access_token
Assuming the request is authorized (see below), you should receive a 200 - OK response if the operation completed successfully.
Authorization
Once a request is authenticated, it must go through the next validation process which is the authorization. This process validates that the authenticated user is authorized to access the requested resource. If the request is unauthorized, the API will return a 403 - Forbidden response.
Rate limits
The number of requests to the API is limited to ensure its stability. If the system detects excessive usage, a 429 – Too Many Requests response will be returned. This doesn’t mean that your request is invalid or unauthorized; it simply means that you should retry the same request later. To help you identify when you should retry, the response will have important information.
The default rules are 5 requests per second and 200 requests per minute.
The body of the response will look like the following.
1
2
3
4
{
"message": "Quota excedeed",
"details": "Quota exceeded. Maximum allowed: 20 per 1m. Please try again in 41 second(s)."
}
The headers of the response will contain the following.
1
Retry-After: 41
You can also try to avoid 429 – Too Many Requests responses before the limit is reached by looking at the headers of 200 – OK responses which will contain the following.
1
2
3
X-Rate-Limit-Limit: 1d
X-Rate-Limit-Remaining: 480
X-Rate-Limit-Reset: 2024-03-26T16:42:30.7526366Z
Status codes
| Status code | Description |
|---|---|
| 200 - OK | Everything is working as expected. |
| 400 – Bad Request | The request is considered invalid; update your request. |
| 401 – Unauthorized | The request could not be authenticated; provide a valid access token. |
| 403 – Forbidden | The request could be authenticated but access to the resource is forbidden. |
| 404 – Not Found | The requested content doesn’t exist or is no longer available. |
| 429 – Too Many Requests | The maximum number of requests was exceeded; wait before retrying the request. |
| 500 – Internal Server Error | An unexpected error has occurred; this may be temporary or not. |
Querying the API
A word about RESO
RESO (Real Estate Standards Organization) is an independent and nonprofit organization which has the mission to:
“create and promote the adoption and utilization of standards that drive efficiency throughout the real estate industry.”
With the collaboration of multiple different organizations, RESO defines standards that help reduce the frictions between systems.
This is done via two main standards.
-
RESO Data Dictionary: Contains the terminology to be used for real estate data. For example, the list price of a listing should be named
ListPrice. For more information, see RESO’s official article. -
RESO Web API: Contains the communication protocol to be used to exchange real estate data. For example, it should be a REST API which follows the OData standard. For more information, see RESO’s official article.
Each year, RESO updates their standards to keep up with the evolving market.
With its certification, Centris aligns with RESO’s standards while also including non-standard resources and fields (known as local resources) which are not yet supported by RESO. These local resources may be integrated in RESO’s standards in the future.
For more information regarding the certification process, see RESO’s official website. You can also access RESO’s Data Dictionary from their wiki.
Functionalities of OData
Summary
The API follows the conventions defined in the standard OData specification. OData (Open Data Protocol) defines ways to interact with a REST API and was selected by RESO as the query language to reduce frictions between different systems.
Because OData is a standard, there are libraries in different programming languages that are built to ease the integration with an OData API. We suggest that you take some time to search if there is one that fits your needs. In any case, it is still very easy to integrate with the API.
An OData query may look like the following.
1
/Property?$select=ListingKey,ListPrice&$filter=StandardStatus eq 'Active'&$orderby=ModificationTimestamp&$count=true
This query returns the listing key and list price of active listings ordered by modification timestamp and includes the total count.
1
2
3
4
5
6
7
8
9
{
"@odata.count": 133,
"value":
[
{ "ListPrice": 123412.00, "ListingKey": "14689588" },
{ "ListPrice": 567632.00, "ListingKey": "14239483" },
{ "ListPrice": 890221.00, "ListingKey": "23659600" }
]
}
Operators
OData offers a broad range of operators but only a few of them are generally used. The following operators are supported.
| Operator | Description |
|---|---|
| $select | Defines the fields to project. |
| $filter | Defines the filter to apply. |
| $orderby | Defines the order to apply. |
| $count | Defines whether to include the total count. |
| $expand | Defines relational navigations to include. |
| $skip | Defines the number of records to skip. |
| $top | Defines the number of records to return. |
See OData’s official website for a comprehensive list of the conventions.
Getting the data model
Route
1
GET /v1/odata/$metadata
Description
Gets the data model of the system.
- An optional parameter
$formatcan be provided to get the response in JSON instead of XML.$format=application/json
Example
1
GET https://datadistributionqc.centris.ca/v1/odata/$metadata
From this endpoint you can retrieve important information on the data model such as.
- The resources available (e.g.
Member,Office,Property, etc.) - The fields available (e.g.
Member.MemberKey,Member.MemberFirstName,Property.ListPrice, etc.) - The data type of the fields (e.g.
String,Decimal) along with their size information (e.g.Max length,Precision, etc.) - The navigation fields (e.g.
Member.MemberSocialMedia, etc.)
Getting a collection of resources
Route
1
GET /v1/odata/Property
This example uses the
Propertyresource (a listing), but any other resource could be used instead (e.g.Member,Office, etc.)
Description
Gets a collection of property resources (listings).
-
An optional parameter
$selectcan be provided to project the results. (e.g.$select=ListingKey,ModificationTimestamp) -
An optional parameter
$filtercan be provided to filter the results. (e.g.$filter=StandardStatus eq 'Active') -
An optional parameter
$orderbycan be provided to order the results. (e.g.$orderby=ModificationTimestamp) -
An optional parameter
$countcan be provided to get the total count of the results. (e.g.$count=true) -
An optional parameter
$expandcan be provided to include related data. (e.g.$expand=ListAgent)
Example
1
GET https://datadistributionqc.centris.ca/v1/odata/Property?$select=ListingKey,ModificationTimestamp&$filter=StandardStatus eq 'Active'&$orderby=ModificationTimestamp&$count=true&$expand=ListAgent(select=MemberFullName)
If no results match the request, a
200 – OKresponse with an empty collection will be returned.
Only some fields can be used with the
$orderbyand$filteroperators. The API will return a400 – Bad Requestwith details of the error in the response if an unauthorized field is used.
Getting a resource by its key
Route
1
GET /v1/odata/Property('12345')
This example uses the
Propertyresource (a listing), but any other resource could be used instead (e.g.Member,Office, etc.)
Description
Gets a single property resource (listing).
-
An optional parameter
$selectcan be provided to project the results. (e.g.$select=ListingKey,ModificationTimestamp) -
An optional parameter
$expandcan be provided to include related data. (e.g.$expand=ListAgent)
Example
1
GET https://datadistributionqc.centris.ca/v1/odata/Property('12345')?$select=ListingKey,ModificationTimestamp&$expand=ListAgent(select=MemberFullName)
If no result match the request, a
404 – Not Foundresponse will be returned.
Paginating through results
Server-side pagination
OData offers a simple way for systems to paginate through result sets using the nextLink field. If more results are available, a generated nextLink field will be included in the response; much like a cursor. To paginate through the complete result set, you can simply follow the next links (i.e. executing a new request using the returned url) until there is no nextLink.
A response may look like the following.
1
2
3
4
5
6
7
8
9
{
"value":
[
{ "ListPrice": 123412.00, "ListingKey": "14689588" },
{ "ListPrice": 567632.00, "ListingKey": "14239483" },
{ "ListPrice": 890221.00, "ListingKey": "23659600" }
],
"@odata.nextLink": "https://datadistributionqc.centris.ca/v1/odata/Property?$select=ListingKey%2CListPrice&$filter=StandardStatus%20eq%20%27Active%27&$orderby=ModificationTimestamp&$skip=10"
}
You can see that the operators of the original request were included in the nextLink with the addition of the $skip operator.
If you provide the
$topoperator, thenextLinkwill not be returned as the result set is considered complete.
Client-side pagination
You can also implement your own pagination using a combination of the $filter, $top and $orderby operators. You would need to provide the last value from the response to the $filter operator.
For example,
1
/Property?$filter=ModificationTimestamp gt 2024-02-05T17:03:58Z
By default, the collection is ordered by the following fields.
ModificationTimestamp- Ascending- Resource key (e.g.
MemberKey,ListingKey, etc.) - Ascending
Working with multiple languages
Centris supports both French and English in its MLS data. The API can distribute the data in the different languages while still being non-breaking with RESO’s standards.
For example, even if Member.MemberFirstName would be Member.Prénom in French, the field MemberFirstName is still used; we only translate the payload of the response.
By default, the payload returned by the API is provided in English.
There are two ways to get the content in French.
Using the Accept-Language header
An optional header Accept-Language can be provided to receive the payload in a different language (e.g. Accept-Language: fr). You will need to do multiple requests to get the content in all languages which may not be practical depending on your use case.
Using the Translations expansion
An optional query parameter $expand=Translations can be provided to include all translations of the resource (e.g. /Member?$expand=Translations). You will get all translations in a single request but will need to do some interpretation of the results.
The response may look like this.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
{
"MemberKey": "1234",
"MemberStatus": "Active",
"MemberIntroduction": "My introduction in English",
"MemberCorporationType": "My corporation type",
"Translations":
[
{
"Locale": "fr",
"Values":
[
{
"FieldName": "MemberIntroduction",
"Value": "Mon introduction en Français"
},
{
"FieldName": "MemberCorporationType",
"Value": "Mon type de corporation"
}
]
},
{
"Locale": "en",
"Values":
[
{
"FieldName": "MemberIntroduction",
"Value": "My introduction in English"
},
{
"FieldName": "MemberCorporationType",
"Value": "My corporation type"
}
]
}
]
}
You can see that there are two locales being returned (fr and en) which both contains two translated fields (MemberIntroduction and MemberCorporationType).
If you expand on a resource that itself has translations, you will need to expand the translations on that resource as well; multiple expansions can be nested in a single request (e.g.
Property?$expand=Translations,Expenses(expand=Translations)).
Working with lookups
What are lookups?
Many fields in the API use lookups to represent a fixed set of possible values. For example, the ParkingFeatures field on a Property resource contains a collection of lookup values that describe the parking characteristics of a listing (e.g. "Heated Garage", "Driveway").
Even though these lookup values may look like human-readable labels, they are actual identifiers and must be treated as such. Do not rely on their textual appearance for display purposes; use the Lookup resource instead to resolve them to their proper display labels.
LookupValue vs. LookupKey
It is important to understand the difference between these two concepts.
- LookupValue: The identifier of a lookup entry as it appears directly on the resource (e.g.
"Heated Garage"inProperty.ParkingFeatures). It is compliant with the RESO standard. - LookupKey: The unique identifier of a record in the
Lookupresource. It is formed by combining the lookup name with the lookup value, separated by a dot:LookupName.LookupValue. For example,ParkingFeatures.Heated Garage.
Resolving lookup values
To get the full details of a lookup value — including its translated display labels — query the Lookup resource by filtering on LookupName and LookupValue.
Filter by
LookupNameandLookupValuerather than constructing theLookupKeyyourself, as the key format may change.
Consider the following property response.
1
2
3
4
{
"ListingKey": "12345",
"ParkingFeatures": ["Heated Garage", "Driveway"]
}
Each value in ParkingFeatures is a LookupValue. To resolve them, filter on LookupName (the field name) and LookupValue and query the Lookup resource.
1
GET /v1/odata/Lookup?$filter=LookupName eq 'ParkingFeatures' and LookupValue in ('Heated%20Garage', 'Driveway')&$expand=Translations
The response would look like this.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
{
"value": [
{
"LookupKey": "ParkingFeatures.Heated Garage",
"LookupName": "ParkingFeatures",
"LookupValue": "Heated Garage",
"DisplayName": "Heated Garage",
"Translations": [
{
"Locale": "en",
"Values": [{ "FieldName": "DisplayName", "Value": "Heated Garage" }]
},
{
"Locale": "fr",
"Values": [{ "FieldName": "DisplayName", "Value": "Garage chauffé" }]
}
]
},
{
"LookupKey": "ParkingFeatures.Driveway",
"LookupName": "ParkingFeatures",
"LookupValue": "Driveway",
"DisplayName": "Driveway",
"Translations": [
{
"Locale": "en",
"Values": [{ "FieldName": "DisplayName", "Value": "Driveway" }]
},
{
"Locale": "fr",
"Values": [{ "FieldName": "DisplayName", "Value": "Entrée" }]
}
]
}
]
}
Notice that "Heated Garage" is a RESO-standard identifier stored in LookupValue that looks like a label, yet its French display label "Garage chauffé" is only available through the Translations expansion. Similarly, "Driveway" maps to a completely different French label "Entrée".
We recommend replicating the entire
Lookupresource (with itsTranslations) at startup and keeping it up to date. This lets you resolve lookup values locally without making additional API requests. See building queries for a recommended starter query.
Replication
Summary
While the API allows live querying, its main objective is to allow other systems to replicate MLS data so that they can use it on their own. There are a few concepts to consider regarding replication.
Initializing your system
This is when your system doesn’t have any MLS data yet. You will request the different resources from an empty starting point and paginate through the result sets. For example, for the Property resource, you would start with /Property and follow the nextLink.
Keeping your system up to date
This is when your system has been initialized and needs to stay updated.
Getting the latest changes by continuously pulling
Your system keeps track of the last ModificationTimestamp of the different resources and requests records updated after this timestamp. See the recommended usage in replicating resources.
For the Property resource, your request may look like this.
1
/Property?$filter=ModificationTimestamp gt 2024-02-05T17:03:58Z
Reconciling your system
This is the process of comparing the complete list of resource keys and modification timestamps between your system and the API. This is to make sure that you system deletes records it shouldn’t have and creates/updates records it may have missed. See the recommended usage in reconciling resources.
For the Property resource, your request may look like this.
1
/Property?$select=ListingKey,ModificationTimestamp
This process is very important to ensure the integrity of your data.
- Records may be removed from the API for multiple reasons such as:
- A listing expires, goes off-market, etc.
- An agent or office becomes inactive or decides to no longer send you their data.
- Records may be added to the API as well if an agent or office now decides to send you their data.
Receiving the latest changes using webhooks
Instead of continuously pulling the data, the API can let you know when a record has been updated. Note that this is optional. Your system could receive something like this.
1
2
3
4
5
{
"ResourceName": "Property",
"ResourceRecordKey": "18634444",
"ResourceRecordUrl": "https://datadistributionqc.centris.ca/v1/odata/Property('18634444')"
}
The webhooks are not yet configured to return only your own data. This optimization is planned for a future development phase. That said, you have the option to query the data as often as you wish. However, we recommend performing a full data reconciliation at least once a day to ensure accuracy.
OpenAPI, Swagger et Scalar
The API also exposes its model using the OpenAPI specification and is available using the Swagger and Scalar formats.
- Some available operations are not listed for performance reasons.
- Some listed operations may not be available for you depending on your access rights.
Recommended usage
As you may have observed, there are multiple ways to replicate data from the API but we recommend the following strategy.
Building queries
Use the $select operator to get only the fields you need except if you need almost everything. This will make your requests much faster.
Prefer this
/Member?$select=MemberKey,ModificationTimestamp,MemberFirstName,MemberLastName
Over this
/Member
Use the $expand operator to get nested resources instead of querying the nested resource directly.
Prefer this
/Property?$expand=Rooms
Over this
/PropertyRooms
Split your request into smaller ones if it fails to be returned.
Prefer this
/Property?$expand=Translations,Mediaand/Property?$select=ListingKey,ModificationTimestamp&$expand=Rooms,Expenses,Equipment
Over this
/Property?$expand=Translations,Media,Rooms,Expenses,Equipment
Here are few queries to get you started.
1
2
3
4
5
6
7
8
9
/Lookup?$expand=Translations
/StateRegion?$expand=Translations
/CityOrTownship?$expand=Neighborhoods(expand=Translations)
/Office?$expand=OtherPhones,OfficeSocialMedia,Media,Translations
/Member?$expand=MemberOtherPhone,MemberSocialMedia,Media,Translations
/Property?$expand=ListAgentOffices,Translations
/Property?$select=ListingKey,ModificationTimestamp&$expand=Media
/Property?$select=ListingKey,ModificationTimestamp&$expand=Rooms(expand=Translations)
/Property?$select=ListingKey,ModificationTimestamp&$expand=Units(expand=Translations)
Replicating resources
Periodically (e.g. every few minutes / hour) get the latest data to update your system based on the current state of your system. This is an example implementation for getting the latest changes by continuously pulling.
We suggest that you execute something like the following.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// Retrieve the most recent modification timestamp of the given resource from your system (e.g. 2024-10-03T07:17:43Z).
// This can be inferred from your replicated resources but we suggest persisting a snapshot somewhere else in case you need to start over the replication.
var mostRecentModificationTimestamp = GetLookupMostRecentModificationTimestamp();
// Start with an initial request based on the most recent modification timestamp.
// If this is your initial replication (i.e. you don't have a modification timestamp), simply don't provide it and you will start from the beginning.
var response = await httpClient.GetAsync($"https://datadistributionqc.centris.ca/v1/odata/Lookup?$filter=ModificationTimestamp gt {mostRecentModificationTimestamp}");
mostRecentModificationTimetamp = GetMostRecentModificationTimestampFromResponse(response);
// If you use a snapshot to keep track of your timestamps, update it.
// The important thing here is to use the most recent timestamp of the resource, not the current UTC timestamp.
UpdateLookupMostRecentModificationTimestamp(mostRecentModificationTimestamp);
// Then use the next link (@odata.nextLink) provided in the previous response for your next request.
var nextLink = GetNextLinkFromResponse(response);
// Repeat until the next link is no longer returned.
while (!string.IsNullOrEmpty(nextLink))
{
response = await httpClient.GetAsync(nextLink);
mostRecentModificationTimetamp = GetMostRecentModificationTimestampFromResponse(response);
UpdateLookupMostRecentModificationTimestamp(mostRecentModificationTimestamp);
nextLink = GetNextLinkFromResponse(response);
}
The initial replication process will take longer as you start from the beginning, but future replications will be incremental thus much faster.
Your system must keep track of the modification timestamps of all resources separately (e.g. the most recent timestamp for
Lookupwill not be the same as the one forMemberwhich will not be the same as the one forProperty, etc.)
Do not restart from the beginning if there is an error in the process (e.g. 429 Too Many Requests, Internal Error, etc.), always restart from the most recent modification timestamp.
Reconciling resources
Periodically (e.g. every hour / day) compare the keys and timestamps between your system and the API. This is an example implementation for reconciling your system.
We suggest that you execute something like the following.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// Start a replication but use select the resource key (e.g. LookupKey) and its modification timestamp.
// These are the only fields you need to know if your system is up to date and the replication will be much faster (page size is increased).
var response = await httpClient.GetAsync($"https://datadistributionqc.centris.ca/v1/odata/Lookup?$select=LookupKey,ModificationTimestamp");
// Then use the next link (@odata.nextLink) provided in the previous response for your next request.
var nextLink = GetNextLinkFromResponse(response);
// Repeat until the next link is no longer returned.
while (!string.IsNullOrEmpty(nextLink))
{
response = await httpClient.GetAsync(nextLink);
nextLink = GetNextLinkFromResponse(response);
}
// Compare the full list of resource keys and timestamps between the API and your system.
// - If a resource key is in the API but not in your system, you are missing this record.
// - If a resource key is not in the API but is in your system, you are referring a record that should no longer be available.
// - If a resource key is in both systems but its timestamps don't match, you should update this record.
// - If a resource key is in both systems and its timestamps match, this record is up to date.
// If you need to get these records from the API, you can replicate by resource keys.
var reconcilationResponse = await httpClient.GetAsync($"https://datadistributionqc.centris.ca/v1/odata/Lookup?$filter=LookupKey in ('AccessibilityFeatures.Visitable', 'Basement.SixFeetAndHigher')");
The number of records returned per page depends on the fields requested using the
$selectoperator.
- Selecting only the primary key (e.g.
ListingKey) andModificationTimestampwill return 1,000 records per page.- Selecting additional fields will return 100 records per page.
There is a limit to the length of the query string so if you have a lot of resources to reconcile, you may want to chunk them in batches when using the
inoperator.