Show today's OEM offers on every VDP, and catch the day they change
Pull current manufacturer cash, finance and lease offers for any ZIP, put them on a VDP or lead form, build them into payment quotes, and get told when a program changes.
MarketCheck5 min read
For developers at dealer groups, lenders and car-shopping sites
Every manufacturer publishes its cash, finance and lease offers on its own website, market by market, and changes them on its own schedule. Copying them onto vehicle detail pages or into a quote tool by hand is slow, and a copied offer stays on the page after the OEM ends it.
MarketCheck crawls those OEM offer pages daily and returns them as structured data: the vehicles each offer covers, the payment terms, the validity dates and the fine print.
In short. Call
/v2/search/car/incentive/{make}/{zip}for the offers that apply in that ZIP's metro area. Show the OEM's own text and fine print, and key everything onbase_sha, which identifies one unique offer wherever it runs. The data refreshes daily, so cache it for the day.
Two endpoints, one dataset
- OEM Incentive by Make and ZIP,
GET /v2/search/car/incentive/{make}/{zip}. Put a make and a ZIP in the path and get every active offer for that make in the ZIP's Metropolitan Statistical Area (MSA). MarketCheck crawls one representative ZIP per MSA, and the docs note that an offer valid in one ZIP of an MSA typically applies across all of it. - OEM Incentive Search,
GET /v2/search/car/incentive/oem. The same data with everything as a query filter (make,model,state,msa_code,offer_type,apr_range,cashback_amount_rangeand more), plusfacetsandstatsfor analysis across makes and markets.
Both return the same shape. Every offer is cash, finance or lease, and all of them are US offers collected from manufacturer websites, so a store's own discounts are outside the data.
Here is one offer, trimmed from the documented make-and-ZIP sample (Ford, ZIP 90210):
{
"num_found": 180,
"listings": [
{
"base_sha": "094588e4a95ee3675ced71242ac6216e8f5ca8d3852952e1c07e46e39a5e0b15",
"offer": {
"vehicles": [{ "make": "Ford", "year": 2025, "model": "Ranger", "trim": "XLT" }],
"offer_type": "lease",
"amounts": [{ "monthly": 369, "term": 36, "term_unit": "months" }],
"cashback_amount": 1000,
"due_at_signing": 4509,
"mileage_limit": 31500,
"valid_from": "07/14/2025",
"valid_through": "09/02/2025",
"titles": ["2025 Ford Ranger XLT"],
"offers": ["$369 /mo. for 36 mos. Ford Credit Red Carpet Lease $4509 Cash Due at Signing Security Deposit Waived Taxes, Title, License Fees Extra"],
"disclaimers": ["With Equipment Group 301A. Not all buyers will qualify for Ford Credit Red Carpet Lease. Payments may vary; dealer determines price. ..."]
},
"msa_code": "4480",
"zip": "90210",
"source": "ford.com",
"status_date": "2025-07-17T09:38:59.000Z",
"scraped_at_date": "2025-07-16T09:37:48.000Z"
}
]
}What to know about the fields:
vehiclesandamountsare arrays. One offer can cover several model years, models or trims, and list several terms.titles,offersanddisclaimersare the OEM's own text, kept as-is so you can reproduce the offer the way the manufacturer advertised it.valid_fromandvalid_throughcome from the offer text.status_dateis when MarketCheck last verified the offer andscraped_at_dateis when it first found it.base_shais a hash of the offer without its location, so one offer found in many MSAs shares onebase_sha.idis unique to each document.
Offers on a VDP
- VDP for a new unit
- Offers for make and ZIP
- Match model, year and trim
- Offer text on the page
For a new-car VDP, call the make-and-ZIP endpoint with the store's ZIP and narrow with model, year and trim from the unit. Render each match with its title, offer text and disclaimers, plus valid_through so the shopper can see when it ends. Where an offer carries offer_link, link to the manufacturer's page for the full terms.
Model names follow the offer and may differ from the strings in your own inventory feed; the same documented sample lists a Bronco 4-Door. Call the endpoint once with facets=model for your make and ZIP to see the exact values, and keep a small mapping table.
Targeted rebates on a lead form
Some cash offers are only for certain buyers. cashback_target_group marks them, with groups such as military personnel or college graduates in the docs' examples. Leave those off the default view, then use them on a lead form: ask one qualifying question and show the rebates that apply to the answer.
Get the group values for your make and market with facets=cashback_target_group, then filter with cashback_target_group=<value>. The docs note that lease and finance offers do not always carry an extracted target group, and suggest search_offers_text, search_titles_text and search_disclaimers_text to find those.
Unusual use: payment quotes that include the OEM's rate
A lender or lead-gen site quotes its own rate, while the OEM may advertise a special APR on the same car. Put both in the quote: the payment at the OEM's advertised rate and term next to yours, plus your rate with any public rebate taken off the amount financed.
- Lead with vehicle, ZIP and asking price
- Finance and cash offers
- Payment math each way
- Shopper compares quotes
const BASE = 'https://api.marketcheck.com/v2'
interface Amount { monthly?: number, apr?: number, term?: number, term_unit?: string }
interface Offer {
offer_type: 'cash' | 'finance' | 'lease'
amounts: Amount[]
cashback_amount?: number
cashback_target_group?: string
titles: string[]
disclaimers: string[]
valid_through: string // MM/DD/YYYY, from the offer text
}
interface Incentive { base_sha: string, msa_code: string, offer: Offer }
export async function incentives(make: string, zip: string, params: Record<string, string | number> = {}) {
const url = new URL(`${BASE}/search/car/incentive/${encodeURIComponent(make.toLowerCase())}/${zip}`)
url.searchParams.set('api_key', process.env.MARKETCHECK_API_KEY ?? '')
for (const [key, value] of Object.entries(params)) url.searchParams.set(key, String(value))
const res = await fetch(url)
// 404 means an unsupported make or an invalid ZIP: show no offers rather than an error
if (res.status === 404) return [] as Incentive[]
if (!res.ok) throw new Error(`incentives returned ${res.status}`)
return (await res.json()).listings as Incentive[]
}
// Standard amortized payment. APR arrives as a percentage, such as 2.9
function monthly(principal: number, apr: number, months: number) {
const r = apr / 1200
return r === 0 ? principal / months : (principal * r) / (1 - (1 + r) ** -months)
}
interface Lead { make: string, model: string, year: number, zip: string, askingPrice: number, down: number, ourApr: number, ourTerm: number }
export async function paymentQuotes(lead: Lead) {
const filters = { model: lead.model, year: lead.year, rows: 10 }
const [finance, cash] = await Promise.all([
incentives(lead.make, lead.zip, { ...filters, offer_type: 'finance' }),
incentives(lead.make, lead.zip, { ...filters, offer_type: 'cash' })
])
const financed = lead.askingPrice - lead.down
const quotes = [{ label: 'Our rate', apr: lead.ourApr, months: lead.ourTerm, payment: monthly(financed, lead.ourApr, lead.ourTerm), fineprint: [] as string[] }]
for (const { offer } of finance) {
for (const a of offer.amounts) {
if (a.apr == null || !a.term) continue
const months = a.term_unit?.startsWith('year') ? a.term * 12 : a.term
quotes.push({ label: offer.titles[0] ?? 'OEM finance offer', apr: a.apr, months, payment: monthly(financed, a.apr, months), fineprint: offer.disclaimers })
}
}
// Targeted rebates need a qualifying question first, so public ones only here
for (const { offer } of cash.filter(i => i.offer.cashback_amount && !i.offer.cashback_target_group)) {
const principal = financed - (offer.cashback_amount ?? 0)
quotes.push({ label: `Our rate with ${offer.titles[0] ?? 'the OEM rebate'}`, apr: lead.ourApr, months: lead.ourTerm, payment: monthly(principal, lead.ourApr, lead.ourTerm), fineprint: offer.disclaimers })
}
return quotes.map(q => ({ ...q, payment: Math.round(q.payment) }))
}Show each quote's disclaimers next to it. They hold the qualification rules, such as the sample's "Not all buyers will qualify" line, and the program restrictions, so read them before you show a rebate and a special rate together.
For lease offers, quote what the OEM advertises: the monthly payment, term, amount due at signing and mileage limit. Fields the offer text does not state are left out of the response, so a lease payment rebuilt from the parts can be missing inputs.
Unusual use: catch the day an OEM program changes
The docs' lifecycle rules turn change tracking into a set comparison:
- Each daily crawl refreshes
status_dateon offers that are still live, and the API only returns offers verified in the last 24 hours. An offer the OEM pulls drops out on its own. - When an OEM changes an offer's terms, MarketCheck treats it as a new offer. The old one expires and a new one appears under a new
base_sha.
So keep a daily snapshot of base_sha values for each make and market you watch, and compare it with yesterday's. Offers missing today have ended. Offers new today are either new programs or changed ones, and a changed offer comes back with the same vehicles and offer type.
- Daily
- Offers per make and MSA
- Compare with yesterday
- Alert to sales and marketing
import json
import os
from pathlib import Path
import requests
BASE = "https://api.marketcheck.com/v2"
KEY = os.environ["MARKETCHECK_API_KEY"]
def offers(make, zip_code):
"""Every active offer for one make in one ZIP's MSA, keyed by base_sha."""
found, start, rows = {}, 0, 10 # the Incentive Search pagination docs cap rows at 10
while True:
r = requests.get(f"{BASE}/search/car/incentive/{make}/{zip_code}",
params={"api_key": KEY, "start": start, "rows": rows}, timeout=60)
r.raise_for_status()
page = r.json()
for listing in page["listings"]:
offer = listing["offer"]
found[listing["base_sha"]] = {
"type": offer["offer_type"],
"vehicles": sorted(f'{v.get("year")} {v.get("model")} {v.get("trim", "")}'.strip()
for v in offer["vehicles"]),
"title": (offer.get("titles") or [""])[0],
"valid_through": offer.get("valid_through"),
}
start += rows
if start >= page["num_found"]:
return found
def changes(make, zip_code, folder=Path("offer-snapshots")):
folder.mkdir(exist_ok=True)
path = folder / f"{make}-{zip_code}.json"
before = json.loads(path.read_text()) if path.exists() else {} # first run: everything is new
now = offers(make, zip_code)
path.write_text(json.dumps(now))
added = {k: v for k, v in now.items() if k not in before}
ended = {k: v for k, v in before.items() if k not in now}
# A changed offer ends and comes back under a new base_sha, so pair them by type and vehicles
changed = [(ended[old], added[new]) for old in ended for new in added
if ended[old]["type"] == added[new]["type"] and ended[old]["vehicles"] == added[new]["vehicles"]]
return added, ended, changedRun it for one ZIP per MSA you serve. Two filters make shorter work of common questions. scraped_at_date_range returns only offers first seen in a date range, and on the search endpoint it answers "what is new since yesterday" for a whole make or state in one call. valid_through_range finds offers that end this week. To see everywhere one offer runs, pass its base_sha to /v2/search/car/incentive/oem with facets=msa_code.
Gotchas
- The unit is the MSA. Any ZIP in a metro gets that metro's offers, so cache by the
msa_codein the response and refresh once a day. - Check parsed numbers against the offer text. In the sample, the disclaimer charges for mileage over 31,500 miles on a 36-month lease, a limit for the whole term, while the field definition describes an annual allowance. Compare parsed fields with the text before you do math with them.
- Two date formats.
valid_fromandvalid_throughareMM/DD/YYYYstrings from the offer.status_dateandscraped_at_dateare ISO 8601 timestamps. - 404 versus empty. A 404 means an unsupported make or an invalid ZIP, so check the supported OEM list in the docs. A supported make with nothing live returns
num_found: 0. dealer_idis the OEM. On incentive records,dealer_idandsourceidentify the manufacturer's website. Join offers to your stores by ZIP or MSA.- Paging and limits. Page with
startandrows. Going past your plan's pagination depth returns a 422, and a 429 carries aRetry-Afterheader to honor.
Who this is for
- Dealer web teams putting current OEM offers on new-car VDPs.
- Marketing and BDC teams using targeted rebates on lead forms.
- Lenders and lead-gen sites that quote payments and want the OEM's advertised rate in the same view.
- Analysts tracking program changes across makes and markets.
Start here
- Create a free account and copy your API key. Start on the Free tier for development; plans are on the pricing page.
- Call
/v2/search/car/incentive/{make}/{zip}for one make and your store's ZIP, then again withfacets=modelto build your model mapping. - Put one VDP's offers on a staging page with the full disclaimers.
- Run the snapshot comparison for a few days before you wire it to alerts.
The endpoint references are OEM Incentive by Make and ZIP and OEM Incentive Search. The incentives data guide explains how offers are collected and deduplicated.