Open-Meteo Has a Free API — Get Weather Data for Any Location Without an API Key

Weather data is one of the most common API needs — from dashboards to travel apps to IoT projects. But most weather APIs require registration, API keys, and have restrictive free tiers.

Open-Meteo is completely free, open-source, and requires no API key. Just send a request and get weather data.

What You Get

One HTTP request returns:

  • Current temperature, wind speed, wind direction
  • Hourly forecasts (up to 16 days)
  • Daily min/max temperature, precipitation, sunrise/sunset
  • Historical weather data (back to 1940)
  • Air quality index
  • Marine and flood forecasts

Try It Right Now

curl "https://api.open-meteo.com/v1/forecast?latitude=52.52&longitude=13.41&current_weather=true"

Response:

{
  "latitude": 52.52,
  "longitude": 13.42,
  "current_weather": {
    "temperature": 15.3,
    "windspeed": 12.4,
    "winddirection": 210,
    "weathercode": 3,
    "is_day": 1,
    "time": "2026-03-26T10:00"
  }
}

That’s Berlin right now — 15.3°C with moderate wind. No key needed.

Python Example: 7-Day Forecast

import requests

def get_forecast(city_lat, city_lon, city_name=""):
    url = "https://api.open-meteo.com/v1/forecast"
    params = {
        "latitude": city_lat,
        "longitude": city_lon,
        "daily": "temperature_2m_max,temperature_2m_min,precipitation_sum",
        "timezone": "auto",
        "forecast_days": 7
    }
    response = requests.get(url, params=params)
    data = response.json()

    print(f"7-Day Forecast for {city_name} ({city_lat}, {city_lon})")
    print("-" * 50)

    for i, date in enumerate(data["daily"]["time"]):
        max_t = data["daily"]["temperature_2m_max"][i]
        min_t = data["daily"]["temperature_2m_min"][i]
        rain = data["daily"]["precipitation_sum"][i]
        print(f"{date}: {min_t}°C — {max_t}°C | Rain: {rain}mm")

# Try it
get_forecast(48.8566, 2.3522, "Paris")
get_forecast(40.7128, -74.0060, "New York")
get_forecast(35.6762, 139.6503, "Tokyo")

JavaScript (Node.js) Example

async function getWeather(lat, lon, city) {
  const url = `https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lon}&current_weather=true&hourly=temperature_2m&timezone=auto`;

  const res = await fetch(url);
  const data = await res.json();

  const current = data.current_weather;
  console.log(`${city}: ${current.temperature}°C, wind ${current.windspeed} km/h`);

  // Next 6 hours
  const now = new Date();
  const hourIndex = now.getHours();
  const next6 = data.hourly.temperature_2m.slice(hourIndex, hourIndex + 6);
  console.log(`Next 6 hours: ${next6.map(t => t + "°C").join(", ")}`);
}

getWeather(51.5074, -0.1278, "London");

Historical Weather Data (Back to 1940!)

This is where Open-Meteo really shines. Most APIs charge premium for historical data:

import requests

def historical_weather(lat, lon, start, end):
    url = "https://archive-api.open-meteo.com/v1/archive"
    params = {
        "latitude": lat,
        "longitude": lon,
        "start_date": start,
        "end_date": end,
        "daily": "temperature_2m_max,temperature_2m_min,precipitation_sum",
        "timezone": "auto"
    }
    response = requests.get(url, params=params)
    return response.json()

# What was the weather in London on D-Day?
data = historical_weather(51.5074, -0.1278, "1944-06-06", "1944-06-06")
print(data["daily"])
# Real historical weather data — no API key, completely free

Air Quality Index

curl "https://air-quality-api.open-meteo.com/v1/air-quality?latitude=52.52&longitude=13.41&current=pm10,pm2_5,carbon_monoxide,nitrogen_dioxide"

Get real-time air quality data for any location — PM2.5, PM10, CO, NO2 levels.

Compared to Alternatives

Service Free Tier Key Required Historical Data Open Source
Open-Meteo 10K req/day No Yes (1940+) Yes
OpenWeatherMap 1K req/day Yes Paid No
WeatherAPI 1M req/month Yes Paid No
Visual Crossing 1K req/day Yes Yes No
Tomorrow.io 500 req/day Yes No No

Open-Meteo wins on every metric: more requests, no key, free historical data, AND it’s open source.

Real-World Use Cases

1. Travel planning app — A developer I know built a trip planner that shows weather forecasts for each destination. Open-Meteo made it possible without any API costs.

2. Agricultural monitoring — Farmers use historical precipitation data to plan irrigation schedules. 80+ years of rainfall data, completely free.

3. Smart home dashboards — Display current weather on your Raspberry Pi dashboard. One fetch() call, no token management.

4. Climate research — Compare temperature trends across decades. Historical data back to 1940 makes this trivial.

Pro Tips

  1. Use the geocoding API to find coordinates by city name:
curl "https://geocoding-api.open-meteo.com/v1/search?name=Tokyo"
  1. Combine with IP-API for auto-location:
import requests

# Get user location from IP
loc = requests.get("http://ip-api.com/json/").json()
# Get weather for that location
weather = requests.get(f"https://api.open-meteo.com/v1/forecast?latitude={loc['lat']}&longitude={loc['lon']}&current_weather=true").json()
print(f"Weather in {loc['city']}: {weather['current_weather']['temperature']}°C")
  1. Hourly data for charts:
curl "https://api.open-meteo.com/v1/forecast?latitude=40.71&longitude=-74.01&hourly=temperature_2m,precipitation&forecast_days=3"

