Build an ILS / syndication feed
Assemble a complete, accurate listing feed for a client's portfolio and keep it current.
Who this is for: internet listing services, syndication platforms, and aggregators that publish a client's inventory on their own marketplace.
What you'll build: a scheduled job that produces a full listing record per marketable unit (property profile, address, floor plan, availability, pricing, and media), refreshed every 10–15 minutes.
What a complete listing needs, and where it comes from
| Listing field | Source |
|---|---|
| Community name, description, contact, hours, urls | GET /api/v2/properties |
| Address, latitude/longitude | address on the V2 property |
| Which rent to publish | pricing_display_settings on the V2 property |
| Community photos | property_images / images on the V2 property |
| Floor plan name, beds, baths, sqft | GET /api/v2/floor-plans |
| Floor plan diagram | floor_plan_2d on the floor plan |
| Unit number, availability date, rent, sqft | GET /api/v2/units |
| Unit photos, virtual tours | images / virtual_tours on the unit |
| Amenities | GET /api/v2/amenities?property_id=… |
| Fee disclosure | GET /api/v1/property/{property}/fees, the property's effective fee schedule |
| Lease-term pricing | GET /api/v1/property/{property}/unit/{unit}/price-matrix (lazy) |
| Application / tour links | application_link / tour_link on the unit |
V2 /fees is the account's fee library: definitions and which properties they are assigned to. The V1 fees endpoint is the resolved schedule a renter actually pays, with per-property overrides applied and expired fees removed, so disclose from that.
The pipeline
per run:
1. properties (V2, paginated)
2. floor plans (V2, paginated)
3. buildings (V2, paginated)
4. units (V2, paginated, per_page=200, sort=number)
5. amenities (V2, incremental via updated_at)
6. fee schedule (V1, 1 request per property, daily)
7. join in memory → filter to marketable → emit feed
8. diff against last run → publish creates/updates/removalsFetch
const headers = { Authorization: `Bearer ${token}`, Accept: "application/json" };
// links.next carries only ?page=N, so send your parameters with every page.
async function collect(path, params = {}) {
const rows = [];
for (let page = 1; ; page++) {
const qs = new URLSearchParams({ per_page: "200", ...params, page: String(page) });
const res = await fetch(`https://v2.getresi.com/api/v2${path}?${qs}`, { headers });
if (!res.ok) throw new Error(`${path} page ${page}: ${res.status}`); // abort the whole run
const body = await res.json();
rows.push(...body.data);
if (!body.links.next) return rows;
}
}
const [properties, floorPlans, buildings, units] = await Promise.all([
collect("/properties"),
collect("/floor-plans", { sort: "name" }),
collect("/buildings", { sort: "name" }),
collect("/units", { sort: "number" }),
]);Filter to what is actually marketable
This is where feeds go wrong. A unit belongs in the feed only if every condition holds:
const marketable = units.filter((u) =>
u.is_enabled && // active record
u.is_available && // available to lease
!u.is_hidden && // not explicitly suppressed
!u.is_model && // model apartment, not for rent
!u.is_guest_suite // guest suite, not for rent
);Publishing a model unit or a hidden unit generates a leasing-office complaint within a day.
Additional judgment calls, depending on your marketplace:
is_affordable: income-restricted units. Many marketplaces require them flagged, or excluded entirely. Ask; do not guess.available_atin the future: a pre-lease. Include it with the date shown; do not present it as immediately available.min_rentnull or0: pricing not yet published. Exclude rather than showing $0.
Assemble
const fpById = new Map(floorPlans.map((fp) => [fp.id, fp]));
const bldById = new Map(buildings.map((b) => [b.id, b]));
const propById = new Map(properties.map((p) => [p.id, p]));
const listings = marketable.map((u) => {
const property = propById.get(u.property_id);
const address = property.address;
const fp = u.floor_plan_id ? fpById.get(u.floor_plan_id) : null;
const rent = publishedRent(u, property.pricing_display_settings); // see "Pricing" below
return {
external_id: u.id,
community: {
name: property.name,
description: property.description,
street: address?.street,
city: address?.city,
state: address?.state,
zip: address?.zipcode,
latitude: address?.latitude,
longitude: address?.longitude,
phone: property.phone_numbers?.[0]?.number ?? null,
photos: [...property.property_images, ...property.images]
.map((m) => ({ url: m.url, caption: m.caption, alt: m.alt_text })),
},
unit: {
number: u.number,
building: u.building_id ? bldById.get(u.building_id)?.name : null,
floor_plan: fp?.name ?? null,
beds: u.bedrooms,
baths: u.bathrooms,
sqft: u.interior_sqft,
available_on: u.available_at,
rent_min: rent.min,
rent_max: rent.max,
deposit: u.deposit,
specials: u.specials,
apply_url: u.application_link,
tour_url: u.tour_link,
photos: u.images.map((m) => m.url),
virtual_tours: u.virtual_tours.map((v) => v.url),
floor_plan_diagram: fp?.floor_plan_2d?.[0]?.url ?? null,
},
source_updated_at: u.updated_at,
};
});Photos. Every image carries url (the stored original), thumb_url and full_url (a resized copy, 2,000 pixels wide). full_url is generated on a queue after import, so for a short time after an import it can return a 404. A feed that may publish minutes after an import should use url, or check that full_url responds before publishing it.
Address. Coordinates are stored as the client entered them; Resi does not geocode. A property with no address has address: null, and a property with an address may still have latitude/longitude of null. Decide whether your marketplace can list it before publishing.
Pricing: what number to publish
min_rent / max_rent is the total monthly leasing price (TMLP): base rent plus the monthly equivalent of the property's mandatory fees. min_base_rent / max_base_rent is rent before those fees.
V2 returns every price regardless of how the property markets itself. The property's pricing_display_settings says what its own website shows, and your listing should match:
function publishedRent(u, settings) {
if (settings.hide_all_pricing) return { min: null, max: null };
if (settings.hide_price_when_no_availability && !u.is_available) return { min: null, max: null };
if (settings.pricing_display_mode === "BASE_ONLY") return { min: u.min_base_rent, max: u.max_base_rent };
return { min: u.min_rent, max: u.max_rent }; // TOTAL_ONLY, or BASE_AND_TOTAL (publish the total)
}Publishing base rent where the client markets the total, or the reverse, produces a price mismatch between your listing and the property's own website. That is the most common syndication complaint.
If your marketplace shows term-based pricing, fetch the price matrix lazily on unit detail. When Resi has no stored matrix it queries the property's PMS live, so it is far too slow to call across a whole portfolio in a feed run. An empty array is a valid answer.
Keeping it fresh
Refresh on a schedule with a full scan and diff:
- Cadence: every 10–15 minutes for units; hourly for properties, floor plans and buildings; daily for amenities (incrementally, via
?updated_at[gte]=) and the V1 fee schedule. - Cost: a 5,000-unit portfolio is about 25 unit requests at
per_page=200, roughly 20% of one minute's budget of 120 requests. - Never remove a listing because a record vanished from one scan. Soft-delete, then purge only after several consecutive complete scans. Abort the whole diff if pagination fails partway.
Full detail in Syncing inventory.
Attribution: get credit for the leads you send
Ask the client to create a lead source for you and give you its code. Then send it on every lead to POST /api/v1/leads:
{ "form_id": "…", "email": "renter@example.com", "resi_source_key": "acme-ils" }Without it, your leads land unattributed and your traffic is invisible in the client's reporting. Verify attribution on the first live lead, not at renewal time. See Capture leads from your own site or CRM.
Optionally, also send demand events (unit_viewed, floor_plan_viewed, apply_clicked) to POST /api/v1/demand-events so the client sees the full funnel you drive, not just the conversions.
Checklist
- Query parameters re-sent on every page; run aborted on any failed page
- Address and coordinates read from the V2 property, with a plan for missing ones
- All five marketability flags applied
- Model and guest-suite units excluded
- Freshly imported photos published from
url - Rent published per the property's
pricing_display_settings - Fees disclosed from the V1 effective schedule
- Affordable-unit handling agreed with the client
- Price matrix fetched lazily, cached, never in the feed loop
- Soft-delete on disappearance; diff aborted on incomplete scans
-
resi_source_keyon every lead, verified live
Last updated on