# RESO Web API (OData)

Recursos estándar del RESO Data Dictionary 2.0 para que MLS, vendors IDX y CRMs de Estados Unidos consuman inventario mexicano sin desarrollos a la medida. Solo lectura.

Fuente: https://multilistado.mx/developers/reso · Actualizado: 2026-09-30 · Otro idioma: https://multilistado.mx/developers/reso.md?lang=en

## Resumen

| | |
|---|---|
| Raíz del servicio | `https://multilistado.mx/reso/odata` |
| Metadatos | [`/reso/odata/$metadata`](https://multilistado.mx/reso/odata/$metadata) (EDMX/XML) |
| Token OAuth2 | `POST https://multilistado.mx/reso/oauth/token` (client credentials) |
| Estándares | Web API Core 2.0.0 (también probado 2.1.0) + Data Dictionary 2.0 · OData 4.0, JSON `odata.metadata=minimal` |
| Recursos | `Property` (+ `$expand=Media`), `Media`, `Member`, `Office`, `Lookup` |
| Límite | 600 solicitudes cada 10 minutos por credencial (429 con `Retry-After`) |
| Estado | Disponible, solo lectura. Aún **no certificada** por RESO (las pruebas oficiales pasan en nuestros entornos de prueba) |

## Quién puede usarla

| Credencial | Cómo se obtiene | Qué ve |
|---|---|---|
| **Key de agente** (`read`) | Tú mismo en [Portal → API](https://multilistado.mx/portal/api-keys) | Tus propiedades (publicadas, en trato, cerradas o retiradas; no borradores) + las de agentes que autorizaron compartir con MLS socios |
| **Socio, alcance `broker`** | Multilistado la emite a un MLS, GDX o vendor ([solicitar](https://multilistado.mx/socios-mls)) | Solo propiedades de agentes que **autorizaron** compartir: Active, Pending, Closed, Withdrawn |
| **Socio, alcance `idx`** | Igual, para exhibición IDX | Solo propiedades **Active** de agentes que autorizaron |

El consentimiento lo da cada agente en [Portal → Portales internacionales](https://multilistado.mx/portal/internacional) («MLS de EE. UU. y red GDX (RESO)»). Nunca se incluyen importaciones con licencia de otros MLS, cuentas de prueba ni **ningún campo de compensación**. Las coordenadas son aproximadas y la dirección se omite salvo que el agente la muestre.

## 1. Obtener un token

`client_id` = el **prefijo** de la key (los 8 caracteres después de `pbm_`); `client_secret` = la key completa. El token dura **1 hora** (`expires_in: 3600`). También puedes enviar la key directamente como `Authorization: Bearer pbm_…`. Las credenciales en la URL (`?api_key=`) se rechazan.

```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" }
```

Errores OAuth: `400 {"error":"unsupported_grant_type"}` y `401 {"error":"invalid_client"}`.

## 2. Consultas OData

```bash
TOKEN="…"   # access_token del paso anterior
R=https://multilistado.mx/reso/odata

# Propiedades activas en Tijuana, 10 por página, con total y fotos
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"

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

# Catálogo (Lookup) de un campo
curl -s -G "$R/Lookup" -H "Authorization: Bearer $TOKEN" --data-urlencode "\$filter=LookupName eq 'PropertySubType'"
```

Respuesta (recortada, ilustrativa):

```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…"
}
```

### Opciones soportadas

| Opción | Detalle |
|---|---|
| `$filter` | `eq ne gt ge lt le`, `in (…)`, `has`, `and`/`or`/`not`, paréntesis, `contains()`, `startswith()`, `endswith()`, `now()`; colecciones con `any()`/`all()` |
| `$select` | Lista de campos; un campo desconocido responde 400 |
| `$orderby` | Cualquier campo escalar, `asc`/`desc` |
| `$top` / `$skip` | Property: 100 por omisión, máximo 500. Media, Member y Office: 200 por omisión, máximo 1000. Lookup: 1000 |
| `$count=true` | Total en `@odata.count` |
| `$expand=Media` | Solo en `Property` |
| `@odata.nextLink` | Sin `$orderby` ni `$skip`, la paginación usa `$skiptoken` ordenado por `ModificationTimestamp` + clave: **segura para replicación**: no se salta registros; uno que cambie durante la descarga puede volver a aparecer al final (haz *upsert* por `ListingKey`) |
| `$format` | Solo `json` |

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

### Catálogos (lookups)

Los valores son los nombres del Data Dictionary 2.0 (p. ej. `'Single Family Residence'`, `'Square Meters'`). El recurso `Lookup` da para cada uno su `LegacyODataValue` (`SingleFamilyResidence`), y los filtros aceptan ambas formas. Los campos multivalor `View`, `WaterfrontFeatures`, `CommunityFeatures` y `PetsAllowed` son colecciones: `View/any(v: v eq 'Ocean')`. Campos locales con prefijo `PBM_`: `PBM_Currency` es la moneda de `ListPrice`/`LeaseAmount` (**MXN o USD, sin convertir**).

## 3. Replicación

Para mantener una copia: descarga completa siguiendo `@odata.nextLink`, guarda el `ModificationTimestamp` más reciente y después pide solo los cambios.

```python
# Réplica incremental (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"          # la marca guardada de tu última corrida
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"])   # guarda aquí tu registro (upsert por ListingKey)
    url, params = page.get("@odata.nextLink"), None   # nextLink ya trae todos los parámetros
print(n, "cambios; próxima marca:", since)
```

Propiedades que salen del mercado cambian a `Closed` o `Withdrawn` (alcance broker) o dejan de aparecer (alcance idx): con alcance idx, elimina de tu copia lo que no vuelva en una descarga completa periódica.

## Reglas para socios

- Muestra el crédito del agente listador (`ListAgentFullName`, `ListOfficeName`) y enlaza a `ListingURL`.
- Actualiza al menos cada 12 horas y elimina lo que ya no esté activo.
- No re-sindiques a terceros sin acuerdo por escrito.
- Programa de socios: [/socios-mls](https://multilistado.mx/socios-mls) · contacto@multilistado.mx.