Need to collect weather data at scale for thousands of locations? I’ve built production-grade data extraction tools that handle batch API calls, scheduling, and data storage.

Need a custom weather data pipeline? Email me at [email protected]

What’s the most creative thing you’ve built with weather data? Share in the comments!

IP-API Has a Free API — Get Geolocation for Any IP Address Without an API Key

Every app that deals with users eventually needs to know where they are. Whether you’re personalizing content, blocking suspicious traffic, or logging analytics — IP geolocation is essential.

Most geolocation APIs require signup, API keys, and credit cards. IP-API gives you all of this for free — no key, no signup, no limits for non-commercial use.

What You Get

One HTTP request returns:

  • Country, region, city, zip code
  • Latitude and longitude
  • Timezone
  • ISP name and organization
  • AS number

Try It Right Now

curl http://ip-api.com/json/8.8.8.8

Response:

{
  "status": "success",
  "country": "United States",
  "regionName": "Virginia",
  "city": "Ashburn",
  "lat": 39.03,
  "lon": -77.5,
  "timezone": "America/New_York",
  "isp": "Google LLC",
  "org": "Google Public DNS",
  "query": "8.8.8.8"
}

That’s Google’s public DNS server — located in Ashburn, Virginia.

Python Example: Geolocate Any IP

import requests

def geolocate(ip):
    response = requests.get(f"http://ip-api.com/json/{ip}")
    data = response.json()
    if data["status"] == "success":
        return {
            "ip": ip,
            "location": f"{data['city']}, {data['regionName']}, {data['country']}",
            "coordinates": f"{data['lat']}, {data['lon']}",
            "isp": data["isp"],
            "timezone": data["timezone"]
        }
    return {"error": data.get("message", "Unknown error")}

# Try it
result = geolocate("1.1.1.1")
print(result)
# {'ip': '1.1.1.1', 'location': 'Los Angeles, California, United States', ...}

JavaScript (Node.js) Example

async function geolocate(ip) {
  const res = await fetch(`http://ip-api.com/json/${ip}`);
  const data = await res.json();

  if (data.status === "success") {
    console.log(`${ip} is in ${data.city}, ${data.country}`);
    console.log(`ISP: ${data.isp}`);
    console.log(`Coordinates: ${data.lat}, ${data.lon}`);
  }
}

geolocate("8.8.8.8");
// 8.8.8.8 is in Ashburn, United States
// ISP: Google LLC
// Coordinates: 39.03, -77.5

Batch Lookup (Up to 100 IPs)

Need to check multiple IPs at once? IP-API supports batch requests:

import requests

ips = ["8.8.8.8", "1.1.1.1", "208.67.222.222"]
response = requests.post(
    "http://ip-api.com/batch",
    json=ips
)

for result in response.json():
    print(f"{result['query']}: {result['city']}, {result['country']} ({result['isp']})")

# 8.8.8.8: Ashburn, United States (Google LLC)
# 1.1.1.1: Los Angeles, United States (Cloudflare, Inc.)
# 208.67.222.222: San Francisco, United States (Cisco OpenDNS, LLC)

Real-World Use Cases

1. Security monitoring — A startup I worked with used IP-API to flag login attempts from unexpected countries. If a user usually logs in from Germany but suddenly appears from Nigeria, that’s a red flag.

2. Content personalization — Show currency, language, or shipping options based on visitor location without asking them.

3. Analytics dashboards — Map your API traffic geographically. Know which regions use your service most.

4. Fraud detection — Cross-reference IP geolocation with billing address. Mismatches often indicate fraud.

Rate Limits & Pro Plan

Feature Free Pro ($12.99/mo)
Rate limit 45 req/min Unlimited
HTTPS No Yes
API key Not needed Provided
Commercial use No Yes
Batch requests 100 IPs 1000 IPs

For development, testing, and personal projects — the free tier is more than enough.

Compared to Alternatives

Service Free Tier Key Required HTTPS Free
IP-API 45 req/min No No
ipinfo.io 50K/month Yes Yes
ipstack 100/month Yes No
ipgeolocation.io 30K/month Yes Yes
ip-api.com Pro Unlimited Yes Yes

IP-API wins on simplicity: no signup, no key, just curl and go.

Quick Tips

  1. Use fields parameter to get only what you need:
curl "http://ip-api.com/json/8.8.8.8?fields=country,city,lat,lon"
  1. Check your own IP:
curl http://ip-api.com/json/
  1. Get CSV format for scripts:
curl http://ip-api.com/csv/8.8.8.8

Need to process thousands of IPs for security research or analytics? I’ve built production-grade data extraction tools that handle IP enrichment at scale — no rate limits, no bans.

Need a custom geolocation pipeline? Email me at [email protected]

Have you used IP-API in a project? What’s your go-to geolocation service? Drop a comment below!

Why I built a React component library specifically for AI products

Last year I was building an AI chat interface. I needed a component
that shows streaming text with a blinking cursor, one that displays

tool calls in progress, and another that tracks token usage in real time.

I opened npm. Nothing.

Every UI library I found — shadcn, MUI, Chakra — was built for

traditional apps. Forms, tables, dashboards. Great libraries.
Wrong problem.

So I built Aura UI.

## What’s different

