Docs
API guides

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 fieldSource
Community name, description, contact, hours, urlsGET /api/v2/properties
Address, latitude/longitudeaddress on the V2 property
Which rent to publishpricing_display_settings on the V2 property
Community photosproperty_images / images on the V2 property
Floor plan name, beds, baths, sqftGET /api/v2/floor-plans
Floor plan diagramfloor_plan_2d on the floor plan
Unit number, availability date, rent, sqftGET /api/v2/units
Unit photos, virtual toursimages / virtual_tours on the unit
AmenitiesGET /api/v2/amenities?property_id=…
Fee disclosureGET /api/v1/property/{property}/fees, the property's effective fee schedule
Lease-term pricingGET /api/v1/property/{property}/unit/{unit}/price-matrix (lazy)
Application / tour linksapplication_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/removals

Fetch

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_at in the future: a pre-lease. Include it with the date shown; do not present it as immediately available.
  • min_rent null or 0: 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_key on every lead, verified live

Last updated on

On this page