# RESO Web API (OData)

Standard RESO Data Dictionary 2.0 resources so US MLSs, IDX vendors and CRMs can consume Mexican inventory with no custom work. Read-only.

Source: https://multilistado.mx/developers/reso?lang=en · Updated: 2026-09-30 · Other language: https://multilistado.mx/developers/reso.md

## Overview

| | |
|---|---|
| Service root | `https://multilistado.mx/reso/odata` |
| Metadata | [`/reso/odata/$metadata`](https://multilistado.mx/reso/odata/$metadata) (EDMX/XML) |
| OAuth2 token | `POST https://multilistado.mx/reso/oauth/token` (client credentials) |
| Standards | Web API Core 2.0.0 (2.1.0 also tested) + Data Dictionary 2.0 · OData 4.0, JSON `odata.metadata=minimal` |
| Resources | `Property` (+ `$expand=Media`), `Media`, `Member`, `Office`, `Lookup` |
| Rate limit | 600 requests per 10 minutes per credential (429 with `Retry-After`) |
| Status | Available, read-only. **Not yet RESO-certified** (the official test suites pass in our test environments) |

## Who can use it

| Credential | How to get it | What it sees |
|---|---|---|
| **Agent key** (`read`) | Yourself at [Portal → API](https://multilistado.mx/portal/api-keys) | Your listings (published, pending, closed or withdrawn; no drafts) + those of agents who consented to share with partner MLSs |
| **Partner, `broker` scope** | Issued by Multilistado to an MLS, GDX node or vendor ([apply](https://multilistado.mx/partners)) | Only listings of agents who **consented**: Active, Pending, Closed, Withdrawn |
| **Partner, `idx` scope** | Same, for IDX display | Only **Active** listings of consenting agents |

Each agent consents at [Portal → International portals](https://multilistado.mx/portal/internacional) ("US MLSs and GDX network (RESO)"). Licensed imports from other MLSs, test accounts and **any compensation field** are never included. Coordinates are approximate and the street address is omitted unless the agent shows it.

## 1. Get a token

`client_id` = the key **prefix** (the 8 characters after `pbm_`); `client_secret` = the full key. Tokens last **1 hour** (`expires_in: 3600`). You can also send the key directly as `Authorization: Bearer pbm_…`. Credentials in the URL (`?api_key=`) are refused.

```bash
curl -s -X POST https://multilistado.mx/reso/oauth/token \
  -d grant_type=client_credentials \
  -d client_id="$RESO_CLIENT_ID" \
  --data-urlencode client_secret="$RESO_CLIENT_SECRET"
```

```bash
curl -s -X POST https://multilistado.mx/reso/oauth/token \
  -u "$RESO_CLIENT_ID:$RESO_CLIENT_SECRET" -d grant_type=client_credentials
```

```json
{ "access_token": "Qm9…", "token_type": "Bearer", "expires_in": 3600, "scope": "read partner idx" }
```

OAuth errors: `400 {"error":"unsupported_grant_type"}` and `401 {"error":"invalid_client"}`.

## 2. OData queries

```bash
TOKEN="…"   # access_token from the previous step
R=https://multilistado.mx/reso/odata

# Active listings in Tijuana, 10 per page, with total count and photos
curl -s -G "$R/Property" -H "Authorization: Bearer $TOKEN" \
  --data-urlencode "\$filter=StandardStatus eq 'Active' and City eq 'Tijuana'" \
  --data-urlencode "\$select=ListingKey,ListPrice,PBM_Currency,City,PropertySubType,BedroomsTotal,ModificationTimestamp" \
  --data-urlencode "\$expand=Media" --data-urlencode "\$top=10" --data-urlencode "\$count=true"

# One listing by key
curl -s -G "$R/Property('07e25392-752f-4ca5-a441-51b4ad0625ed')" -H "Authorization: Bearer $TOKEN" \
  --data-urlencode "\$select=ListingKey,ListPrice,StandardStatus"

# Lookup values of a field
curl -s -G "$R/Lookup" -H "Authorization: Bearer $TOKEN" --data-urlencode "\$filter=LookupName eq 'PropertySubType'"
```

Response (trimmed, illustrative):

```json
{
  "@odata.context": "https://multilistado.mx/reso/odata/$metadata#Property",
  "@odata.count": 27,
  "value": [{
    "ListingKey": "07e25392-752f-4ca5-a441-51b4ad0625ed", "ListPrice": 4350000, "PBM_Currency": "MXN", "City": "Tijuana",
    "PropertySubType": "Single Family Residence", "BedroomsTotal": 3, "ModificationTimestamp": "2026-09-30T14:07:28.671Z",
    "Media": [{ "MediaKey": "05453dad-…", "ResourceRecordKey": "07e25392-…", "MediaURL": "https://…/fachada.jpg", "MediaCategory": "Photo", "Order": 0 }]
  }],
  "@odata.nextLink": "https://multilistado.mx/reso/odata/Property?%24filter=…&%24skiptoken=WyIy…"
}
```

### Supported options

| Option | Detail |
|---|---|
| `$filter` | `eq ne gt ge lt le`, `in (…)`, `has`, `and`/`or`/`not`, parentheses, `contains()`, `startswith()`, `endswith()`, `now()`; collections with `any()`/`all()` |
| `$select` | Field list; an unknown field returns 400 |
| `$orderby` | Any scalar field, `asc`/`desc` |
| `$top` / `$skip` | Property: default 100, max 500. Media, Member and Office: default 200, max 1000. Lookup: 1000 |
| `$count=true` | Total in `@odata.count` |
| `$expand=Media` | `Property` only |
| `@odata.nextLink` | Without `$orderby` or `$skip`, paging uses a `$skiptoken` ordered by `ModificationTimestamp` + key: **replication-safe** — no record is skipped; one that changes during the download may appear again at the end (upsert by `ListingKey`) |
| `$format` | `json` only |

OData errors: `{"error":{"code":"BadRequest","message":"Unknown field Bogus"}}` (400), `Unauthorized` (401), `NotFound` (404), `TooManyRequests` (429).

### Lookups

Values are Data Dictionary 2.0 names (e.g. `'Single Family Residence'`, `'Square Meters'`). The `Lookup` resource gives each one's `LegacyODataValue` (`SingleFamilyResidence`), and filters accept either form. Multi-value fields `View`, `WaterfrontFeatures`, `CommunityFeatures` and `PetsAllowed` are collections: `View/any(v: v eq 'Ocean')`. Local fields use the `PBM_` prefix: `PBM_Currency` is the currency of `ListPrice`/`LeaseAmount` (**MXN or USD, not converted**).

## 3. Replication

To keep a copy: do a full download following `@odata.nextLink`, store the newest `ModificationTimestamp`, then request only changes.

```python
# Incremental replica (Python 3.8+, requests)
import os, requests

R = "https://multilistado.mx/reso/odata"
tok = requests.post("https://multilistado.mx/reso/oauth/token", data={
    "grant_type": "client_credentials", "client_id": os.environ["RESO_CLIENT_ID"], "client_secret": os.environ["RESO_CLIENT_SECRET"]}, timeout=30).json()
h = {"Authorization": f"Bearer {tok['access_token']}"}

since = "2026-01-01T00:00:00Z"          # the mark saved by your last run
url, params, n = f"{R}/Property", {"$filter": f"ModificationTimestamp gt {since}", "$expand": "Media", "$top": "200"}, 0
while url:
    page = requests.get(url, headers=h, params=params, timeout=60).json()
    for p in page["value"]:
        n += 1
        since = max(since, p["ModificationTimestamp"])   # store your record here (upsert by ListingKey)
    url, params = page.get("@odata.nextLink"), None   # nextLink already carries every parameter
print(n, "changes; next mark:", since)
```

Listings leaving the market change to `Closed` or `Withdrawn` (broker scope) or stop appearing (idx scope): with idx scope, delete from your copy whatever is missing from a periodic full download.

## Partner rules

- Credit the listing agent (`ListAgentFullName`, `ListOfficeName`) and link to `ListingURL`.
- Refresh at least every 12 hours and remove what is no longer active.
- Do not re-syndicate to third parties without a written agreement.
- Partner program: [/partners](https://multilistado.mx/partners) · contacto@multilistado.mx.
