# Map where your leads are looking

Source: https://help.ruuster.com/api/leads-map

```http
GET https://api.ruuster.com/v1/leads/map
```

Where your leads are looking for homes, as map-ready points. Listing views, favorites and tour requests from the last 30 days, grouped by the viewed listings' locations into clusters, ZIP codes, cities or single listings, with the top areas and the map bounds. One call for a heatmap, dot map or market map; no need to page through leads.

- Authentication: `x-api-key` and `x-email` headers of an owner or admin
- Rate limit: 120 requests per minute per key

Every listing view, favorite and tour request your leads made on your sites, placed at the location of the listing they looked at (any listing, yours or not) and grouped for a map. Use it for "where are my buyers looking?", a heatmap or dot map of demand, or to compare areas. It is one call: there is no need to list leads and read each one's activity.

In Claude (claude.ai and Claude desktop) and other assistants that show MCP Apps, the `leads_map` tool's answer comes with an interactive map of these points: zoom, click a circle for its counts, and click a row in the top-areas table to fly there. Nothing extra to call.

The response is counts only. It never names a lead, so it doesn't count against your daily lead-row quota. For who is behind the activity, use [List active leads](https://help.ruuster.com/api/leads-active); for one listing's viewers, [List a listing's viewers](https://help.ruuster.com/api/properties-viewers).

## Query parameters

| Name | Type | Default | Description |
|---|---|---|---|
| `from` | date | 30 days before `to` | First day, `YYYY-MM-DD` in America/Chicago, included. Activity is kept for 30 days, so no earlier than 30 days ago |
| `to` | date | tomorrow | Day after the last day, `YYYY-MM-DD`, excluded. The default includes today |
| `groupBy` | `cluster` \| `zip` \| `city` \| `listing` | `cluster` | How points are grouped: `cluster` is a grid sized to the area your leads cover, best for a heatmap; `zip` and `city` give one point per ZIP code or city; `listing` gives one point per listing with its address |
| `agentId` | string | | Only leads assigned to this agent (an `agentId` from [List agents](https://help.ruuster.com/api/agents-list)) |
| `limit` | integer | `300` | Most points returned, 1 to 1000. The most active points are kept |
| `sources` | `0` \| `1` | `0` | `1` adds, for every point and area, how many listing views happened on Ruuster versus on other sources your CRM recorded (portals, IDX sites). Not with `agentId` |

## Response

| Field | Description |
|---|---|
| `groupBy` | The grouping used |
| `cellDegrees` | Grid cell size in degrees when `groupBy` is `cluster` (0.005 to 0.25), otherwise null |
| `totals.views`, `favorites`, `tourRequests` | All activity in the range, mapped or not |
| `totals.leads` | Distinct leads behind that activity |
| `totals.listings` | Distinct listings they looked at |
| `totals.mappedListings`, `unmappedListings` | Listings placed on the map, and those with no location on record |
| `bounds` | `south`, `west`, `north`, `east` of every mapped listing; null when nothing was mapped |
| `focus` | The same four edges around the core of the activity (the middle 95% of mapped listings), so a few far-away views don't zoom the map out. Fit the map to this; clusters are sized from it |
| `points` | One per group, most distinct leads first: `lat`, `lng` (the activity-weighted center), `views`, `favorites`, `tourRequests`, `leads`, `listings`, `medianPrice` (asking price), `label` ("City, ST ZIP"); with `groupBy=listing` also `propertyId` and `address` |
| `pointsOmitted` | Points left out by `limit` |
| `areas` | The top 20 cities by distinct leads, whatever the grouping: `city`, `state`, `lat`, `lng`, `views`, `favorites`, `tourRequests`, `leads`, `listings`, `medianPrice` |
| `ruusterViews`, `outsideViews`, `ruusterShare` | With `sources=1`, on `totals`, each point and each area: listing views on Ruuster (the same as `views`), listing views your CRM recorded from other sources, and Ruuster's share of the two (0 to 1, null with no views). `totals` also has `ruusterLedPoints` and `ruusterLedAreas`, how many returned points and areas have a share above 0.5 |

`meta.range` echoes the days mapped and `meta.eventsAvailableFrom` the earliest day you can ask for. If a range holds more than 60,000 events, the first 60,000 are mapped and `meta.truncated` is `true`; ask for fewer days to see the rest.

## Example request

cURL:

```bash
curl -s "https://api.ruuster.com/v1/leads/map?groupBy=zip&limit=3" \
  -H "x-api-key: $RUUSTER_API_KEY" \
  -H "x-email: $RUUSTER_EMAIL"
```

Node.js:

```javascript
const res = await fetch("https://api.ruuster.com/v1/leads/map?groupBy=zip&limit=3", {
  headers: {
    "x-api-key": process.env.RUUSTER_API_KEY,
    "x-email": process.env.RUUSTER_EMAIL,
  },
});
const body = await res.json();
if (!body.ok) throw new Error(`${body.error.code}: ${body.error.message} (${body.requestId})`);
console.log(body.data);
```

Python:

```python
import os
import requests

res = requests.get(
    "https://api.ruuster.com/v1/leads/map?groupBy=zip&limit=3",
    headers={"x-api-key": os.environ["RUUSTER_API_KEY"], "x-email": os.environ["RUUSTER_EMAIL"]},
    timeout=30,
)
body = res.json()
if not body["ok"]:
    raise RuntimeError(f'{body["error"]["code"]}: {body["error"]["message"]} ({body.get("requestId")})')
print(body["data"])
```

## Example response

```json
{
  "ok": true,
  "data": {
    "groupBy": "zip",
    "cellDegrees": null,
    "totals": { "views": 1085, "favorites": 41, "tourRequests": 9, "leads": 212, "listings": 871,
                "mappedListings": 866, "unmappedListings": 5 },
    "bounds": { "south": 33.4012, "west": -84.9133, "north": 34.6127, "east": -83.8021 },
    "focus": { "south": 33.7108, "west": -84.6655, "north": 34.3394, "east": -84.1302 },
    "points": [
      { "lat": 34.0391, "lng": -84.3422, "views": 64, "favorites": 4, "tourRequests": 1, "leads": 23,
        "listings": 41, "medianPrice": 845000, "label": "Roswell, GA 30075" },
      { "lat": 34.0702, "lng": -84.2711, "views": 51, "favorites": 2, "tourRequests": 0, "leads": 19,
        "listings": 37, "medianPrice": 699000, "label": "Alpharetta, GA 30009" },
      { "lat": 33.9512, "lng": -84.5498, "views": 38, "favorites": 3, "tourRequests": 2, "leads": 17,
        "listings": 30, "medianPrice": 512500, "label": "Marietta, GA 30064" }
    ],
    "pointsOmitted": 118,
    "areas": [
      { "city": "Roswell", "state": "GA", "lat": 34.0288, "lng": -84.3497, "views": 112, "favorites": 6,
        "tourRequests": 2, "leads": 38, "listings": 77, "medianPrice": 799000 }
    ]
  },
  "meta": {
    "range": { "from": "2026-09-19", "to": "2026-10-19", "tz": "America/Chicago" },
    "eventsAvailableFrom": "2026-09-18"
  }
}
```

## Ruuster and other sources

With `sources=1`, listing views your CRM recorded from other sources (Ylopo, Zillow, Realtor.com, RealScout, your IDX site and so on) are placed on the same map. Ruuster's own views are counted from Ruuster's activity, and the copies of them that your CRM holds (source `Ruuster`, or `App` before May 2026) are left out so nothing is counted twice. Views with no source recorded are left out. Other-source views are counted only from the day Ruuster began recording your leads' activity for the API, so both sides cover the same days. Other-source views have no listing or lead attached, so they never add to `leads` or `listings`. With `groupBy=listing`, each place other-source views happened is its own point, with no `propertyId` or `address`, listed after your listings (up to half of `limit` of them), so every property shows on the map. They come only from CRMs that send listing events back to Ruuster, so an account whose CRM sends none shows 0 outside views.

## Drawing the map

Plot `points` by `lat` and `lng`, sized or colored by `leads` (how many people) or `views` (how much attention), and fit the view to `focus` (or `bounds` to show every point). `areas` makes a ready summary table beside the map. Listing locations come from the MLS record; a listing with no location is counted in the totals but not placed.
