How to build a loan comparison marketplace.
Three data problems in a trench coat: what products exist, which ones this consumer can actually obtain, and which is genuinely best for them. Most teams solve the first, skip the second, and get the third wrong in a way that is invisible until trust is gone.
The architecture, in three calls
// The three calls a marketplace is actually made of. // 1. WHAT EXISTS — current, comparable products.const rates = await rateapi("/v1/rates", { product_type: "auto_loan", state: user.state, term_months: 60, sort: "apr_asc", limit: 100,}); // 2. WHAT THEY CAN GET — the step that separates a marketplace from a table.const eligibility = await rateapi("/v1/eligibility/search", { home_zip: user.zip, employer: user.employer, willing_to_join_association: true,}); const reachable = new Set( [...eligibility.eligible, ...eligibility.conditionally_eligible] .map((cu) => cu.credit_union_id)); // 3. WHAT IS BEST — ranked, with reasoning you can show.const ranked = await rateapi("/v1/decisions", { scenario: "auto_purchase", loan_amount: 35_000, term_months: 60, state: user.state,}); const offers = ranked.actions[0].offers.filter((o) => reachable.has(o.credit_union_id));Where it goes wrong
Stale data that looks fine
A scraper that silently returns last quarter’s number does not throw. Every row needs its own
as_of, and your UI needs to show it. A rate without a date is a claim you cannot stand behind.Ranking products the user cannot get
The cheapest auto loan in the country is irrelevant if it is at an institution the user cannot join. Filter by eligibility before ranking, or your best-looking result is your worst experience.
Ranking by the number that markets best
APR is a comparison tool with real limits — it does not treat fees uniformly across product types. Rank by cost over the user’s actual scenario, and keep the reasoning so you can show it.
Selling placement
The moment ordering can be bought, the ranking is advertising and every claim you make about it becomes unprovable. It is also the hardest decision to reverse once revenue depends on it.
Freshness: suppress, do not delete
// Suppress at serve time; never delete. Two different jobs.//// Deleting stale rows destroys the history you need to answer "what did we// show this user in March?" — and retention deletes have a habit of failing// silently against foreign keys. Keep everything, filter on read.const servable = rows.filter((row) => ageInDays(row.as_of) <= FRESHNESS_WINDOW_DAYS); // And always render the date next to the number. A rate without an as_of is// a claim you cannot stand behind.- One window, one definition
- Define the freshness window once and import it. Inline age clauses scattered across the codebase drift, and then two pages disagree about what "current" means.
- Filter on read
- Keep every observation; decide servability at query time. You keep the history and can change the policy without losing data.
- Show the date
- Next to the number, not in a footnote. It is the cheapest credibility you will ever buy.
Build or buy the data?
- Build
- Right when coverage is narrow and stable — a handful of institutions you have a relationship with. The scraper is easy; the decade of maintenance is the actual cost.
- Buy
- Right when coverage is the product. Thousands of institutions, constantly changing layouts, PDFs, renamed products — the failure mode is silent staleness, which is expensive precisely because nothing alerts.
Frequently asked
You can, and the first fifty institutions are genuinely easy. The cost is not the scraper, it is the decade after: layouts change constantly, PDFs appear, products get renamed, and a silently stale rate is worse than no rate because your users act on it. Budget for the maintenance, not the build — that is the honest version of the build-vs-buy question.
Because a comparison table works without it and looks finished. The failure is invisible: you rank a product first that the user cannot obtain, they click through, and they find out at the institution. You never see the bounce as a data problem. It is the single biggest quality difference between a table and a marketplace.
By the cost the user actually bears over their scenario, with the reasoning visible. Not by headline APR alone — APR does not capture fees uniformly across product types, and it says nothing about access. And not by who pays you, which turns the ranking into advertising and destroys the reason anyone trusts it.
Fresh enough that a user acting on it is not embarrassed, which in practice means days rather than weeks for most consumer lending, and every row needs to carry its own timestamp. The important design decision is what you do when a row goes stale: suppress it at serve time rather than deleting it, so you keep the history and never serve a number you cannot date.