Insights

An IDX site on a RESO Web API feed: what the docs leave out

An IDX site on a RESO Web API feed: what the docs leave out

A RESO Web API feed is a well specified thing. Web API Core 2.1.0 has been ratified since December 2023, and any board can hand you a base URL, a token and a field list. What none of it tells you is how a RESO Web API IDX build behaves at three in the morning on shared hosting, with a catalogue in the tens of thousands and a photo queue that turns out to be the real project.

These are the build notes from an IDX site running on a regional MLS's feed for a Portland-area brokerage client, live since late August 2026. The feed runs under the client's own participant licence, into the client's own database. I hold no MLS licence, I do not resell listings, and there is no central listing database at my end: one feed, one database, per participant is the rule, and it shapes everything downstream. I have written before about building a real estate site from scratch; this one had a live feed attached, and the engine that came out of it is now a product I sell.

What the standard guarantees, and what it leaves to your MLS

The standard guarantees a small and genuinely useful floor. Web API Core 2.1.0, ratified in December 2023, says providers "MUST support server-driven paging using @odata.nextLink", and that servers must support the $select operator. It describes itself as defining "the primary functionality RESO Web API servers are expected to have in order to provide both replication and live query support."

Above that floor, most of MLS data feed integration is your board's choice, and the first surprise was authentication. I had planned for OAuth2 and a refresh cycle. What arrived was a static bearer token and no token endpoint at all. Nothing in Core requires an OAuth flow, so both shapes are conformant. Core names two authorization strategies, OAuth2 Bearer Token and Client Credentials, and leaves the choice to the server. Which one you get is not in any document you can read before the credentials arrive. MLS Grid, whose documentation is public, goes the other way: an OAuth2 access token, which must also be sent as the HTTP user-agent header on every media download or the request is blocked.

Authorization: Bearer <token>
Accept: application/json
OData-Version: 4.0
OData-MaxVersion: 4.0

That token lives in a 0600 file, not the settings table: settings rows get dumped into backups and rendered into exports, and a credential wants a short, shreddable home.

The field list is the other day-one decision. The server advertises 714 Property fields; the request pins 77 of them in $select, each checked against the board's own IDX field document, and no method in the client can fetch Property rows without it. A request with no $select returns everything your licence grants, which on a production payload can include showing instructions and lockbox details. That list is a fence, not an optimisation.

Why is the page size 250 when the spec never names a number?

Because page size is the server's call, not the standard's. Core is explicit that "servers MAY respond with a page size different than the one requested, and clients should be prepared to respond accordingly." On this feed the answer is 250 rows, hard, whatever $top asks for. MLS Grid documents 5,000 records per request, dropping to 1,000 when you use $expand, with a default of 500. Same standard, twenty times the page.

Which is why you follow the link rather than construct one. Spark's RESO documentation puts it plainly: "Using the @odata.nextLink is the recommended method for replicating Property, Member, and Office records, as it ensures records aren't inadvertently missed." Rebuilding the URL with $skip works right up until a row is inserted between two of your pages.

Three things about that link cost me time, and none of them appears in documentation I have seen:

  • It came back over a thousand characters long. Stored in a VARCHAR(255) resume column it truncates without complaining, and every tick quietly restarts the pull from page one. The column is TEXT.
  • Following it blind walks your bearer token to whatever host the link names. The client refuses any nextLink whose host is not the one it was configured for.
  • Enum comparisons on this server are spelled StandardStatus eq Odata.Models.StandardStatus'Active'. I found that by reading $metadata, not by reading a guide.
$host = strtolower(parse_url($link, PHP_URL_HOST));
$ours = strtolower(parse_url(self::base(), PHP_URL_HOST));
if ($host === '' || $host !== $ours) {
    throw new RuntimeException('nextLink points at ' . ($host ?: 'nowhere')
        . ', not ' . $ours . '; refusing to follow');
}
A pipeline diagram of one cron tick. On the left, a donut clock divides a thirty-minute tick into 15 percent listing sync (4.5 minutes), 45 percent photo drain (13.5 minutes) and the rest as headroom, above the shell line flock -w 1 lock timeout -s 9 1800 php cron.php. On the right, seven numbered steps: the RESO Web API Property resource with a static bearer token and 77 fields pinned in dollar-select out of 714 advertised; one page at top equals 250, the server's hard cap, ordered by ModificationTimestamp ascending; follow at-odata dot nextLink verbatim and only on the configured host; upsert then commit then check the budget, with the cursor set to the newest ModificationTimestamp seen minus five minutes; the photo queue taking covers first; 1600 pixel WebP at quality 80, scaled and never cropped; and finally cards, hubs, listing pages and the sitemap.

How do you know a listing is gone?

