Property managers running 20-200 unit portfolios leave significant revenue on the table at renewal time. The average operator underprices rents by $100-150/month per unit compared to market - that is $1,200-$1,800 per unit per year in foregone revenue. The problem is not intentional; it is informational. Checking market rent for every unit at every renewal cycle is genuinely tedious when done manually through Zillow or Apartments.com. This post shows you how to automate it for both AppFolio and Buildium users.

For broader context on PM software integrations, see our posts on integrating a rent estimate API with property management software and market rent APIs for property management software. This post goes deep on the two most popular platforms specifically.

The Business Case: Why Renewal Time Is the Highest-ROI Moment

Rent benchmarking is useful at acquisition, at vacancy, and at renewal. But renewal is where the math is most favorable. At acquisition, you are making a buy/no-buy decision - the data is critical but the decision is binary. At vacancy, you are already losing $50-100/day in vacancy cost, so optimizing on $50/month of additional rent is a secondary concern. At renewal, the calculus is different: the tenant is in place, vacancy risk is low if you price reasonably, and every dollar of rent you capture goes straight to NOI with essentially zero friction.

The renewal window - 60 to 90 days before lease end - is the ideal time to fire a benchmarking call. Early enough to give notice per the lease terms, late enough that the market data reflects current conditions rather than conditions from 6 months ago when you last checked.

AppFolio Integration: Two Paths

AppFolio's API situation is complicated. The company has historically been resistant to public API access - the "AppFolio Open API" exists but is restricted to enterprise subscribers and approved integration partners. For most independent operators, the practical integration path is the CSV export/import workflow.

Path 1: CSV-Based Integration (Any AppFolio Tier)

This works for any AppFolio subscriber. The workflow:

  1. Export the current lease roll from AppFolio (Reports > Rent Roll > export to CSV)
  2. Run a Python script that reads the CSV, pulls comps for units expiring in 60-90 days, and generates a renewal recommendations CSV
  3. Review the recommendations, adjust as needed, and use the data to inform your renewal offer letters

The AppFolio rent roll CSV export includes: property address, unit number, tenant name, lease start date, lease end date, current rent, and unit specs (beds/baths/sqft). That is exactly what you need.

import csv
import requests
import json
from datetime import datetime, timedelta
from pathlib import Path

RENTCOMP_API_KEY = "your_api_key_here"
RENTCOMP_BASE_URL = "https://api.rentcompapi.com/v1"

def get_comp_estimate(address, bedrooms, bathrooms, sqft):
    """Call RentComp API for a single unit."""
    resp = requests.post(
        f"{RENTCOMP_BASE_URL}/comps",
        headers={"Authorization": f"Bearer {RENTCOMP_API_KEY}"},
        json={
            "address": address,
            "bedrooms": int(bedrooms),
            "bathrooms": float(bathrooms),
            "sqft": int(sqft),
            "radius_miles": 1.0,
            "max_age_days": 90,
        },
        timeout=15
    )
    if not resp.ok:
        return None
    return resp.json()


def process_appfolio_lease_roll(input_csv: str, output_csv: str,
                                 renewal_window_days: int = 90):
    """
    Read AppFolio lease roll export, pull comps for units
    expiring within renewal_window_days, write recommendations.
    """
    today = datetime.today()
    cutoff = today + timedelta(days=renewal_window_days)
    rows_out = []

    with open(input_csv, newline="", encoding="utf-8") as f:
        reader = csv.DictReader(f)
        for row in reader:
            lease_end_str = row.get("Lease End Date", "").strip()
            if not lease_end_str:
                continue

            # AppFolio exports dates as M/D/YYYY
            try:
                lease_end = datetime.strptime(lease_end_str, "%m/%d/%Y")
            except ValueError:
                continue

            # Only process leases ending within the window
            if not (today <= lease_end <= cutoff):
                continue

            address = row.get("Property Address", "").strip()
            unit = row.get("Unit", "").strip()
            full_address = f"{address} Unit {unit}" if unit else address
            current_rent = float(row.get("Market Rent", "0").replace(",", ""))
            beds = row.get("Beds", "1").strip()
            baths = row.get("Baths", "1").strip()
            sqft = row.get("Sq Ft", "800").replace(",", "").strip()

            print(f"  Fetching comps for {full_address}...")
            result = get_comp_estimate(full_address, beds, baths, sqft)

            if result is None:
                recommended = current_rent
                delta = 0
                confidence = 0
                comp_count = 0
            else:
                stats = result.get("market_stats", {})
                recommended = stats.get("median", current_rent)
                delta = recommended - current_rent
                confidence = result.get("confidence_score", 0)
                comp_count = result.get("comp_count", 0)

            rows_out.append({
                "Address": full_address,
                "Lease End Date": lease_end_str,
                "Current Rent": current_rent,
                "Market Median": recommended,
                "Dollar Gap": round(delta, 2),
                "Pct Gap": round((delta / current_rent) * 100, 1) if current_rent else 0,
                "Confidence Score": confidence,
                "Comp Count": comp_count,
                "Action": (
                    "INCREASE >10%" if delta / current_rent > 0.10
                    else "RENEWAL TARGET" if delta / current_rent > 0.05
                    else "HOLD"
                ) if current_rent else "REVIEW",
            })

    if not rows_out:
        print("No leases found in renewal window.")
        return

    fieldnames = rows_out[0].keys()
    with open(output_csv, "w", newline="", encoding="utf-8") as f:
        writer = csv.DictWriter(f, fieldnames=fieldnames)
        writer.writeheader()
        writer.writerows(rows_out)

    print(f"\nWrote {len(rows_out)} recommendations to {output_csv}")