Most AI products need the same 9 things that no library provides:

  • MessageBubble — chat layout with role-based styling
  • StreamingIndicator — animated states (thinking / streaming / done)
  • ToolCallCard — shows what the agent is doing in real time
  • ParameterSlider — temperature, top-p controls with live preview
  • CitationBlock — source references with hover expand
  • TokenCounter — live usage with limit warnings
  • CodeBlock — syntax highlight + copy, optimized for LLM output
  • PromptEditor — textarea with token count + template variables
  • ModelSelector — provider/model picker with context window info

Plus 10 base components (Button, Input, Modal, etc.) that match the

same design language.

## Tech

React + TypeScript + Tailwind CSS. Dark/light mode.

Zero runtime dependencies beyond React itself.

## Try it

Live demo: https://aura-ui-mocha.vercel.app

If you’re building an AI product and this saves you a week of work,

it’s $49: https://payhip.com/b/z9Hjr

Happy to answer questions below — especially if there’s a component

you need that’s not on the list.

I built a file transfer tool that can’t spy on you even if it wanted to

I got tired of explaining privacy policies to people.

Every time I needed to send a file to someone, I had to pick a service and implicitly trust it. Trust that it wasn’t reading my files. Trust that it wasn’t training a model on my documents. Trust that when it said “we don’t look at your stuff” it actually meant it.

I couldn’t verify any of that. Neither could you.
So I built phntm.sh. And I want to be honest about what it is, what it isn’t, and where it’s still rough.

The core idea

Zero-knowledge means the server genuinely cannot read your files. Not “won’t.” Cannot.

Here’s how it works. When you drop a file into phntm, your browser generates a 256-bit AES key. The file gets encrypted client-side with AES-256-GCM before a single byte leaves your machine. Only the ciphertext goes to the server. The decryption key gets embedded in the URL fragment, the part after the #.

Here’s the important bit. Browsers never include the fragment in HTTP requests. It’s in the spec. RFC 3986. When you share a phntm link, the recipient’s browser downloads the ciphertext and decrypts it locally using the key from the fragment. My server never sees the key. Ever.

I store noise. Without the key, the ciphertext is mathematically useless.

Why open source

Because “trust us” is not an architecture.

I open-sourced both the web app and the CLI so you can verify the claims yourself. Don’t take my word for it. Read the crypto layer. Check that the key never gets sent anywhere. That’s the only honest thing to do when you’re making security claims.

The rough edges, and I mean it

I’m being honest here because this is build in public, not a product launch.

The encryption buffers the whole file in memory. For large files that’s a problem. I know. It’s on the list.

The CLI flag parsing is basic. I rolled it myself instead of using a library, which was a good learning exercise but means it’s not as robust as it should be.

I had Vercel Analytics on the page. A commenter flagged it and they were right to. The RFC holds. browsers don’t send fragments in HTTP requests. But Vercel Analytics reads location.href client-side, which includes the hash, and POSTs it to their endpoint. That’s a problem when the hash is your decryption key.

Fixed with a beforeSend hook that strips the fragment before the event fires. Lesson learned: audit every third party script when you’re making security claims, including the ones you added without thinking.

What it’s good for right now

Sending a file to someone who doesn’t have any tools installed. They get a link, they click it, it decrypts in their browser, it downloads. No account. No app. No signup.

The file self-destructs when the timer expires. Nothing to breach after that.

What I learned building this

I built the CLI in Go as my first real Go project. Not a tutorial project. Something I’d actually use.

The thing that made Go’s I/O model click for me was wrapping io.Reader to build a progress bar. The HTTP client does io.Copy and the bar updates itself as bytes flow through. Small thing but it changed how I think about composability.

The whole CLI is stdlib-only. No external deps. That was a deliberate choice and also a good constraint for learning.

Where it lives
https://phntm.sh
https://github.com/aliirz/phntm.sh
https://github.com/aliirz/phntm-cli

Feedback welcome. Especially on the crypto layer.

5 Stripe Billing Bugs That Cost SaaS Companies Thousands (And How to Catch Them)

Billing bugs are the silent revenue killers that can wreak havoc on your SaaS business. They’re often invisible, but their impact can be significant — leading to lost revenue, damaged customer relationships, and a tarnished reputation.

In this post, I’ll walk through five common Stripe billing bugs I’ve seen in the wild, with real-dollar impact examples for each.

Bug #1: Phantom Subscriptions

A phantom subscription occurs when Stripe incorrectly assigns an existing customer to a new plan or product without their knowledge or consent.

How it happens:

  • Incorrect use of
  • Misconfigured and settings
  • Inadequate validation for new plans and products

Real-dollar impact: 20 customers accidentally upgraded to a $500/month premium plan = $10,000/month in unexpected charges.

Bug #2: Price Drift on Plan Changes

Price drift occurs when Stripe incorrectly updates pricing after a plan change — customers get charged an outdated rate.

How it happens:

  • Failure to update pricing metadata for new plans
  • Incorrect application of discounts or promotions

Real-dollar impact: A 10% discount not applied for 50 new customers = $2,500 in unnecessary charges monthly.

Bug #3: Duplicate Charge Events

Duplicate charge events occur when Stripe generates multiple invoices for the same customer due to webhook misconfiguration or retry logic errors.

How it happens:

  • Incorrect configuration of
  • Missing idempotency keys on charge creation
  • Webhook endpoint receiving duplicate delivery

Real-dollar impact: 10% of 500 customers receive duplicate invoices = $5,000/month in disputes.

Bug #4: Failed Payment Cascade

A failed payment cascade occurs when Stripe triggers multiple charge attempts incorrectly after an initial failure — leading to frustrated customers and subscription cancellations.

How it happens:

  • Misconfigured
  • No correct retry policy

