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.

Last updated 2026-08-21Architecture + codeFree tier: 20 requests/month

The architecture, in three calls

JSThe whole marketplace, roughly
// 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));
That really is the shape. The interesting engineering is not in the calls — it is in keeping step 1 current, not skipping step 2, and being able to defend step 3.

Where it goes wrong

  1. 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.

  2. 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.

  3. 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.

  4. 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

JSServe-time filtering
// 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.
The honest test: if a rate on your site were three months stale, how long before anyone noticed? If the answer is “a user would tell us”, that is the cost you are actually deciding about.

Frequently asked