Real estate syndication runs on data. The general partner underwrites deals, reports to investors, and tracks portfolio performance across dozens of properties. Every quarter, LPs want to know what their investments are worth.
Most syndicators pull this data manually. They check Zillow for property values, call brokers for comps, and spend hours assembling quarterly reports in spreadsheets. Some pay $500 or more per month for platforms like Juniper Square or SyndicationPro to handle investor reporting.
The property data piece of that workflow is simple to automate. One API call per property returns the current Zestimate, rent estimate, tax assessment, and comparable sales. Pull that data into your existing reporting system and the valuations update themselves.
Here is how to build syndication data workflows with Python and the Zillow API.
What data do syndicators need?
Syndicators pull property data at four points in the deal lifecycle: acquisition underwriting, ongoing monitoring, investor reporting, and exit analysis.
| Syndication task | Data needed | API field |
|---|---|---|
| Deal underwriting | Value, rent, comps, property details | zestimate, rentZestimate, search (RECENTLY_SOLD) |
| Portfolio monitoring | Current value, rent trends | zestimate, rentZestimate (periodic) |
| Investor reports | Value, equity, NOI, appreciation | zestimate, rentZestimate, taxAssessedValue |
| Market analysis | Area pricing, days on market | search endpoint, daysOnZillow |
| Exit planning | Current value vs. acquisition price | zestimate, search (RECENTLY_SOLD) |
| Comp analysis | Recent sales, price per sqft | Search endpoint + property details |
One call returns 300+ fields per property at $0.005. For API key setup, see the step-by-step guide.
How do I track portfolio value for a syndication fund?
A typical syndication fund holds 10 to 50 properties. Tracking their combined value over time is the foundation of investor reporting.
import requests, os, jsonfrom datetime import date
API_KEY = os.environ["ZILLAPI_KEY"]HEADERS = {"Authorization": f"Bearer {API_KEY}"}
def track_fund_portfolio(fund_name, properties, history_file="fund_history.json"): """Track portfolio values for a syndication fund.""" try: with open(history_file, "r") as f: history = json.load(f) except FileNotFoundError: history = {}
if fund_name not in history: history[fund_name] = {}
today = str(date.today()) total_value = 0 total_rent = 0 property_data = []
print(f"{'='*65}") print(f"FUND: {fund_name}") print(f"Report Date: {today}") print(f"{'='*65}")
for prop in properties: address = prop["address"] acquisition_price = prop["acquisition_price"] units = prop.get("units", 1)
data = requests.get( "https://api.zillapi.com/v1/properties/by-address", params={ "address": address, "fields": "address,zestimate,rentZestimate,taxAssessedValue,bedrooms,bathrooms,livingArea,yearBuilt", }, headers=HEADERS, ).json()["data"]
value = data.get("zestimate", 0) rent = data.get("rentZestimate", 0) appreciation = value - acquisition_price appreciation_pct = round(appreciation / acquisition_price * 100, 1) if acquisition_price else 0
total_value += value total_rent += rent * units
property_data.append({ "address": address, "units": units, "acquisition": acquisition_price, "current_value": value, "appreciation": appreciation, "appreciation_pct": appreciation_pct, "monthly_rent": rent * units, })
print(f"\n {data['address']['streetAddress']} ({units} unit{'s' if units > 1 else ''})") print(f" Acquired: ${acquisition_price:,} | Current: ${value:,} | Change: {appreciation_pct:+}%") print(f" Rent: ${rent * units:,}/mo | {data.get('bedrooms')}bd/{data.get('bathrooms')}ba | {data.get('livingArea', 0):,} sqft")
# Fund totals total_acquisition = sum(p["acquisition_price"] for p in properties) total_appreciation = total_value - total_acquisition total_pct = round(total_appreciation / total_acquisition * 100, 1) if total_acquisition else 0
print(f"\n{'='*65}") print(f"FUND SUMMARY") print(f" Total Acquisition Cost: ${total_acquisition:,}") print(f" Current Portfolio Value: ${total_value:,}") print(f" Total Appreciation: ${total_appreciation:,} ({total_pct:+}%)") print(f" Monthly Rent Roll: ${total_rent:,}") print(f" Annual Gross Revenue: ${total_rent * 12:,}") print(f"{'='*65}")
# Save snapshot history[fund_name][today] = { "total_value": total_value, "total_rent": total_rent, "properties": len(properties), } with open(history_file, "w") as f: json.dump(history, f, indent=2)
return {"total_value": total_value, "appreciation_pct": total_pct, "rent_roll": total_rent}
# Example: 5-property syndication fundfund = [ {"address": "17 Zelma Dr, Greenville, SC 29617", "acquisition_price": 265000, "units": 1}, {"address": "100 Main St, Greenville, SC 29601", "acquisition_price": 380000, "units": 1}, {"address": "45 Oak Ave, Taylors, SC 29687", "acquisition_price": 195000, "units": 1}, {"address": "200 Church St, Greenville, SC 29601", "acquisition_price": 420000, "units": 2}, {"address": "88 Stone Ave, Greenville, SC 29609", "acquisition_price": 310000, "units": 1},]result = track_fund_portfolio("Greenville Growth Fund I", fund)Five properties, five API calls, one complete portfolio snapshot. Run this quarterly and you have a value history for every investor report.
The history file stores each snapshot with a date stamp. Over four quarters, you can show investors exactly how the portfolio appreciated period by period.
How do I generate a quarterly investor report?
LPs want four things in a report: what the portfolio is worth, how much income it generated, what the estimated returns look like, and how each property performed individually.
def quarterly_investor_report(fund_name, properties, total_equity_raised, distributions_to_date): """Generate a quarterly report for syndication investors.""" today = date.today() quarter = f"Q{(today.month - 1) // 3 + 1} {today.year}"
total_value = 0 total_acquisition = 0 total_monthly_rent = 0 property_details = []
for prop in properties: address = prop["address"] acquisition = prop["acquisition_price"] units = prop.get("units", 1)
data = requests.get( "https://api.zillapi.com/v1/properties/by-address", params={ "address": address, "fields": "address,zestimate,rentZestimate,taxAssessedValue,livingArea,bedrooms,bathrooms", }, headers=HEADERS, ).json()["data"]
value = data.get("zestimate", 0) rent = data.get("rentZestimate", 0) * units
total_value += value total_acquisition += acquisition total_monthly_rent += rent
property_details.append({ "address": data["address"]["streetAddress"], "units": units, "acquisition": acquisition, "value": value, "rent": rent, "appreciation": round((value - acquisition) / acquisition * 100, 1), })
# Fund-level metrics total_appreciation = total_value - total_acquisition appreciation_pct = round(total_appreciation / total_acquisition * 100, 1) annual_noi = total_monthly_rent * 12 * 0.60 # 40% expense ratio estimate portfolio_cap_rate = round(annual_noi / total_value * 100, 1) if total_value else 0 equity_multiple = round((total_value - total_acquisition + distributions_to_date) / total_equity_raised + 1, 2)
print(f"{'='*65}") print(f"QUARTERLY INVESTOR REPORT") print(f"Fund: {fund_name}") print(f"Period: {quarter}") print(f"{'='*65}")
print(f"\nFUND PERFORMANCE") print(f" Portfolio Value: ${total_value:,}") print(f" Total Appreciation: ${total_appreciation:,} ({appreciation_pct:+}%)") print(f" Monthly Rent Roll: ${total_monthly_rent:,}") print(f" Estimated Annual NOI: ${annual_noi:,.0f}") print(f" Portfolio Cap Rate: {portfolio_cap_rate}%") print(f" Equity Multiple: {equity_multiple}x") print(f" Distributions to Date: ${distributions_to_date:,}")
print(f"\nPROPERTY DETAIL") for p in property_details: print(f"\n {p['address']} ({p['units']} unit{'s' if p['units'] > 1 else ''})") print(f" Acquired: ${p['acquisition']:,} | Current: ${p['value']:,} | {p['appreciation']:+}%") print(f" Monthly Rent: ${p['rent']:,}")
# Sorted by appreciation top = sorted(property_details, key=lambda x: x["appreciation"], reverse=True) print(f"\nTOP PERFORMER: {top[0]['address']} ({top[0]['appreciation']:+}%)") if top[-1]["appreciation"] < 0: print(f"WATCH LIST: {top[-1]['address']} ({top[-1]['appreciation']}%)")
print(f"\n{'='*65}")
return { "quarter": quarter, "portfolio_value": total_value, "appreciation_pct": appreciation_pct, "cap_rate": portfolio_cap_rate, "equity_multiple": equity_multiple, }
report = quarterly_investor_report( fund_name="Greenville Growth Fund I", properties=fund, total_equity_raised=500000, distributions_to_date=75000,)The report calculates portfolio value, appreciation, NOI, cap rate, and equity multiple from live API data. It identifies the top performer and flags any property that lost value.
The NOI uses a 40% expense ratio as a quick estimate. For actual expense breakdowns, see the NOI calculation guide.
How do I underwrite a new acquisition?
Before adding a property to the fund, the GP runs an underwriting model. The API provides the inputs: rent estimate, comps, tax data, and property details.
def underwrite_deal(address, asking_price, target_cap=7.0, hold_years=5, rent_growth=0.03, exit_cap=7.5): """Underwrite a syndication acquisition with sensitivity analysis.""" data = requests.get( "https://api.zillapi.com/v1/properties/by-address", params={ "address": address, "fields": "address,zestimate,rentZestimate,taxAssessedValue,bedrooms,bathrooms,livingArea,yearBuilt,homeType", }, headers=HEADERS, ).json()["data"]
rent = data.get("rentZestimate", 0) zestimate = data.get("zestimate", 0) tax = data.get("taxAssessedValue", 0) sqft = data.get("livingArea", 0)
# Year 1 NOI gross_rent = rent * 12 vacancy = gross_rent * 0.05 management = gross_rent * 0.08 maintenance = gross_rent * 0.10 insurance = 1500 taxes = tax * 0.012 if tax else asking_price * 0.01 total_expenses = vacancy + management + maintenance + insurance + taxes noi_year1 = gross_rent - total_expenses
# Going-in cap rate going_in_cap = round(noi_year1 / asking_price * 100, 1) if asking_price else 0
# Projected exit value (NOI at year N / exit cap rate) noi_exit = noi_year1 * (1 + rent_growth) ** hold_years exit_value = round(noi_exit / (exit_cap / 100))
# Total return total_rent_collected = sum(noi_year1 * (1 + rent_growth) ** y for y in range(hold_years)) total_return = exit_value - asking_price + total_rent_collected equity_multiple = round((total_return + asking_price) / asking_price, 2) annualized = round((equity_multiple ** (1 / hold_years) - 1) * 100, 1)
print(f"SYNDICATION UNDERWRITING") print(f"{'='*55}") print(f" {data['address']['streetAddress']}") print(f" {data.get('bedrooms')}bd/{data.get('bathrooms')}ba | {sqft:,} sqft | Built {data.get('yearBuilt', 'N/A')}") print(f"\n Asking Price: ${asking_price:,}") print(f" Zestimate: ${zestimate:,}") print(f" Rent Estimate: ${rent:,}/mo (${gross_rent:,}/yr)") print(f" Year 1 NOI: ${noi_year1:,.0f}") print(f" Going-In Cap: {going_in_cap}%")
print(f"\n PROJECTIONS ({hold_years}-Year Hold)") print(f" Rent Growth: {rent_growth * 100}%/yr") print(f" Exit Cap Rate: {exit_cap}%") print(f" Exit NOI: ${noi_exit:,.0f}") print(f" Exit Value: ${exit_value:,}") print(f" Total NOI Collected: ${total_rent_collected:,.0f}") print(f" Equity Multiple: {equity_multiple}x") print(f" Annualized Return: {annualized}%")
# Verdict if going_in_cap >= target_cap: print(f"\n Verdict: MEETS TARGET. Going-in cap {going_in_cap}% >= {target_cap}% target.") else: max_price = round(noi_year1 / (target_cap / 100)) print(f"\n Verdict: BELOW TARGET. Need ${max_price:,} or less for {target_cap}% cap rate.")
# Sensitivity table print(f"\n SENSITIVITY (Exit Value at Different Cap Rates + Rent Growth)") for rc in [0.02, 0.03, 0.04]: for ec in [6.5, 7.0, 7.5, 8.0]: projected_noi = noi_year1 * (1 + rc) ** hold_years ev = round(projected_noi / (ec / 100)) print(f" Rent +{rc*100:.0f}% / Exit {ec}% cap: ${ev:,}")
return { "going_in_cap": going_in_cap, "exit_value": exit_value, "equity_multiple": equity_multiple, "annualized_return": annualized, }
deal = underwrite_deal("17 Zelma Dr, Greenville, SC 29617", asking_price=275000)One API call feeds the entire underwriting model. You get the going-in cap rate, projected exit value, equity multiple, annualized return, and a sensitivity table showing how the deal performs under different rent growth and exit cap assumptions.
Syndicators typically target a 7% or higher going-in cap rate and a 2x equity multiple over a 5-year hold. If the numbers hit those targets, the deal moves to the next stage of due diligence.
How do I compare markets for fund allocation?
Syndicators operating in multiple markets need to know where values are rising and where they are flat. The API lets you compare market conditions across cities:
def compare_markets(markets): """Compare real estate market conditions across multiple areas.""" results = []
for market in markets: name = market["name"] bbox = market["bbox"]
# Pull recent sales sales = requests.post( "https://api.zillapi.com/v1/search", json={ "bbox": bbox, "listingStatus": "RECENTLY_SOLD", "homeType": ["SINGLE_FAMILY"], }, headers=HEADERS, ).json()["data"]
# Pull active listings active = requests.post( "https://api.zillapi.com/v1/search", json={ "bbox": bbox, "listingStatus": "FOR_SALE", "homeType": ["SINGLE_FAMILY"], }, headers=HEADERS, ).json()["data"]
# Calculate metrics sale_prices = [s.get("price", 0) for s in sales if s.get("price")] active_prices = [a.get("price", 0) for a in active if a.get("price")] days_list = [a.get("daysOnZillow", 0) for a in active if a.get("daysOnZillow")]
median_sale = sorted(sale_prices)[len(sale_prices) // 2] if sale_prices else 0 median_ask = sorted(active_prices)[len(active_prices) // 2] if active_prices else 0 avg_days = round(sum(days_list) / len(days_list)) if days_list else 0 inventory = len(active)
results.append({ "market": name, "recent_sales": len(sales), "median_sale": median_sale, "active_listings": inventory, "median_ask": median_ask, "avg_days": avg_days, })
# Print comparison print(f"{'='*70}") print(f"MARKET COMPARISON") print(f"{'='*70}") for r in results: print(f"\n {r['market']}") print(f" Recent Sales: {r['recent_sales']} | Median: ${r['median_sale']:,}") print(f" Active Listings: {r['active_listings']} | Median Ask: ${r['median_ask']:,}") print(f" Avg Days on Market: {r['avg_days']}")
# Rank by activity ranked = sorted(results, key=lambda r: r["avg_days"]) print(f"\n Hottest Market: {ranked[0]['market']} ({ranked[0]['avg_days']} avg days)") print(f" Slowest Market: {ranked[-1]['market']} ({ranked[-1]['avg_days']} avg days)")
return results
markets = [ {"name": "Greenville, SC", "bbox": {"north": 34.90, "south": 34.80, "east": -82.35, "west": -82.45}}, {"name": "Columbia, SC", "bbox": {"north": 34.05, "south": 33.95, "east": -80.95, "west": -81.05}}, {"name": "Charleston, SC", "bbox": {"north": 32.82, "south": 32.72, "east": -79.90, "west": -80.00}},]results = compare_markets(markets)Two API calls per market (one for recent sales, one for active listings) give you median prices, inventory levels, and days on market. Compare three markets in six calls. That costs $0.03.
Markets with low days on market and rising median prices are where you want to allocate capital. Markets with growing inventory and stale listings signal a cooldown.
How does this compare to syndication platforms?
Dedicated syndication platforms handle investor relations, distributions, K-1 generation, and compliance. The API does not replace those functions. It replaces the property data piece.
| Feature | Syndication Platform (Juniper Square, SyndicationPro) | Zillapi |
|---|---|---|
| Monthly cost | $500 to $2,000+ | $5 |
| Investor portal | Yes | No (build your own or use a separate tool) |
| K-1 generation | Yes | No |
| Distribution tracking | Yes | No |
| Property valuations | Manual entry or appraisal | Automated via API |
| Comparable sales | No | Yes (search endpoint) |
| Rent estimates | No | Yes (rentZestimate) |
| Market comparison | Limited | Yes (search across markets) |
| Setup time | Days to weeks | 60 seconds |
Many syndicators use both. The platform handles investor management and compliance. The API handles the property data that feeds into quarterly reports and underwriting models.
A syndicator with 30 properties pulling quarterly valuations uses 120 API credits per year. That costs $0.60 total. Compare that to manually checking Zillow for each property four times a year.
What does a syndication data workflow look like?
At acquisition, the GP runs the underwriting function for each candidate property. One API call per property returns the rent estimate, Zestimate, tax value, and comps. The underwriting model calculates going-in cap rate, projected returns, and sensitivity analysis.
During the hold period, the portfolio tracker runs monthly or quarterly. It pulls current Zestimates for every property in the fund, calculates appreciation, and flags any value drops. This data feeds into the quarterly investor report.
At exit, the GP pulls fresh comps and current values to set the listing price. The same data that supported the acquisition now supports the disposition.
For scheduling these scripts to run automatically, see the automation guide. For building a visual dashboard with this data, see the Streamlit tutorial.
How do I get started?
Go to zillapi.com and sign up. You get 100 free credits with no credit card required.
Start with the portfolio tracker. Enter the addresses and acquisition prices for your current fund. Run the script and see how your portfolio has appreciated since purchase. That one report is worth more than the cost of a full year of API access.
For the full Python setup, see the Python tutorial. For property field documentation, see the property data guide. For NOI calculations, see the NOI guide. For ARV on value-add deals, see the ARV guide.
Frequently asked questions
What is real estate syndication?
Real estate syndication is a structure where a general partner (GP) pools capital from limited partners (LPs) to acquire and manage investment properties. The GP handles operations. The LPs provide capital and receive passive returns. Typical syndications target 15% or higher IRR for equity investors and 8% to 12% annual returns for debt investors. Most syndications hold 5 to 50 properties across one or more markets.
Can syndicators use the Zillow API for portfolio tracking?
Yes. Third-party REST APIs like Zillapi return current Zestimates, rent estimates, tax assessments, and property details for any U.S. property. Syndicators use this data to track portfolio values over time, generate quarterly investor reports, underwrite new acquisitions, and monitor market conditions. One API call costs 1 credit ($0.005). The free tier gives 100 credits at signup.
How do I generate investor reports with property data?
Pull the current Zestimate and rent estimate for every property in the fund. Calculate total portfolio value, equity, net operating income, and estimated returns. Compare current values to acquisition prices to show appreciation. Format the output as a quarterly report. The API gives you fresh valuations without ordering appraisals. Each property lookup costs 1 credit.
How do I underwrite a syndication deal with the API?
Pull the property details, rent estimate, tax assessment, and comparable sales. Calculate NOI using the rent estimate and standard expense ratios. Apply a cap rate to estimate value. Compare the asking price to your underwritten value. Run sensitivity analysis by adjusting vacancy, rent growth, and exit cap rate assumptions. Two API calls per property give you the data for a complete underwriting model.
How much does syndication portfolio data cost?
Enterprise platforms like Juniper Square and SyndicationPro charge $500 to $2,000 per month for portfolio management and investor reporting. Zillapi costs $5 per month for 1,000 property lookups. A syndicator managing 30 properties needs about 120 credits per quarterly report cycle. That is $0.60 per quarter. The free tier gives 100 credits at signup.
Can I track portfolio value across multiple markets?
Yes. The API covers every U.S. property regardless of market. Store addresses for all properties in your fund, pull Zestimates monthly or quarterly, and compare values across markets. You can track which markets are appreciating and which are flat or declining. A 50-property portfolio costs $0.25 per monitoring cycle, covering properties in any state or city.