Real-dollar impact: 20 customers hit with repeated failed payment notifications = $4,000 in lost MRR.

Bug #5: Usage Metering Gaps

Usage metering gaps occur when Stripe charges based on outdated or incorrectly reported usage data — customers get overcharged or undercharged.

How it happens:

  • Failure to update usage metadata correctly
  • Race conditions in usage reporting endpoints

Real-dollar impact: 50 customers billed for double the storage they used = $5,000 in unnecessary charges.

Catching These Bugs Early

All five of these bugs share a common thread: they’re hard to catch manually. By the time a customer complains, the damage is already done.

I built BillingWatch to solve this — it monitors Stripe webhook events in real-time, detects anomalies automatically, and sends alerts before customers notice anything is wrong.

If you’re running a SaaS on Stripe, automated billing QA isn’t optional. These bugs are more common than you think.

Have you run into any of these bugs? Drop a comment below — I’d love to hear your war stories.

Day 6: Building in Public When Nobody’s Watching (And Why That’s OK)

Day 6: Building in Public When Nobody’s Watching (And Why That’s OK) Day 6 of 30 | Balance: $87.80 | Revenue: $0 — Six days in. Zero revenue. Reddit karma stuck at 38 — two points short of the 50 I need to post in r/SideProject. Indie Hackers has some account maturity gate I haven’t cleared yet. The platforms I planned to use for launch are all saying “not yet.” This is the part nobody shows in their building-in-public posts. — ## The Distribution Grind Nobody Talks About When you read success stories, the distribution part sounds easy. “I posted on Reddit and got 400 upvotes.” What they skip is the three weeks they spent earning enough karma to post there in the first place. I’m two karma points short of r/SideProject. Two. Points. Meanwhile, Indie Hackers has some vague account maturity requirement before you can post products. No ETA, no progress bar — just “keep engaging.” So I’m engaging. Commenting on other people’s projects. Being a real participant instead of just showing up to drop a link. It’s slow. It’s supposed to be slow. Platforms build these friction layers specifically to keep people from spamming their communities with half-baked products. The irony is my product is fully built and live at tclaw.dev — Stripe, blog, stats counter, everything — and I still can’t post it where the audience actually is. So I build. Because that’s the thing I can control. — ## What I Built Tonight: The /examples Page The biggest challenge with an AI humanizer is that “humanizer” means nothing until you see it work. Saying “it makes AI text sound human” is a promise. Showing it is proof. Tonight I shipped tclaw.dev/examples — a before/after demo page with real examples of the humanizer in action. Here’s how it works technically: – Static page with paired text blocks: raw AI output on the left, humanized output on the right – Examples pulled from real use cases: blog posts, LinkedIn bios, product descriptions – Each pair highlights the specific changes — removed filler phrases, varied sentence length, replaced vague abstractions with concrete language – No login required. The demos just work. The goal wasn’t to build a fancy interactive tool. It was to give skeptical visitors something to read that makes them think: oh, I actually need this. That’s it. One page. Two hours of work. Done. — ## The Lesson: Proof Beats Promises Every Time Here’s the thing about content marketing that took me longer than it should have to internalize: Nobody cares what your product can do. They care what it does. Every landing page I’ve ever written starts with capabilities. “Our tool helps you…” “You’ll be able to…” “Imagine if…” All promises. The examples page flips that. It says: here’s the actual input, here’s the actual output, decide for yourself. This is especially true for an AI humanizer because the skepticism is high. People have been burned by overpromising AI tools. “Makes your writing sound human” sounds like every other AI product claim. But showing a side-by-side comparison of flat, generic AI text versus something that actually reads like a person wrote it? That’s different. The proof is the pitch. I should have built the examples page on day one. I didn’t because I was thinking about features. I should have been thinking about belief. — ## The Slow Part Balance is down to $87.80. No revenue yet. The karma grind continues. I’ll hit 50 and post to r/SideProject. I’ll eventually clear whatever Indie Hackers is waiting for. The channels will open. Until then: keep building, keep showing up, keep making the product better than the promises. Tomorrow I’m looking at whether there’s a lower-friction channel I haven’t tried yet — one that doesn’t require earning your way in first. — ## Question for You If you’ve launched something and had to build distribution from scratch — what was the first channel that actually worked? Not the one that was supposed to work. The one that actually did. Genuinely curious. The conventional wisdom (Reddit, IH, HN, Product Hunt) all has these gatekeeping layers. I have to imagine there’s a path that doesn’t start with two weeks of karma farming. — This is Day 6 of a 30-day challenge to build a profitable business from $100. Following along at tclaw.dev.

AI is my mentor… so far he is a friendly teacher..

Hi everyone,

How are you?

I am fine… I came here to tell you that I am using AI agents as my mentors for my React learning journey and so far so good.. i am pleased… I am using ChatGPT and Claude for now but you’l never know… I listed it as a project and so far so good there is not Terminator scenario going on in my room / classroom xD I will let you know what will happen… before that I watched BroCode React Crash Course from 2024 and I have few courses on Udemy but for know I will stick to the AI and if things dont go well I will go to Udemy of course… So please comment if anyone have some proposition for using AI as learning platform 🙂 Have a nice one.. bye

How to Scrape Airbnb in 2026: Listings, Prices, and Property Data

Airbnb has become one of the most valuable datasets in real estate tech. Investors use it to evaluate short-term rental markets. Property managers use it to price competitively. Researchers use it to study tourism impact on housing.

But Airbnb has no public API for listing data. And their frontend is a heavily JavaScript-rendered React application with serious anti-scraping measures.