# Run it
process_appfolio_lease_roll(
    input_csv="appfolio_rent_roll_export.csv",
    output_csv="renewal_recommendations.csv",
    renewal_window_days=90
)

AppFolio column names vary by account configuration. Always open the export CSV first and verify the exact column headers match what the script expects. Common variations: "Market Rent" vs "Scheduled Rent", "Sq Ft" vs "Square Feet".

Buildium Integration: Full REST API

Buildium is the better-documented integration target. Their REST API at api.buildium.com is fully public and well-maintained. You authenticate with a Client ID and Client Secret obtained from the Buildium portal under Settings > API. The endpoints you need:

import requests
from datetime import datetime, timedelta
from base64 import b64encode

BUILDIUM_CLIENT_ID = "your_buildium_client_id"
BUILDIUM_CLIENT_SECRET = "your_buildium_client_secret"
RENTCOMP_API_KEY = "your_rentcomp_api_key"

BUILDIUM_BASE = "https://api.buildium.com"
RENTCOMP_BASE = "https://api.rentcompapi.com/v1"


def buildium_headers():
    """Buildium uses HTTP Basic Auth."""
    creds = b64encode(
        f"{BUILDIUM_CLIENT_ID}:{BUILDIUM_CLIENT_SECRET}".encode()
    ).decode()
    return {
        "Authorization": f"Basic {creds}",
        "Content-Type": "application/json",
        "Accept": "application/json",
    }


def get_expiring_leases(days_ahead: int = 90):
    """Fetch active leases expiring within N days."""
    today = datetime.today()
    cutoff = today + timedelta(days=days_ahead)

    leases = []
    page = 1
    while True:
        resp = requests.get(
            f"{BUILDIUM_BASE}/v1/leases",
            headers=buildium_headers(),
            params={
                "status": "Active",
                "pagesize": 100,
                "pageNumber": page,
            }
        )
        resp.raise_for_status()
        data = resp.json()
        if not data:
            break
        for lease in data:
            end_str = lease.get("LeaseToDate", "")
            if end_str:
                end_date = datetime.fromisoformat(end_str.split("T")[0])
                if today <= end_date <= cutoff:
                    leases.append(lease)
        if len(data) < 100:
            break
        page += 1

    return leases


def get_unit_details(unit_id: int):
    """Get address and specs for a Buildium unit."""
    resp = requests.get(
        f"{BUILDIUM_BASE}/v1/rentals/units/{unit_id}",
        headers=buildium_headers()
    )
    resp.raise_for_status()
    return resp.json()


def get_comp_estimate(address, bedrooms, bathrooms, sqft):
    resp = requests.post(
        f"{RENTCOMP_BASE}/comps",
        headers={"Authorization": f"Bearer {RENTCOMP_API_KEY}"},
        json={
            "address": address,
            "bedrooms": bedrooms,
            "bathrooms": bathrooms,
            "sqft": sqft,
            "radius_miles": 1.0,
            "max_age_days": 90,
        },
        timeout=15
    )
    if not resp.ok:
        return None
    return resp.json()


def create_renewal_offer(lease_id: int, recommended_rent: float,
                          effective_date: str):
    """Post a renewal offer to Buildium."""
    resp = requests.post(
        f"{BUILDIUM_BASE}/v1/leases/{lease_id}/renewaloffer",
        headers=buildium_headers(),
        json={
            "Rent": recommended_rent,
            "EffectiveDate": effective_date,
        }
    )
    resp.raise_for_status()
    return resp.json()