You do not, from the update stream alone. You have to ask for every key you should still be holding, and delete whatever does not come back.

This is the widest gap between what the documentation describes and what a compliant site needs, and RESO says so about itself. From its March 2024 post on why the EntityEvent resource exists: "One of the most important things EntityEvent provides is a way to communicate which records should no longer be in a user's feed. There is currently no good solution for this in the RESO ecosystem." The same post notes that timestamps "can be unreliable", and that clock drift can put events out of order. EntityEvent and push replication were both ratified in December 2023. I have not yet been offered either on a feed I work with.

There is a Deleted resource on this feed, carrying a resource name, a primary key and a timestamp. It covers exactly one case: records removed from the API, which the board's own overview puts at two or three listings a month. A listing that turns Canceled, Expired or Withdrawn does not appear in it, and neither does one whose seller switches internet display off. Those just stop matching your filter.

A diagram of three ways a home leaves a feed. Exit one, the record is removed from the API, is seen by the Deleted resource. Exit two, the status turns Canceled, Expired or Withdrawn, and exit three, the seller switches internet display off by flipping InternetEntireListingDisplayYn to zero, are both seen only by a full key sweep. A bar below explains the sweep: select ListingKey and nothing else, page every key the feed still returns under the display filter, stamp each matching row with the run id, then delete anything left unstamped. It runs every 11 hours inside a 12-hour removal deadline, and a pass that wants to delete more than a fifth of the catalogue refuses and raises an alarm. A quotation from RESO, March 2024, reads: there is currently no good solution for this in the RESO ecosystem.

So a pass ends with a keys-only reconciliation whenever the clock says it is due: $select=ListingKey, every key the feed still returns under the display filter, each matching row stamped with the run id, anything left unstamped removed. The interval is eleven hours because the board's API overview gives a delisted home twelve hours to come off the site. Vendor commentary puts that window at twelve to twenty-four hours across boards generally; check yours rather than assuming.

The guard on that pass matters more than the pass does. A reconciliation that wants to delete more than a fifth of the catalogue refuses, writes an alarm and waits for a person. From inside the code, a fat-fingered filter and a real mass delisting look identical.

What does a thirty-minute cron actually buy you?

Enough time for a resumable state machine to make progress, and not a second more. The host runs the job like this:

flock -w 1 /path/to/cron.lock timeout -s 9 1800 /usr/bin/php cron.php

flock stops a slow tick stacking on the next one. timeout -s 9 1800 kills the tick at thirty minutes with SIGKILL: no cleanup, no shutdown handler, no last write. So the design assumption is that this process can die at any instant. Every page commits before the budget is checked, and nothing calls set_time_limit.

The budgets are shares of the interval rather than constants, because the schedule changes: fifteen percent of the tick to the IDX feed sync, forty-five percent to the photo drain. At thirty minutes that is four and a half minutes and thirteen and a half, well clear of the kill line, and both phases pick up where they stopped.

How often is often enough? MLS Grid's public best-practices guide says "performing these requests once every 15 minutes will be sufficient to keep your database fresh and in sync". Spark recommends polling "no less than once every hour". Thirty minutes sits between them, well inside the twelve-hour removal rule that actually binds.

Two things I would tell anyone starting this on shared hosting. Long-lived workers are not the answer: six of mine were reaped within an hour, and the cron is the one thing that always comes back, so the work belongs inside it. And check whether your language runtime and your database agree about the time. PHP's CLI ran Pacific here while MySQL ran UTC, seven hours apart, so a lock heartbeat written from PHP looked stale the instant it was written. A second worker reclaimed a live lock, the two raced on one run row, and a backfill that had done its first thousand rows in minutes slowed to five hundred an hour. Every clock a rule or a lock depends on now reads NOW() from the database.

The photographs are the project

Listing rows are megabytes. The photographs are tens of gigabytes, and nearly every real decision here came out of that arithmetic.

The catalogue on 29 August 2026 held 76,524 rows: 22,397 for sale, 5,810 pending, 48,317 closed. Stored covers average about 580 KB, so one cover for every listing is roughly 44 GB before a single gallery exists. The two thousand on-market listings in the test payload, at about 28 photos each, came to 20 GB and seventeen hours of fetching. Based on information from the RMLS for the period August 29, 2024 through August 29, 2026.

So the policy is deliberately lopsided. Every listing gets a stored cover, sales included. Full sets are for homes on the market only, and only while a disk-budget setting allows. The drain serves covers first, always, because a card with no cover is a blank tile while a gallery missing its last twenty photos is not. Until a file lands, the card shows the feed's own image URL, so nothing renders empty. Getting that order wrong is a disk-quota postmortem of its own: crawlers opening listing pages pulled 42.9 GB of full sets and left no room for a single new cover.