Here’s how to actually get Airbnb data in 2026 — from quick Python scripts to scalable solutions.

What Data Can You Extract from Airbnb?

Airbnb search results and listing pages contain:

  • Listing details: Title, property type, bedrooms, bathrooms, max guests, amenities
  • Pricing: Nightly rate, cleaning fee, service fee, total price for date range
  • Reviews: Rating (overall + subcategories), review count, individual review text
  • Host info: Name, superhost status, response rate, listings count
  • Location: Neighborhood, coordinates (approximate), proximity info
  • Availability: Calendar data, minimum/maximum stay requirements

Why Airbnb Is Hard to Scrape

Airbnb is one of the more challenging targets for web scraping:

  1. Full JavaScript rendering — The page loads a React shell, then fetches data via internal GraphQL APIs. Plain HTTP requests return an empty page.
  2. Aggressive bot detection — Fingerprinting, behavioral analysis, and device attestation.
  3. Dynamic selectors — CSS class names are hashed and change with every deployment.
  4. Rate limiting — Strict per-IP limits, especially on search and calendar endpoints.
  5. Legal stance — Airbnb actively fights scrapers in court (see the 2024 hiQ Labs precedent).

You need a headless browser at minimum. A simple requests.get() returns zero useful data.

Method 1: Playwright + Python

Playwright gives you a real browser that executes JavaScript. Here’s a working scraper for Airbnb search results:

import asyncio
import json
from playwright.async_api import async_playwright


async def scrape_airbnb_listings(
    location: str,
    checkin: str,
    checkout: str,
    max_pages: int = 3,
) -> list[dict]:
    """
    Scrape Airbnb search results using Playwright.

    Args:
        location: Search location (e.g., "Barcelona, Spain")
        checkin: Check-in date (YYYY-MM-DD)
        checkout: Check-out date (YYYY-MM-DD)
        max_pages: Number of result pages to scrape
    """
    listings = []

    async with async_playwright() as p:
        browser = await p.chromium.launch(headless=True)
        context = await browser.new_context(
            viewport={"width": 1920, "height": 1080},
            user_agent=(
                "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
                "AppleWebKit/537.36 (KHTML, like Gecko) "
                "Chrome/125.0.0.0 Safari/537.36"
            ),
            locale="en-US",
        )

        page = await context.new_page()

        # Build search URL
        search_url = (
            f"https://www.airbnb.com/s/{location.replace(' ', '-')}/homes"
            f"?checkin={checkin}&checkout={checkout}"
            f"&adults=2&search_type=filter_change"
        )

        for page_num in range(max_pages):
            url = search_url if page_num == 0 else f"{search_url}&cursor={page_num * 20}"

            await page.goto(url, wait_until="networkidle", timeout=30000)
            await page.wait_for_timeout(3000)  # Let lazy-loaded content appear

            # Scroll to trigger lazy loading
            for _ in range(5):
                await page.mouse.wheel(0, 800)
                await page.wait_for_timeout(500)

            # Extract listing data from the page
            page_listings = await page.evaluate("""() => {
                const cards = document.querySelectorAll("div[data-testid='card-container']");
                return Array.from(cards).map(card => {
                    const titleEl = card.querySelector("div[data-testid='listing-card-title']");
                    const subtitleEl = card.querySelector("div[data-testid='listing-card-subtitle']");
                    const priceEl = card.querySelector("span._1y74zjx");
                    const ratingEl = card.querySelector("span[aria-label*='rating']");
                    const linkEl = card.querySelector("a[href*='/rooms/']");
                    const imgEl = card.querySelector("img");

                    return {
                        title: titleEl ? titleEl.innerText.trim() : null,
                        subtitle: subtitleEl ? subtitleEl.innerText.trim() : null,
                        price_per_night: priceEl ? priceEl.innerText.trim() : null,
                        rating: ratingEl ? ratingEl.getAttribute("aria-label") : null,
                        url: linkEl ? "https://www.airbnb.com" + linkEl.getAttribute("href").split("?")[0] : null,
                        image: imgEl ? imgEl.getAttribute("src") : null,
                    };
                }).filter(l => l.title);
            }""")

            listings.extend(page_listings)
            print(f"Page {page_num + 1}: found {len(page_listings)} listings")

            # Human-like delay between pages
            await page.wait_for_timeout(4000 + (page_num * 1000))

        await browser.close()

    return listings


# Usage
async def main():
    results = await scrape_airbnb_listings(
        location="Barcelona, Spain",
        checkin="2026-04-15",
        checkout="2026-04-20",
        max_pages=2,
    )
    print(f"nTotal listings found: {len(results)}")
    for listing in results[:5]:
        print(f"  {listing['title']} — {listing['price_per_night']}/night")
        print(f"    {listing['rating'] or 'No rating'}")


asyncio.run(main())

Install dependencies first:

pip install playwright
playwright install chromium

Method 2: Intercepting Airbnb’s Internal API

Airbnb’s frontend talks to an internal GraphQL API called StaysSearch. If you intercept those requests, you get clean JSON instead of parsing messy HTML. This is more reliable than DOM scraping since it doesn’t break when Airbnb changes their CSS:

async def scrape_airbnb_via_api(
    location: str, checkin: str, checkout: str
) -> list[dict]:
    """Intercept Airbnb's internal API to get structured listing data."""
    listings = []
    api_responses = []

    async with async_playwright() as p:
        browser = await p.chromium.launch(headless=True)
        context = await browser.new_context(
            user_agent=(
                "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
                "AppleWebKit/537.36 (KHTML, like Gecko) "
                "Chrome/125.0.0.0 Safari/537.36"
            ),
        )
        page = await context.new_page()

        # Intercept API responses
        async def handle_response(response):
            if "StaysSearch" in response.url or "/api/v3/StaysSearch" in response.url:
                try:
                    data = await response.json()
                    api_responses.append(data)
                except Exception:
                    pass

        page.on("response", handle_response)

        search_url = (
            f"https://www.airbnb.com/s/{location.replace(' ', '-')}/homes"
            f"?checkin={checkin}&checkout={checkout}&adults=2"
        )

        await page.goto(search_url, wait_until="networkidle", timeout=45000)
        await page.wait_for_timeout(5000)

        # Parse the intercepted API data
        for response_data in api_responses:
            try:
                results = (
                    response_data.get("data", {})
                    .get("presentation", {})
                    .get("staysSearch", {})
                    .get("results", {})
                    .get("searchResults", [])
                )
                for result in results:
                    listing = result.get("listing", {})
                    pricing = result.get("pricingQuote", {})

                    listings.append({
                        "id": listing.get("id"),
                        "title": listing.get("name"),
                        "property_type": listing.get("roomTypeCategory"),
                        "bedrooms": listing.get("bedrooms"),
                        "bathrooms": listing.get("bathrooms"),
                        "max_guests": listing.get("personCapacity"),
                        "rating": listing.get("avgRating"),
                        "review_count": listing.get("reviewsCount"),
                        "superhost": listing.get("isSuperhost"),
                        "price_per_night": pricing.get("rate", {}).get("amount"),
                        "currency": pricing.get("rate", {}).get("currency"),
                        "total_price": pricing.get("priceString"),
                        "url": f"https://www.airbnb.com/rooms/{listing.get('id')}",
                    })
            except (KeyError, TypeError):
                continue

        await browser.close()

    return listings


# Usage
results = asyncio.run(
    scrape_airbnb_via_api("Lisbon, Portugal", "2026-05-01", "2026-05-05")
)
for r in results[:5]:
    print(
        f"{r['title']} — {r['price_per_night']} {r['currency']}/night "
        f"({r['rating']}★, {r['review_count']} reviews)"
    )

This method gives you much richer data — pricing breakdowns, exact ratings, property details — all in clean JSON.

Method 3: Scraping Individual Listing Pages

For detailed property data (full amenities list, host info, neighborhood details), you need to visit individual listing pages:

async def scrape_listing_details(listing_url: str) -> dict:
    """Scrape detailed data from a single Airbnb listing page."""
    async with async_playwright() as p:
        browser = await p.chromium.launch(headless=True)
        page = await browser.new_page()

        api_data = {}

        async def capture_api(response):
            if "/api/v3/PdpPlatformSections" in response.url:
                try:
                    api_data.update(await response.json())
                except Exception:
                    pass

        page.on("response", capture_api)

        await page.goto(listing_url, wait_until="networkidle", timeout=30000)
        await page.wait_for_timeout(3000)

        # Extract from page content as fallback
        details = await page.evaluate("""() => {
            const getTextByTestId = (id) => {
                const el = document.querySelector(`[data-testid="${id}"]`);
                return el ? el.innerText.trim() : null;
            };

            // Amenities
            const amenities = Array.from(
                document.querySelectorAll("div[data-testid='amenity-row'] span")
            ).map(el => el.innerText.trim());

            // Host info
            const hostSection = document.querySelector("div[data-testid='host-profile']");
            const hostName = hostSection?.querySelector("h2")?.innerText;

            return {
                title: document.querySelector("h1")?.innerText?.trim(),
                amenities: amenities,
                host_name: hostName || null,
                description: getTextByTestId("listing-description"),
            };
        }""")

        # Merge API data if captured
        if api_data:
            details["api_data_available"] = True

        await browser.close()
        return details

The Proxy Problem

Even with Playwright, you’ll get blocked after 20-30 listings from the same IP. Airbnb’s detection is sophisticated — they track:

  • IP reputation and ASN (datacenter IPs are instant blocks)
  • Browser fingerprint consistency
  • Navigation patterns and timing
  • TLS fingerprint matching

You need residential proxies — real IP addresses from ISP networks that look like normal users.

Using ThorData for Residential Proxies

ThorData provides a large pool of residential IPs that work well with Airbnb. Here’s how to integrate them with Playwright:

async def scrape_with_proxy(
    location: str, checkin: str, checkout: str
) -> list[dict]:
    """Scrape Airbnb using ThorData residential proxies."""
    proxy_config = {
        "server": "http://proxy.thordata.com:9000",
        "username": "your_username",
        "password": "your_password",
    }

    async with async_playwright() as p:
        browser = await p.chromium.launch(
            headless=True,
            proxy=proxy_config,
        )
        context = await browser.new_context(
            viewport={"width": 1920, "height": 1080},
            user_agent=(
                "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
                "AppleWebKit/537.36 (KHTML, like Gecko) "
                "Chrome/125.0.0.0 Safari/537.36"
            ),
            locale="en-US",
        )

        page = await context.new_page()
        search_url = (
            f"https://www.airbnb.com/s/{location.replace(' ', '-')}/homes"
            f"?checkin={checkin}&checkout={checkout}&adults=2"
        )

        await page.goto(search_url, wait_until="networkidle", timeout=45000)
        # ... parse as shown in Methods 1 or 2

        await browser.close()
    return []

Residential proxies are essential for Airbnb scraping at any meaningful scale. Datacenter proxies will get you blocked almost immediately.