def run_buildium_benchmarking(dry_run: bool = True):
    """
    Main workflow: fetch expiring leases, benchmark each unit,
    create renewal offers in Buildium.
    Set dry_run=False to actually write offers back.
    """
    print("Fetching leases expiring in next 90 days...")
    leases = get_expiring_leases(days_ahead=90)
    print(f"Found {len(leases)} leases in renewal window.")

    results = []
    for lease in leases:
        unit_id = lease.get("Unit", {}).get("Id")
        lease_id = lease.get("Id")
        current_rent = lease.get("Rent", 0)
        end_date = lease.get("LeaseToDate", "").split("T")[0]

        unit = get_unit_details(unit_id)
        address_obj = unit.get("Address", {})
        full_address = (
            f"{address_obj.get('AddressLine1', '')} "
            f"{address_obj.get('City', '')}, "
            f"{address_obj.get('State', '')} "
            f"{address_obj.get('PostalCode', '')}"
        ).strip()
        bedrooms = unit.get("Bedrooms", 1)
        bathrooms = unit.get("Bathrooms", 1)
        sqft = unit.get("TotalArea", 800)

        print(f"  Benchmarking: {full_address}")
        comp_data = get_comp_estimate(full_address, bedrooms, bathrooms, sqft)

        if comp_data is None:
            print(f"    -> No comp data, skipping.")
            continue

        confidence = comp_data.get("confidence_score", 0)
        stats = comp_data.get("market_stats", {})
        market_median = stats.get("median", current_rent)
        delta_pct = ((market_median - current_rent) / current_rent * 100
                     if current_rent else 0)

        # Skip low-confidence results - flag for human review
        if confidence < 60:
            print(f"    -> Confidence {confidence}% too low, flagging for review.")
            results.append({
                "lease_id": lease_id,
                "address": full_address,
                "current_rent": current_rent,
                "recommended_rent": None,
                "confidence": confidence,
                "status": "NEEDS_REVIEW",
            })
            continue

        recommended = round(market_median, -1)  # round to nearest $10

        if not dry_run and delta_pct > 2.0:
            renewal_effective = (
                datetime.fromisoformat(end_date) + timedelta(days=1)
            ).strftime("%Y-%m-%d")
            create_renewal_offer(lease_id, recommended, renewal_effective)
            status = "OFFER_CREATED"
        else:
            status = "DRY_RUN" if dry_run else "AT_MARKET"

        results.append({
            "lease_id": lease_id,
            "address": full_address,
            "current_rent": current_rent,
            "recommended_rent": recommended,
            "delta_pct": round(delta_pct, 1),
            "confidence": confidence,
            "status": status,
        })

    # Print summary
    print(f"\n{'Address':<45} {'Curr':>8} {'Rec':>8} {'Delta':>7} {'Conf':>6} Status")
    print("-" * 90)
    for r in results:
        print(
            f"{r['address'][:44]:<45} "
            f"${r['current_rent']:>7.0f} "
            f"${r.get('recommended_rent', 0) or 0:>7.0f} "
            f"{r.get('delta_pct', 0):>6.1f}% "
            f"{r['confidence']:>5}% "
            f"{r['status']}"
        )

    return results


if __name__ == "__main__":
    # Set dry_run=False to actually write renewal offers
    run_buildium_benchmarking(dry_run=True)

Scheduling and Operational Considerations

Both integrations should run on a schedule, not on-demand. The right cadence is weekly - specifically, every Monday morning before the workday starts. This gives your leasing team a fresh queue of renewal recommendations to act on each week. Running it daily is wasteful because lease end dates do not change daily. Running it monthly is too infrequent - you can miss the 60-day notice window on short leases.

Set it up as a cron job on a small VM or as a GitHub Actions scheduled workflow:

# crontab entry - runs every Monday at 6:00 AM
0 6 * * 1 /usr/bin/python3 /home/pm/scripts/buildium_benchmarking.py >> /var/log/rent_benchmark.log 2>&1

Handling Low-Confidence Results

When the RentComp API returns a confidence score below 60%, do not automate the renewal offer. The data is not strong enough to act on programmatically. The right workflow for low-confidence units:

Low-confidence results are most common in rural areas (thin listing density), niche unit types (5BR+ houses, micro-studios under 300 sqft), and markets with very low turnover (some HOA-restricted communities). For these, manual Craigslist/Zillow research is still the right answer - the API is not magic, it reflects the data that exists.

What Not to Do

A few failure modes worth calling out explicitly from experience building these integrations:

Do not fire API calls synchronously per unit on page load. If you are building a custom portal, always batch the calls in a background job. Even at 200ms per call, 50 units is 10 seconds of blocking time.

Do not treat the market median as the recommended rent. The market median is a starting point. Your actual recommendation should account for unit condition (renovated vs original finishes), floor level, parking availability, and tenant quality. The API gives you the market anchor - you apply the judgment.

Do not run benchmarking on vacant units and active leases the same way. Vacant units need a different urgency model. For a unit that has been vacant for 14+ days, the question is not "what is market" - it is "what rent clears fastest." Those are different questions that sometimes have different answers in a soft market.

Ready to Pull Rental Comps via API?

Join the waitlist and get 80% off founding member pricing - for life.

Join the Waitlist