Getting started

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 $format can 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 Property resource (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 $select can be provided to project the results. (e.g. $select=ListingKey,ModificationTimestamp)

  • An optional parameter $filter can be provided to filter the results. (e.g. $filter=StandardStatus eq 'Active')

  • An optional parameter $orderby can be provided to order the results. (e.g. $orderby=ModificationTimestamp)

  • An optional parameter $count can be provided to get the total count of the results. (e.g. $count=true)

  • An optional parameter $expand can 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 – OK response with an empty collection will be returned.

Only some fields can be used with the $orderby and $filter operators. The API will return a 400 – Bad Request with 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 Property resource (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 $select can be provided to project the results. (e.g. $select=ListingKey,ModificationTimestamp)

  • An optional parameter $expand can 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 Found response 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 $top operator, the nextLink will 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" in Property.ParkingFeatures). It is compliant with the RESO standard.
  • LookupKey: The unique identifier of a record in the Lookup resource. 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 LookupName and LookupValue rather than constructing the LookupKey yourself, 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 Lookup resource (with its Translations) 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.

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,Media and /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 Lookup will not be the same as the one for Member which will not be the same as the one for Property, 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 $select operator.

  • Selecting only the primary key (e.g. ListingKey) and ModificationTimestamp will 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 in operator.