Pushing inventory
Send your vehicles one at a time or 500 at a time, map your feed's fields, attach an inspection report by its link, and keep the whole inventory in step.
TL;DRAI summary
This chapter explains how to push your vehicle inventory to Cypress, either one vehicle at a time or in bulk. You will learn how to add, update, or replace vehicles, and how to keep your Cypress inventory in sync with your system.
Your system is the source of truth for what you own; Cypress keeps a copy it
can sell. A push writes that copy: one vehicle with POST /api/v1/vehicles,
or up to 500 with POST /api/v1/vehicles/bulk. Both need the vehicles
scope.
One vehicle or many
| You want to… | Call | What happens |
|---|---|---|
| Add a vehicle | POST /api/v1/vehicles | Created as a draft (201). A VIN already in your inventory answers 409 with its id |
| Add it or replace it, by VIN | POST /api/v1/vehicles?mode=upsert | New VINs are created (201), known ones replaced (200): every field you leave out is cleared |
| Add it or update what you send, by VIN | POST /api/v1/vehicles?mode=merge | New VINs are created (201); on known ones only the fields you send change (200) (since 1.8.0) |
| Replace one you know | PUT /api/v1/vehicles/{id} | Every field you leave out is cleared (see below) |
| Change a few fields | PATCH /api/v1/vehicles/{id} | Only the fields you send change |
| Send many | POST /api/v1/vehicles/bulk (?mode=merge or ?mode=upsert) | Up to 500 items, an answer per item; one bad item never fails the others |
| Send your whole inventory, and take off what left | POST /api/v1/vehicles/bulk?mode=merge&removeMissing=true | The vehicles the push leaves out are archived, at once or, when a buyer is involved, after Cypress's review: see below (since 1.8.0) |
A daily feed usually sends everything with POST /api/v1/vehicles/bulk?mode=merge:
new vehicles are added, the rest are brought up to date, and you never need to
know which is which. Merge or upsert? merge changes only the fields each
item sends, so the details your feed doesn't carry (a trim, a transmission, an
interior color your team or Cypress filled in) stay. upsert makes each vehicle
exactly what you send and clears the rest: use it when your feed carries every
field. Every push takes ?dryRun=true (every check, nothing written); the
single-vehicle writes also take an Idempotency-Key.
A bulk push answers one item per vehicle, in the order sent:
{
"items": [
{ "vin": "1FTFW1ET5DFC10312", "status": 201, "id": "clx…", "href": "/api/v1/vehicles/clx…", "inspectionReport": "QUEUED" },
{ "vin": "1FAHP2E88DG100002", "status": 200, "id": "clx…", "href": "/api/v1/vehicles/clx…" },
{ "vin": "BAD", "status": 422, "errors": ["vin: …"] }
],
"created": 1, "updated": 1, "conflicts": 0, "invalid": 1, "refused": 0
}The fields
Four fields are required: vin, modelYear, make and model. Cypress does
not decode the VIN of a pushed vehicle: your data is the record.
| Field | What it is |
|---|---|
vin, modelYear, make, model | The vehicle. model may carry the trim ("228I AWD"); a known trim is lifted into trim |
trim, bodyClass, driveType, transmission, fuelType | Optional specs |
exteriorColor, interiorColor | As your system names them |
mileage | Whole miles |
condition | Preferably the industry 1.0–5.0 grade (3.9); EXCELLENT, GOOD, FAIR or POOR also work |
stockNumber | Your unit or stock number, shown to people |
externalIds | Your systems' ids for the vehicle, to look it up by them: see Your own ids |
location | name, address, city, state, zip. A yard code such as "DEN" goes in name: when it matches one of your saved locations (Settings › Locations, any case), Cypress fills the address, city, state and ZIP you leave out (since 1.8.0). Since 1.9.0 a location's aliases match too ("HOU-2" for "Houston Hobby lot"), and the vehicle takes the location's own name |
floorPrice | Your floor: the lowest sale price you accept. Set at once on a vehicle not yet on the market; on one on the market or in a sale, a change Cypress reviews first (since 1.8.0) |
images | Up to 50 { "href", "type" } links Cypress can fetch; the first, or the one typed PRIMARY_IMAGE, is the cover |
inspectionReportUrl | A third-party inspection report's link: see Inspection reports (since 1.8.0) |
listing | Put it on the market in the same push: prices, channels and listingMode. See Pricing & listing rules |
lifeStage | OPERATION or PLANNING sends it to the plan instead of to market: see Planning API |
A field Cypress doesn't know is kept with the push and never refused, so your feed can send its own columns as they are.
A typical fleet feed maps like this:
{
"vin": "1FTFW1ET5DFC10312",
"modelYear": 2013, "make": "Ford", "model": "F-150 XLT",
"exteriorColor": "BLACK",
"mileage": 88500,
"stockNumber": "101815",
"externalIds": [{ "source": "fleet", "id": "101815" }],
"location": { "name": "DEN" },
"floorPrice": 24300,
"inspectionReportUrl": "https://inspectionreport.manheim.com/?disclosureid=…"
}With a saved location named DEN, the vehicle gets its address. A name that
matches none of your saved locations is a warning on the item, and the vehicle
keeps what you sent. The item says what became of the floor in floorPrice:
SET, UNCHANGED, or STAGED when the vehicle is on the market and Cypress
reviews the change first.
What a replace clears. A PUT, or mode=upsert on a VIN you already pushed,
replaces the vehicle: a field you leave out is cleared, and the pictures you
pushed are replaced by the ones you send (none sent, none kept). Two things a
replace never touches: pictures added in the app or read from an inspection
report, and — since 1.8.0 — a condition an inspection set, when the push
carries no condition. A PATCH, or mode=merge, changes only what it names:
your pictures stay unless it sends images.
Prices on a re-push. The floor travels on every push (floorPrice, above).
The listing block creates the listing the first time; once a vehicle has an
open listing, a re-push leaves the listing's own prices as they are: change them
with PATCH /api/v1/listings/{id} (the vehicle's read names the listing in
listing.id). A push that sends both floorPrice and listing.floorPrice
must send the same amount.
Inspection reports
Since 1.9.0. A vehicle's read also has its latest ARVIS inspection, Cypress's own, in arvisInspection:
its status, the technician's grade (EXCELLENT, GOOD, FAIR, POOR) with the same score the app shows (5 to
2), when it was inspected, how many damages it found and their estimated repair cost, and a url that opens it in
ARVIS through the app's sign-on (for a person signed in to Cypress whose role may see ARVIS). It is null when the
vehicle has none. The third-party report below is separate, in inspectionReport.
Since 1.8.0. Send the link of a third-party inspection report with the
vehicle, and Cypress reads the report for you. Today that is the Manheim
condition report (https://inspectionreport.manheim.com/?disclosureid=…):
- its grade becomes the vehicle's condition (unless a Cypress inspection was finalized for it),
- each damage is recorded, with the title, keys, tires and warning lights,
- its photos are added to the vehicle, so a feed with no pictures still lists with them.
The push answers right away and the report is read in the background. The push
says what it did with the link, in inspectionReport:
| Answer | Means |
|---|---|
QUEUED | A new link: Cypress is reading it |
UNCHANGED | The vehicle already has this link: nothing to do |
LINK_ONLY | No provider Cypress reads: the link is kept on the vehicle for people to open |
A Manheim link that names no report (no disclosureid) is a warning on the
push, and the vehicle is written anyway. Because the same link changes nothing,
a daily feed can send the link every time; a new link replaces the report,
and a link whose report could not be read is tried again at most once a day.
Null or no inspectionReportUrl leaves the vehicle's report as it is.
When the report lands, your endpoint hears VEHICLES.UPDATED with
changedFields: ["inspectionReport"] and the report in inspectionReport. Its
status is IMPORTING_PHOTOS when the grade and damages are in and the photos
are on their way, COMPLETE when everything is in, FAILED when the report
could not be read.
The event and the vehicle's read carry the report in the same shape:
"inspectionReport": {
"url": "https://inspectionreport.manheim.com/?disclosureid=…",
"provider": "MANHEIM",
"status": "COMPLETE",
"grade": 3.9, "gradeLabel": "Average",
"damages": 3, "photos": 37,
"updatedOn": "2026-10-22T14:02:11.000Z"
}and GET /api/v1/vehicles/{id}/history-report has each damage. To attach a
link to a vehicle on its own, POST /api/v1/vehicles/{id}/history-report with
{ "url": "…" } (MCP: attach_vehicle_history_report).
Keys and declared damage
Since 1.9.0. What the seller says of a car travels with it, apart from any inspection:
- Keys and a damage note.
keyCount(how many keys come with the car,0for none) anddamageNote(the seller's own words) are set when the car is added (POST /api/v1/fleet/vehicles,add_vehicle) or edited (PATCH /api/v1/fleet/vehicles/{id},edit_vehicle); null clears them. - Damage lines.
POST /api/v1/vehicles/{id}/damages(add_vehicle_damage) declares one:area(rear bumper, driver door),description,severity(MINOR,MODERATE,SEVERE,CRITICAL) and, optionally, a picture sent ascontentBase64andcontentType, as a photo upload is. The picture joins the car's photos as a damage photo, never as the cover.GETon the same path lists the lines with each picture's link;DELETE …/damages/{damageId}takes one back. Declaring damage is a person's act: an agent's token is refused403 PEOPLE_ONLY.
The vehicle's read carries keyCount, damageNote and damages. A change to
a car's condition, grade, keys, damage note or damage lines is told on the
car's Updates to the seller, to Cypress and to every buyer with an open
offer on it, who bid on the car as it was described; your endpoint hears
VEHICLES.UPDATED with changedFields naming what moved (damages,
keyCount, damageNote, condition).
Condition checked in person
Since 1.10.0. A condition can be a guess, a grade from a feed, or what a
person saw. edit_vehicle takes conditionChecked: true when someone at the
organization looked at the car and its condition is as set: Cypress keeps who
and when, and the vehicle's read carries conditionCheck (checkedOn, by),
or null when nobody has. false clears it. It is a person's word: an agent's
token is refused 403 PEOPLE_ONLY. Listing readiness warns
(field: "conditionCheck") when a car has a condition nobody checked and no
inspection; it never blocks a listing.
Keeping the whole inventory in step
Since 1.8.0. Add removeMissing=true and the push is your whole inventory:
every active vehicle Cypress holds for you that the push leaves out is one you
want off the platform. You don't keep track of what you sent or what left; the
next push says it.
POST /api/v1/vehicles/bulk?mode=merge&removeMissing=trueWhat happens to a vehicle the push leaves out is what happens when you archive it in the app, or pick the "vehicles not in this file" on the Import screen:
- With no open offer and no sale in progress, it is archived at once.
- With an open offer, or a sale in progress on Cypress (an accepted
offer, due diligence, ready to deliver), it waits for a Cypress Manager,
flagged with
liveDealso the reviewer sees the deal. A vehicle on the market pauses while it waits (ON_HOLD); if the removal is rejected, it goes back on the market. - A vehicle whose removal Cypress rejected is not asked again on every push. Push it again and leave it out later, and it is filed again.
- Active means not sold, not archived and not in Planning, whoever added it: a vehicle added in the app counts too.
- A vehicle that already has a request waiting is not filed twice
(
alreadyPending). - An item refused for another reason (a bad field, an id another vehicle holds) still counts as in your inventory: a typo never takes a car off.
- A vehicle you push that still has a request waiting (it was left out before) gets a warning on its item. The push does not withdraw the request; tell Cypress if the vehicle should stay. A vehicle you push that is already archived gets its details updated, and a warning: a push does not bring it back to Cypress.
The answer adds removals: one entry in items per vehicle taken off or
filed, and one in kept per vehicle left out but not filed, with the reason
(REVIEW_REJECTED, or NO_LONGER_ACTIVE for one sold or archived while the
push was written):
"removals": {
"activeInventory": 120, "missing": 3, "archived": 1, "staged": 1, "alreadyPending": 0,
"items": [
{ "vin": "…", "id": "clx…", "requestId": "clx…", "archived": true, "statusBefore": "LISTED", "statusAfter": "ARCHIVED",
"paused": false, "alreadyPending": false, "liveDeal": null },
{ "vin": "…", "id": "clx…", "requestId": "clx…", "archived": false, "statusBefore": "UNDER_OFFER", "statusAfter": "UNDER_OFFER",
"paused": false, "alreadyPending": false, "liveDeal": { "kind": "OFFERS", "openOffers": 1, "topOffer": 21000 } }
],
"kept": [
{ "vin": "…", "id": "clx…", "status": "APPROVED", "reason": "REVIEW_REJECTED" }
]
}Your endpoint hears the same through webhooks:
- A vehicle archived at once:
VEHICLES.DISPOSITION.RESOLVEDright away, withdecision: "APPROVED",automatic: true,statusBeforeandstatusAfter: "ARCHIVED". - A vehicle that waits for review:
VEHICLES.DISPOSITION.STAGEDnow (itsrequestId,statusBefore,statusAfter,paused,reviewReasonandliveDeal), thenVEHICLES.DISPOSITION.RESOLVEDwhen Cypress decides: thedecision(APPROVEDorREJECTED) andstatusAfter:ARCHIVEDwhen approved; when rejected,LISTEDagain if the request had paused it, otherwise the status it had. - Both carry
source: "api-sync".
Add syncId=<your label> (letters, digits, ., :, - or _, up to 64) and
the push's answer and both events carry it, so your logs can tie a decision to
the push that asked for it.
More than 500 vehicles. Send the pages as usual, without the flag. On the
last page, add removeMissing=true and inventoryVins: every VIN you hold (up
to 20,000). Cypress compares against that list and the page's own vehicles.
Nothing is kept between calls, so a page that failed is simply sent again.
POST /api/v1/vehicles/bulk?mode=merge&removeMissing=true&syncId=feed-2026-10-22
{ "vehicles": [ …the last page… ], "inventoryVins": ["1FTFW1ET5DFC10312", "…"] }A truncated feed never empties your fleet. A push that leaves out more than
a fifth of your active vehicles (and more than five) is refused whole, items
included (409 MASS_REMOVAL, with activeInventory and missing): a vehicle
with no buyer is archived at once, so the guard stops a broken feed before
anything is written. When the removal is meant, add allowMassRemoval=true.
Try it first. With dryRun=true the push answers what each item would do
and what would happen to each vehicle it leaves out, writing nothing.
To take off a single vehicle, or to say why it left (sold elsewhere, back on rent), the calls of De-listing remain.
How each car is doing
Since 1.9.0. Every vehicle in GET /api/v1/vehicles (and GET /api/v1/vehicles/{id})
says how buyers are taking to it, as the Seller Hub's Market column does:
| Field | Meaning |
|---|---|
engagement | While the car is on the market (LISTED, UNDER_OFFER, ON_HOLD): COLD with no views, saves or offers in 14 days, HOT when the last 7 days beat the 7 before by 20% or more (or a new listing's first week has any), else WARM. Null off the market |
listedDays | Whole days on the market from the listing date, stopped at the sale; null when it was never listed |
views14d, saves14d, offers14d | Buyer views, saves and offers in the last 14 days |
Add engagement=COLD (or a comma list, HOT,WARM) to list only the cars on
the market with that engagement, newest first: GET /api/v1/vehicles?engagement=COLD
is the cars nobody looks at. It combines with the other filters, and
totalCount and nextHref count the filtered set.
Every car on the marketplace says it too, whoever sells it: engagement is in
each result of GET /api/v1/marketplace/vehicles (MCP: search_marketplace) and
in GET /api/v1/marketplace/vehicles/{id} (get_marketplace_vehicle), so a
buyer sees which cars are drawing interest. Another seller's car gives the level
only: its views, saves and offers stay its seller's.
Dashboard numbers
Since 1.9.0. GET /api/v1/fleet/metrics?period=30d (MCP: get_fleet_metrics,
scope vehicles) answers the dashboard's tiles from the same computation the
dashboard page draws, so an app that shows them shows the dashboard's numbers.
period is 7d, 30d (the default) or 90d, as the dashboard's switch
(365d is read too).
{
"period": "30d", "periodDays": 30,
"from": "2026-09-27T21:00:00.000Z", "to": "2026-10-27T21:00:00.000Z",
"pipeline": { "activePipeline": 69, "stuckCount": 33, "totalFleet": 104 },
"tiles": [
{ "key": "activePipeline", "label": "Active pipeline", "value": 69, "display": "69", "format": "number",
"snapshot": true, "prev": null, "delta": null, "lowerIsBetter": false, "trend": [], "trendStep": null },
{ "key": "wholesaleGmv", "label": "Wholesale GMV", "value": 1033400, "display": "$1.0M", "format": "currency",
"snapshot": true, "prev": null, "delta": null, "lowerIsBetter": false, "trend": [], "trendStep": null },
{ "key": "offersPerListing", "label": "Offers per listing", "value": 0.3, "display": "0.3", "format": "decimal",
"snapshot": false, "prev": 0.1, "delta": { "pct": 200, "better": true, "display": "↑200.0%" },
"lowerIsBetter": false, "trend": [], "trendStep": null }
]
}(Three of the seven tiles shown.) The tiles come in the dashboard's order: Active pipeline, Stuck 7+ days, Wholesale GMV, Retail value, Vehicles sold, Avg days to sell, Offers per listing.
| Field | Meaning |
|---|---|
label, display | The dashboard's words and the value as it shows it ($1.0M, 122.4d, 0.3, US format). Show display as is to match it |
snapshot | True for what is true now: Active pipeline and Stuck 7+ days (the pipeline's counts, planning stock included) and Wholesale GMV and Retail value (the latest valuation of every vehicle not sold, archived or planning stock). A snapshot has no prior period, no delta and no trend |
prev, delta | The prior period of the same length, and the change against it. delta is null for a snapshot and when the prior period is 0; its display arrow means better (↑) or worse (↓), so a shorter Avg days to sell is ↑ |
trend, trendStep | A real series only: Vehicles sold per day, Avg days to sell per week (a week without a sale reads as the period's average). The dashboard draws it when it has more than 2 points |
There is no history of fleet value. fleetValue and retailValue, kept from
before 1.9.0 beside vehiclesSold, avgDaysToSell, offersPerListing and
daysToSellDistribution, are snapshots: prev is 0 and trend is empty.
Never draw a fleet-value trend or compare it with a prior period. A sale counts
on the day the vehicle became SOLD.
Comparable sales
Since 1.9.0. GET /api/v1/vehicles/{id}/comps (MCP: get_vehicle_comps, scope
vehicles) reads what cars like one of yours sold for on Cypress: the same year
(one either side), make and model, at a similar mileage (within 30%, never closer
than 25,000 miles; a sale with no mileage on record counts), sold in the last
year (days, 30 to 730).
- Your own sales come one by one in
yours.items, newest first, each with its price, asking price, mileage, date, state and buyer. - Other sellers' sales come only as a range in
market: low, middle and high (rounded to $100), what they fetched against their ask, and how many. A range needs five sales from at least two other companies; with fewer,marketsaysenough: falseand gives no figure. Another seller's sale is never listed, priced on its own or named. summarycounts your sales and, when the range stands, theirs: the median sold price, that median as a percent of this car's listing price (pctOfListing) and what the sales fetched against their own ask (medianPctOfAsk).blackBookis the car's latest Black Book value (retail, wholesale, trade-in), null when it has none.
{
"summary": { "count": 7, "medianSoldPrice": 28000, "pctOfListing": 93.3, "medianPctOfAsk": 96.6, "basis": "YOURS_AND_MARKET",
"words": "7 sold like it in the last 365 days, middle $28,000, 93% of your listing price." },
"yours": { "totalCount": 2, "items": [ { "vehicleId": "clx…", "year": 2022, "make": "Ford", "model": "Transit", "mileage": 45000,
"soldPrice": 29000, "askingPrice": 30000, "pctOfAsk": 96.7, "soldAt": "2026-10-22T12:09:50.891Z", "state": "TX",
"buyer": { "organizationId": "clx…", "name": "Lakeside Motors" } } ] },
"market": { "enough": true, "count": 5, "low": 27000, "median": 28000, "high": 29000, "medianPctOfAsk": 96.6, "medianMileage": null },
"blackBook": { "source": "BLACK_BOOK", "retail": 32000, "wholesale": 27000, "tradeIn": 25000, "mileageAtValuation": 40000, "valuedAt": "…" }
}GET /api/v1/vehicles/comps?year=2021&make=Ford&model=Transit (MCP:
find_comparable_sales) asks the same for a car you name rather than one you
hold; add mileage and listingPrice to compare with them. mine=true reads
your own sales only, so mine=true&limit=1 answers "my last sold 2021 Ford
Transit".
Staging an import for review
Since 1.9.0. A spreadsheet import (POST /api/v1/imports, import_vehicles)
can be held for a person to review before it runs. Send it with
"mode": "stage": Cypress runs the import's dry run, keeps it with the rows as
parsed, and answers the staged batch (id, totalRows, exceptionRows). No
vehicle is written, and your endpoint hears VEHICLES.IMPORT.STAGED.
GET /api/v1/imports/{batchId} reads it back (kind: "STAGED"): every row,
what the dry run said of it, and its exceptions, the things a person should
look at before it runs:
| Exception | Means |
|---|---|
DUPLICATE_VIN | The VIN is on more than one row of the file |
MILEAGE_DROP | The odometer is 10 times lower (or more) than the car's |
UNKNOWN_LOCATION | The location name matches no saved location and no alias |
ALREADY_SOLD | The VIN is a car you already sold |
ARCHIVE_LIVE_DEAL | The row archives a car that has an open offer or a sale in progress |
MISSING_LIVE_DEAL | A car the file leaves out has an open offer or a sale in progress (a row of its own, after the file's) |
PATCH /api/v1/imports/{batchId}/rows/{rowId} (decide_import_row) settles a
row: decision KEEP (do what the row proposes) or SKIP (leave it out),
locationId to map an unknown location name to a saved location (add
rememberAlias: true and the name becomes that location's alias, so the next
file needs no decision), or mileage to correct the odometer.
POST /api/v1/imports/{batchId}/apply (apply_import) runs it once every
exception is settled (409 CONFLICT lists the rows that are not), with the rows
as decided: the import itself runs, with its checks, and answers the batches it
started in importBatchIds; your endpoint hears VEHICLES.IMPORT.APPLIED, then
VEHICLES.IMPORT.COMPLETED for each batch. ?dryRun=true answers the import's
own dry run of the decided rows. Staging, deciding and applying are a person's
acts: an agent's token is refused 403 PEOPLE_ONLY.
Location aliases. A saved location answers to other names your files use
for it: PATCH /api/v1/locations/{locationId} with
{ "aliases": ["HOU-2", "Hobby"] } (the list replaces the one stored), read
back on GET /api/v1/locations. An import row or a push naming an alias is put
at that location, under the location's own name. A name another location
already answers to is refused (409 CONFLICT). Aliases are set by a person.