API + MCP reference

CSV export

Stream a flat-file projection of every parcel matching a recipe — addresses, signals, score. Drop straight into a mail-merge template or BI tool without remapping. v1 ships one-shot CSV; scheduled delivery is a v1.x line item, use webhooks if you need streaming.

Operator tier and up. CSV is gated on the csv_export feature key (Operator, Team, Custom). Starter requests with format=csv return 403 forbidden_tier with an upgrade-prompt URL. See Errors.

Overview

CSV is not a separate endpoint family — it's a format=csv query param on the existing list, single-folio, and batch endpoints. Same auth, same filter DSL, same tier gating — just a different response codec.

Three use cases:

  • Mail-merge / outbound — list mode (GET /v1/leads?format=csv) for the wholesaler send.
  • Pipeline re-hydration — batch mode (POST /v1/leads/batch?format=csv) when you have a saved folio list and want fresh detail.
  • Single-parcel export — detail mode (GET /v1/leads/{folio}?format=csv) for handoff to a non-API operator.

Endpoint shape

Three flavors of the same CSV schema. The response is streaming Content-Type: text/csv; charset=utf-8.

all three modes
# List CSV — every parcel matching a recipe.curl 'https://api.terminal.house/v1/leads?recipe=oos_long_tenure_high_signal&format=csv' \  -H "Authorization: Bearer $TERMINAL_HOUSE_API_KEY" \  -o leads.csv # Single-row CSV for one parcel.curl 'https://api.terminal.house/v1/leads/30-3122-008-0440?format=csv' \  -H "Authorization: Bearer $TERMINAL_HOUSE_API_KEY" # Bulk CSV for a known folio list.curl -X POST 'https://api.terminal.house/v1/leads/batch?format=csv' \  -H "Authorization: Bearer $TERMINAL_HOUSE_API_KEY" \  -H "Content-Type: application/json" \  -d '{"folios": ["30-3122-008-0440", "30-4011-002-0210", ...]}'

Python · streaming consumer

Large exports stream — don't buffer the full response into memory. Use httpx's streaming + the stdlib csv.DictReader:

stream + parse
import osimport csvimport httpx with httpx.stream(    "GET",    "https://api.terminal.house/v1/leads",    params={"recipe": "oos_long_tenure_high_signal", "format": "csv"},    headers={"Authorization": f"Bearer {os.environ['TERMINAL_HOUSE_API_KEY']}"},    timeout=60.0,) as resp:    resp.raise_for_status()    reader = csv.DictReader(resp.iter_lines())    for row in reader:        print(row["folio"], row["composite_score"], row["signals"])

Schema (locked v1)

11 columns, locked at v1. New columns ship additively at the end of the row — your existing mail-merge template will keep resolving the same fields. We will never insert, rename, or remove a v1 column without a v2.

ColumnTypeDescription
foliostringCounty parcel identifier. The primary key.
addressstringNormalized street address (CSV-quoted because addresses contain commas).
year_builtintegerFrom the property appraiser's building record.
sq_ftintegerHeated living area square footage.
bedsintegerBedroom count.
bathsdecimalBathroom count (half-baths render as 1.5, 2.5, etc.).
tenure_yearsintegerYears since the most recent qualified sale. The tenure_years_long signal fires above the tenure threshold (Tri-County defaults).
homestead_exemptbooleanWhether the parcel currently has a homestead exemption on record. true / false lower-case.
signalsstring;-separated list of signal types that fired on this parcel. Just the type names — for scores + evidence use the JSON endpoint with include=signals.
composite_scoredecimalComposite Phase 0 heuristic score, range 0.0 1.0. See composite scoring.
first_observedtimestampRFC3339 UTC — the moment the parcel first entered the feed. Useful for de-duping incremental pulls.

Sample row

The first row is the locked column header. Body rows render one parcel each.

header
folio,address,year_built,sq_ft,beds,baths,tenure_years,homestead_exempt,signals,composite_score,first_observed
body (two rows)
30-3122-008-0440,"4567 NW 22nd Av, Miami FL 33125",1962,1840,3,2,14,false,out_of_state_owner;tenure_years_long;no_homestead;code_violation,0.78,2026-05-12T08:14:00Z30-4011-002-0210,"1219 SW 8th St, Miami FL 33135",1955,1320,2,1,21,false,tenure_years_long;lis_pendens;tax_delinquent,0.84,2026-05-18T11:02:00Z

Quota + size

Every row in the CSV consumes one lead-view against the monthly quota, same as the JSON path. A 5,000-row export on Operator (2,000/mo cap) won't complete — at row 2,000 the stream terminates with a trailing comment row indicating the quota was reached:

quota-truncated CSV tail
...30-7331-009-0112,"2300 NW 12th Av, Miami FL 33136",1973,1610,3,2,9,false,code_violation;no_homestead,0.62,2026-05-19T14:51:00Z# quota_exceeded: lead-views exhausted at row 2000 of 4982 matches# next reset: 2026-06-12T00:00:00Z# upgrade: https://terminal.house/api-keys?action=upgrade

Plan big back-fills around the billing-anniversary reset, or upgrade tier for a one-month burst. See per-tier caps.

Calendar-day idempotency still applies

Re-running the same CSV recipe later the same UTC day does NOT re-consume lead-views for parcels you've already seen — the same (folio, calendar-day-UTC) idempotency rule from the JSON path applies. Useful for iterating on filter recipes without burning quota on each tweak.

Scheduled exports

Not in v1. Recurring CSV delivery (e.g. nightly drop to S3, daily email attachment) is a v1.x roadmap item — not shipped today. If you need a streaming feed of new matches, use webhooks — that's the v1 push surface. CSV is the v1 one-shot surface.

A common interim pattern: cron a CSV pull on your side and diff against last run. The first_observed column makes incremental diff trivial — only parcels with a first_observed after your last run are new to the feed.