Markets

Real-time competitive intelligence for every short-term-rental market you operate in. Powered by Atlas — comp sets, nightly rates, availability, occupancy, demand signals, and local events, exposed through one read API. Learn more about Atlas →

What you get

  • Markets you operate in — auto-derived from your listings. As soon as you add a property in Lisbon, Lisbon shows up in GET /v1/markets.
  • Market-wide metrics — active listing count, median ADR, occupancy, revenue per available night, supply trend, and a 90-day demand index.
  • Per-listing comp sets — the same competitive set used to price your listing, returned with rate position (p25 / p50 / p75) and an under/over-priced flag.
  • Calendar overlays — day-by-day demand from Wheelhouse plus a curated local-events feed (concerts, sports, conferences) with rank and expected attendance.
  • Browse new markets — a global slice you can paginate through to find markets to expand into, sorted by yield, growth, or supply.

Where this data comes from

Every metric in the markets API is sourced from Atlas, our market-data partner. Nothing here is an estimate; if you ask for the median ADR in Lisbon for next Friday, you get the median of actual live listings priced for that night.

List your markets

Returns every market that contains at least one of your listings, plus a top-line snapshot of each. This is the right call to drive a markets index page in your dashboard.

GET/v1/markets
curl 'https://api.repull.dev/v1/markets' \
  -H 'Authorization: Bearer sk_live_...'

Query parameters

limitintegerDefault: 50

Max markets to return. 1-200.

offsetintegerDefault: 0

Pagination offset.

sortstringDefault: listings_desc

One of listings_desc, adr_desc, occupancy_desc, revpan_desc.

Response

{
  "data": [
    {
      "city": "lisbon",
      "display_name": "Lisbon, Portugal",
      "country": "PT",
      "your_listings": 14,
      "metrics": {
        "active_listings": 8421,
        "median_adr": 142.50,
        "occupancy": 0.71,
        "revpan": 101.18,
        "supply_trend_30d": 0.04,
        "demand_index_90d": 1.18
      },
      "updated_at": "2026-04-30T22:14:09.000Z"
    },
    {
      "city": "barcelona",
      "display_name": "Barcelona, Spain",
      "country": "ES",
      "your_listings": 7,
      "metrics": {
        "active_listings": 11293,
        "median_adr": 168.00,
        "occupancy": 0.79,
        "revpan": 132.72,
        "supply_trend_30d": -0.01,
        "demand_index_90d": 1.04
      },
      "updated_at": "2026-04-30T22:14:09.000Z"
    }
  ],
  "has_more": false
}

Market deep dive

Drop into a single market for the full picture: percentile rate distribution, channel mix, amenity-weighted comp sets, top-performing listings, and the trailing 90-day occupancy and ADR series. Use this to back a single-market dashboard or a deep analysis surface.

GET/v1/markets/{city}
curl 'https://api.repull.dev/v1/markets/lisbon' \
  -H 'Authorization: Bearer sk_live_...'

Response (excerpt)

{
  "city": "lisbon",
  "display_name": "Lisbon, Portugal",
  "country": "PT",
  "metrics": {
    "active_listings": 8421,
    "median_adr": 142.50,
    "occupancy": 0.71,
    "revpan": 101.18
  },
  "rate_distribution": {
    "p10": 68,
    "p25": 92,
    "p50": 142.50,
    "p75": 198,
    "p90": 285
  },
  "channel_mix": {
    "airbnb": 0.62,
    "booking": 0.31,
    "vrbo": 0.05,
    "direct": 0.02
  },
  "your_position": {
    "listing_count": 14,
    "median_adr": 178,
    "rate_position": 0.68,
    "underpriced_count": 2,
    "overpriced_count": 0
  },
  "trailing_90d": {
    "adr": [/* ... daily series ... */],
    "occupancy": [/* ... daily series ... */]
  },
  "updated_at": "2026-04-30T22:14:09.000Z"
}