Files are stored at 1600px on the long edge as WebP quality 80. Proportional scaling was permitted in writing on this feed. Cropping was not, and that restriction matters more than it sounds: watermarks such as "Virtually Staged" and "Sample Image" are burned into the pixels, so a cropping preset can amputate a legal notice. The delivery layer refuses every crop preset and answers with a width-only one, and a border-trimming routine that is useful everywhere else in the engine is banned on these files, because a watermark bar is exactly what it eats.

None of that transfers to your board. Hotlinking is another one. MLS Grid forbids what this feed allows: "DO NOT link directly to the Media URLs you receive. You should store and post to Media locally on your end." Read your own rules before writing the ingest.

What do closed sales cost you?

Nearly two thirds of the catalogue, and none of it is on the market: 48,317 of those rows were closed sales. They earn their place, because sold comparables are what a market page is made of, but they cannot carry a live listing's media policy.

So the feed filter has a floor, and the floor is a setting rather than a constant:

StandardStatus ne Odata.Models.StandardStatus'Closed' or CloseDate ge {floor}

Where {floor} is today minus the configured months.

Moving that floor from twenty-four months to four cut the closed set to 18,093 rows. Based on information from the RMLS for the period August 29, 2024 through August 29, 2026. Old sales keep a cover and never get a full set. If you want the whole sold history visible, that is a hosting decision to make before launch rather than a code decision to make after, and it pairs with how you intend to render them: 28,000 listings on a map has a very different disk profile from 28,000 listing pages.

The vocabulary your MLS reviews before go-live

Your board reads the words as literally as it reads the data. This is the part of IDX website development nobody budgets for, and it is what gets audited before you are allowed to launch.

Every page carrying feed data needs the same block: where the data comes from, that it is deemed reliable but not guaranteed, when it was last updated, and that it is for consumers' personal, non-commercial use. Listings held by other firms carry the MLS logo and the listing broker's name, there is a channel for reporting a data problem, and any public figure drawn from the data carries a period notice, which is why the catalogue-size paragraph above has one.

Three specific things caught me:

  • The update-frequency statement is generated from the last completed sync, so before the first full pass finishes there is no last sync and one of the three required statements is simply absent. The catalogue stayed parked until the pass completed.
  • Placement gets reviewed, not just presence. I read the brokerage attribution as acceptable in the footer. The review came back: header, above the fold. One round trip, and it was mine to get wrong.
  • The words are load-bearing. The site does not "search the MLS", and it is not one: it searches a licensed copy of listing data held in the client's own database. Nor is anything "certified" by the board. The phrasing I am allowed to use is that the site was built to the RMLS Rules and Regulations, and reviewed by RMLS before go-live.

One rule I set before the first row arrived, and would set again on any feed: listing data never reaches a language model and never enters a retrieval index. The site has an assistant, and it parses a visitor's question into a structured search, then renders the results server-side. The model sees the question and never the listings. Aggregation and third-party AI are precisely what a licence like this restricts, and the cheapest way to stay inside that line is to build so the question cannot come up.

Before this build I would have told you the hard part was the API. The hard part was an eleven-column spreadsheet due to the MLS in the first week of every month, and a number in a public health endpoint that says how long the longest-waiting home on the market has been showing a photograph I do not host yet. I check that number before I check the listing count.

Common questions

Does the RESO Web API standard set a maximum page size?

No. Web API Core 2.1.0 requires providers to support server-driven paging with @odata.nextLink, and says a server may respond with a page size different from the one requested. The cap is your MLS's: 250 rows on the feed described here, 5,000 in MLS Grid's public documentation.

How do you detect a listing that has been removed?

With a keys-only reconciliation, not the update stream. A Deleted resource only covers records removed from the API. A listing that turns Canceled, Expired or Withdrawn, or whose seller switches internet display off, simply stops matching your filter, so you page every key the feed still returns and delete whatever did not come back.

How often should an IDX feed sync?

Vendor guidance ranges from no less than once an hour (Spark) to once every 15 minutes (MLS Grid). What actually binds is your board's removal deadline. On this build it is 12 hours, and the cron runs every 30 minutes with the tick budget split between the listing sync and the photo queue.

Can you resize MLS listing photos?

Proportional scaling was permitted in writing on this feed, so photos are stored at 1600px on the long edge as WebP. Cropping, trimming and overlays are not permitted, because watermarks are burned into the pixels and a crop can remove a legal notice. Confirm with your own MLS: the rules differ by board.

Does listing data go into an AI model or a RAG index?

No. Listing rows are never sent to a language model and never enter a retrieval index. The site assistant parses the visitor's question into a structured search and receives server-rendered cards. The model sees the question, never the listings.

Related

← All insights