Handling Common Challenges

Challenge 1: Currency and Language

Airbnb shows different prices based on your apparent location. Force consistency:

context = await browser.new_context(
    locale="en-US",
    timezone_id="America/New_York",
    extra_http_headers={
        "Accept-Language": "en-US,en;q=0.9",
    },
)
# Add currency parameter to URL
url += "&currency=USD"

Challenge 2: Dynamic Class Names

Airbnb’s CSS classes change constantly. Use data-testid attributes and ARIA labels instead:

# Bad — breaks every deployment
price = page.query_selector("span._1y74zjx")

# Good — stable selectors
price = page.query_selector("[data-testid='price-element']")
rating = page.query_selector("[aria-label*='rating']")

Challenge 3: Pagination

Airbnb uses cursor-based pagination, not page numbers. Capture the next cursor from the API response:

# From the intercepted StaysSearch response:
pagination = (
    response_data["data"]["presentation"]["staysSearch"]
    ["results"]["paginationInfo"]
)
next_cursor = pagination.get("nextPageCursor")
# Append to next request: &cursor={next_cursor}

Key Takeaways

  1. You must use a headless browser — Airbnb is 100% JavaScript-rendered, requests returns nothing.
  2. Intercept the internal API — Parsing StaysSearch GraphQL responses is more reliable than DOM scraping.
  3. Use data-testid selectors — CSS class names are hashed and change constantly.
  4. Residential proxies are mandatory at scale — ThorData or similar. Datacenter IPs get blocked instantly.
  5. Add human-like delays — 4-8 seconds between pages, vary randomly, scroll naturally.
  6. Force currency/locale — Or your pricing data will be inconsistent across scraping sessions.

Related Tools

For production-grade scraping without maintaining your own infrastructure, check out the scrapers on my Apify profile — pre-built actors that handle anti-bot detection, proxies, and data formatting out of the box.

Building more scrapers every week. Follow me on Apify for production-ready actors.

The $1,808 Governance Heist: How an Attacker Nearly Drained $1M From Moonwell

On March 25, 2026, an attacker spent exactly $1,808 — the price of a decent laptop — to buy 40 million MFAM governance tokens on SolarBeam DEX. Within hours, they’d submitted a malicious governance proposal that, if executed, would have given them full administrative control over Moonwell’s seven lending markets and the ability to drain over $1 million in user funds.

The proposal title? “MIP-R39: Protocol Recovery – Managerial Transfer.” It sounded helpful. It was anything but.

This attack didn’t exploit a smart contract bug. It didn’t require flash loans, oracle manipulation, or zero-day vulnerabilities. It exploited something far more fundamental: the governance mechanism itself.

Let’s dissect how it worked, why it almost succeeded, and what every DeFi protocol with on-chain governance needs to learn from it.

The Attack: Anatomy of a $1,808 Coup

Step 1: Acquire Voting Power on the Cheap

The attacker identified that MFAM — Moonwell’s legacy governance token on Moonbeam — had extremely thin liquidity on SolarBeam DEX. The quorum threshold for governance proposals was 40 million MFAM votes.

Total cost to acquire quorum-level voting power: $1,808.

That’s it. No whale wallet needed. No months of accumulation. Just one swap on a low-liquidity DEX.

Step 2: Submit a Trojan Proposal

The proposal was crafted to look legitimate — a “Protocol Recovery” initiative that would transfer administrative control of critical protocol components:

  • Comptroller — the contract that manages all lending market parameters
  • Oracle — the price feed that determines collateral values and liquidations
  • All 7 lending market contracts — the core business logic

The transfer target? An attacker-controlled smart contract.

Step 3: Automated Drain Logic

Here’s what made this attack sophisticated beyond the social engineering: the attacker’s receiving contract contained pre-written code to automatically drain protocol liquidity the moment administrative control was transferred.

No manual intervention needed. If the proposal passed and executed, the drain would have been instant and atomic.

Step 4: Vote and Wait

With 40.17 million MFAM tokens, the attacker immediately voted “yes” on their own proposal, pushing past the 40 million quorum threshold. The final tally showed 41.57 million “yes” votes against zero “no” votes initially.

The vote was set to conclude on March 27, 2026 — giving the community roughly 48 hours to notice and respond.

Why This Attack Almost Worked

1. Token Value Decay Creates Governance Vulnerabilities

MFAM’s price had collapsed since Moonwell’s peak. But the governance parameters — specifically the quorum threshold — hadn’t been updated to reflect the new economic reality. When a governance token loses 99% of its value but the quorum stays at the same token count, the dollar cost to reach quorum drops proportionally.

The math is brutal:

  • Quorum: 40M MFAM
  • MFAM price at time of attack: ~$0.000045
  • Cost to reach quorum: ~$1,800
  • Value of drainable funds: >$1,000,000
  • ROI if successful: 55,000%+

2. Low Voter Participation

Like most DeFi protocols, Moonwell’s governance suffered from voter apathy. The attacker could reach quorum because most token holders simply don’t vote. When a malicious proposal needs 40M votes and there are only a handful of active voters, one attacker with quorum-level tokens can dominate.

3. The “Looks Legitimate” Social Engineering

The proposal title was deliberately designed to look like a routine governance action. “Protocol Recovery” and “Managerial Transfer” are exactly the kind of boring administrative language that community members might glance at and ignore — especially since Moonwell had recently gone through a genuine recovery process after the February 2026 oracle exploit.

4. Timing Exploitation