Calendar view

Day-by-day demand and pricing across a date range. Combines Wheelhouse market demand with a curated events feed — concerts, sports, conferences, and major holidays — so you can see why a Friday three weeks from now is going to spike.

GET/v1/markets/{city}/calendar
curl 'https://api.repull.dev/v1/markets/lisbon/calendar?from=2026-06-01&to=2026-06-30' \
  -H 'Authorization: Bearer sk_live_...'

Query parameters

fromstring (YYYY-MM-DD)Required

First date in the range. Inclusive.

tostring (YYYY-MM-DD)Required

Last date in the range. Inclusive. Max 365 days from `from`.

listing_idstring

Optional. Overlay your listing's current rates and bookings on top of the market view.

Response (excerpt)

{
  "city": "lisbon",
  "from": "2026-06-01",
  "to": "2026-06-30",
  "days": [
    {
      "date": "2026-06-01",
      "demand_index": 0.94,
      "median_adr": 138,
      "occupancy": 0.69,
      "events": []
    },
    {
      "date": "2026-06-12",
      "demand_index": 1.74,
      "median_adr": 224,
      "occupancy": 0.92,
      "events": [
        {
          "name": "Rock in Rio Lisboa — Day 1",
          "category": "concert",
          "rank": 1,
          "expected_attendance": 80000
        }
      ]
    },
    {
      "date": "2026-06-13",
      "demand_index": 1.81,
      "median_adr": 238,
      "occupancy": 0.95,
      "events": [
        {
          "name": "Rock in Rio Lisboa — Day 2",
          "category": "concert",
          "rank": 1,
          "expected_attendance": 80000
        },
        {
          "name": "Festas de Lisboa — Santo António",
          "category": "festival",
          "rank": 2,
          "expected_attendance": 250000
        }
      ]
    }
  ]
}

Pass listing_id to see your overlay

Add ?listing_id=lst_abc123 and each day in the response also returns your_rate, your_booked, and recommended_rateso you can render "you vs the market" in one chart.

Common patterns

Show me where I'm under-priced

your_position.underpriced_count on each market tells you how many of your listings are sitting below the comp-set median. Sort markets by it to triage.

const { data } = await repull.markets.list()

const opportunities = data
  .filter(m => m.your_position && m.your_position.underpriced_count > 0)
  .sort((a, b) => b.your_position.underpriced_count - a.your_position.underpriced_count)

for (const m of opportunities) {
  console.log(`${m.display_name}: ${m.your_position.underpriced_count} under-priced`)
}

Find new markets to expand into

Pass browseMarkets: trueto escape the "markets I'm already in" scope and see the global ranked list. Sort by RevPAN, occupancy, or growth.

const { data } = await repull.markets.list({
  browseMarkets: true,
  sort: 'revpan_desc',
  limit: 25,
})

// data is now ranked across every market in the catalog, not just yours.

Heat map of my listings vs market

For each of your listings, fetch the calendar with the listing overlay. The diff between your_rate and recommended_rate across 30 days gives you a per-day heat-map cell.

const listings = await repull.properties.list()

const heatmap = await Promise.all(
  listings.data.map(l =>
    repull.markets.calendar(l.market, {
      from: '2026-06-01',
      to: '2026-06-30',
      listing_id: l.id,
    })
  )
)

// Render heatmap[i].days[j].your_rate vs heatmap[i].days[j].recommended_rate
// in a grid (listings on Y, dates on X).

Caching

Shared market slices (the data that's the same for everyone in a market) are cached for 5 minutes. Per-listing overlays — anything that depends on your account or a specific listing ID — bypass the cache and fetch live. The response always includes updated_at so you know how fresh the data is.

Rate limits

Markets endpoints follow the standard tier-based limits. See Rate Limits for the per-tier numbers. The deep-dive and calendar endpoints are higher cost than GET /v1/markets — budget accordingly.

AI