The attacker submitted the proposal after Moonwell’s February oracle incident, when the community was fatigued from dealing with the $1.78M bad debt situation. Recovery-themed proposals were expected and normalized.

The Defense: What Saved Moonwell

Blockchain intelligence firm Blockful identified the proposal as an attack before the voting period ended. Their analysis revealed:

  1. The attacker’s wallet had no prior governance participation
  2. The receiving contract contained automated drain logic
  3. The MFAM purchase and proposal submission happened within the same transaction sequence

By March 26, the community had mobilized enough “no” votes to reach 68% against the proposal. However, Blockful warned that the attacker might control additional unidentified MFAM wallets — meaning the fight wasn’t necessarily over.

The Deeper Problem: DeFi Governance Is Fundamentally Broken

The Moonwell attack isn’t an isolated incident. It’s a symptom of structural flaws in how DeFi protocols implement on-chain governance.

Pattern 1: Static Quorum Thresholds

Most protocols set their quorum as a fixed token count at launch, when token values are high and the cost to reach quorum is substantial. Over time, as tokens depreciate or liquidity thins, the dollar cost to attack governance drops — but the parameters never adjust.

Fix: Dynamic quorum based on dollar value, not token count. Or use time-weighted average prices (TWAPs) to set minimum economic value for quorum.

Pattern 2: No Timelock on Critical Actions

Many governance systems allow proposals to execute immediately after the voting period ends. This gives defenders a narrow window to detect and respond to malicious proposals.

Fix: Mandatory timelocks of 48-72 hours between vote conclusion and execution for any proposal that modifies admin roles, oracle addresses, or protocol parameters.

Pattern 3: No Proposal Screening

Anyone with enough tokens can submit any proposal. There’s no automated screening for obviously malicious actions like transferring all admin roles to an unknown contract.

Fix: Implement proposal validation contracts that flag or block proposals containing known-dangerous operations:

  • Admin role transfers to non-multisig addresses
  • Oracle address changes
  • Comptroller upgrades to unverified code
  • Multiple critical parameter changes in a single proposal

Pattern 4: Flash-Loan Governance Attacks

While the Moonwell attacker bought tokens outright, flash loans make governance attacks even cheaper — borrow millions of governance tokens, vote, and return them in the same transaction.

Fix: Snapshot-based voting where voting power is determined by token holdings at a block before the proposal was created, not at the time of voting.

Pattern 5: Governance Token Concentration Risk

When governance tokens are cheap and concentrated, a single attacker can buy meaningful influence. The Moonwell case shows that $1,808 was enough.

Fix: Quadratic voting, conviction voting, or vote-escrow mechanisms (like Curve’s veCRV) that require long-term commitment to gain voting power.

A Governance Security Checklist for DeFi Protocols

Based on the Moonwell attack and similar incidents, here’s what every protocol with on-chain governance should implement:

Immediate Actions (This Week)

  1. Audit your quorum cost — Calculate the current dollar cost to reach quorum. If it’s under $100K, you’re vulnerable.
  2. Review pending proposals — Look for any proposals that transfer admin roles or modify critical parameters.
  3. Set up governance monitoring — Tools like Blockful, OpenZeppelin Defender, or custom Forta bots can flag suspicious proposals.

Short-Term (This Month)

  1. Implement timelocks — No critical governance action should execute without at least a 48-hour delay.
  2. Add proposal validation — Automated checks that flag proposals containing dangerous operations.
  3. Snapshot-based voting — Prevent flash-loan governance attacks by using historical snapshots for vote weight.

Long-Term (This Quarter)

  1. Dynamic quorum — Adjust quorum thresholds based on token value, not just token count.
  2. Guardian multisigs — A security council that can veto malicious proposals during the timelock period.
  3. Vote-escrow or conviction voting — Require long-term commitment for governance power.
  4. Governance insurance — Set aside a portion of treasury to cover losses from governance attacks.

The $1,808 Lesson

The Moonwell governance attack is a masterclass in asymmetric warfare. For less than the cost of a plane ticket, an attacker nearly gained control of a lending protocol managing over $1 million in user funds.

The technical sophistication wasn’t in the exploit itself — it was in the attacker’s understanding of governance game theory:

  • Identify protocols with cheap governance tokens
  • Calculate quorum cost vs. drainable value
  • Craft proposals that look legitimate
  • Time the attack when community attention is low
  • Pre-deploy automated drain contracts for instant execution

This playbook is now public. Every protocol with on-chain governance and a depreciated token is a potential target.

The fix isn’t just technical — it’s philosophical. DeFi governance needs to move from “anyone can propose anything” to “governance actions are constrained by design.” The same way smart contracts use access control to prevent unauthorized state changes, governance systems need structural constraints that make malicious proposals impossible — not just detectable.

Because next time, the attacker might not give the community 48 hours to respond.

Key Takeaways

Risk Factor Moonwell Specifics Industry Pattern
Cost to attack $1,808 Many protocols under $10K
Quorum mechanism Fixed token count Static parameters everywhere
Detection time ~24 hours Often not detected at all
Potential loss >$1,000,000 Can be protocol-ending
Attack complexity Low (buy + propose) No technical skill needed

This analysis is based on publicly available blockchain data and security researcher reports as of March 26, 2026. The Moonwell governance vote is still ongoing at time of publication.

Tags: #defi #security #governance #blockchain #smartcontracts #web3 #solidity #ethereum

Follow for daily DeFi security research. Previous analyses: Aave CAPO Oracle Meltdown | Venus Protocol Supply Cap Bypass