Offers
Read the offers on your vehicles and the offers you made, and accept, decline, counter or withdraw them with the app's own rules, dry runs and idempotency keys.
TL;DRAI summary
This chapter explains how to manage offers on your vehicles and the offers you make on others. You'll learn how to view, accept, decline, counter, or withdraw offers, while understanding the rules Cypress uses for these decisions.
The offers on your vehicles, and the offers your organization made on others', are on the API, and so are the decisions: accept, decline, counter, withdraw. Since 1.5.0. Every decision goes through the same offer commands the app and Cedar use, so the rules are the app's whoever calls:
- The floor is the seller's consent. Accepting an offer below the floor, or
countering below it, is a person's decision in the app. Every API caller is
refused with
403 PEOPLE_ONLY: an API key, a connected app acting for a person, an agent. An agent cannot decide a below-floor offer at all, a decline included (403 BELOW_FLOOR). - Only an open offer can be decided (
NEW,REVIEWEDorCOUNTERED). Anything else answers409 CONFLICT, and nothing is written. - Your organization's switches hold. A member without the right to decide
offers, through an app, is refused with
403 POLICY_DENIED. - The buyer stays anonymous, as in the webhooks: an offer never names the organization or the person who made it.
| Scope | Grants |
|---|---|
offers:read | GET /api/v1/offers, GET /api/v1/offers/{id} |
offers:write | Everything offers:read grants, plus the four decisions below |
Both an API key and a Connect Cypress token are
accepted. Agents reach the same commands through
Cypress Operate (list_offers, get_offer,
accept_offer, decline_offer, counter_offer, withdraw_offer).
The offer
{
"id": "clx…",
"href": "/api/v1/offers/clx…",
"side": "SELLER",
"vehicleId": "clx…",
"vehicleHref": "/api/v1/vehicles/clx…",
"vin": "1FTFW1ET5DFC10312",
"amount": 21000,
"status": "COUNTERED",
"counterAmount": 22000,
"counterExpiresAt": "2026-11-04T15:00:00.000Z",
"createdOn": "2026-11-02T14:10:00.000Z",
"updatedOn": "2026-11-02T15:00:00.000Z",
"links": { "app": "https://app.getcypress.ai/go/offer/clx…" }
}side is SELLER for an offer on one of your vehicles and BUYER for one
your organization made. On the buyer side vehicleHref is null: the vehicle
is not yours to read through the vehicles routes. status is one of NEW,
REVIEWED, COUNTERED, ACCEPTED, REJECTED, EXPIRED, WITHDRAWN or
CANCELLED. links.app opens the offer in Seller Hub for the seller and in
Buyer Hub for the buyer.
List and read
GET /api/v1/offers returns the offers on your vehicles, newest first, 25 to a
page (limit up to 100, offset, nextHref). Filters combine:
| Filter | Example |
|---|---|
side | SELLER (the default) or BUYER, the offers you made |
status | NEW,COUNTERED |
vehicleId | the offers on one vehicle |
curl "$BASE/api/v1/offers?status=NEW,REVIEWED" -H "authorization: Bearer $TOKEN"GET /api/v1/offers/{id} returns one offer on your vehicle or one you made.
Any other offer answers 404, exactly as one that does not exist.
The buyer's record with you
GET /api/v1/offers/received (the Seller Hub's list, list_offers_received)
carries a buyerRecord on each offer: what this buyer did with you, never
with anyone else. null when the two of you never closed a deal. Since
1.9.0.
"buyerRecord": { "purchasesWithYou": 4, "lastPurchaseAt": "2026-09-30T16:20:00.000Z", "avgDaysToPay": 2.5 }| Field | Meaning |
|---|---|
purchasesWithYou | Closings with you that completed |
lastPurchaseAt | When the latest of them completed |
avgDaysToPay | Average days from payment due (the Bill of Sale done) to the funds confirmed, one decimal; a closing recorded off-platform is left out. null when none has both |
The condition at offer
Every offer made since 1.9.0 keeps the car's condition as the buyer saw it at
that moment, so a later "it was not like this" has the record. Both sides read
it as conditionAtOffer: the buyer on GET /api/v1/offers/made
(list_offers_made), the seller on GET /api/v1/offers/received. null on an
older offer.
"conditionAtOffer": {
"condition": "GOOD", "conditionScore": 3.8, "arvisScore": 3.6, "damageCount": 3,
"inspection": { "id": "clx…", "at": "2026-10-26T15:00:00.000Z", "provider": "MANHEIM", "grade": 3.5, "gradeLabel": "Average", "status": "REVIEW" },
"takenAt": "2026-10-27T18:04:11.000Z"
}inspection is the latest one the vehicle page shows (in review or finalized),
damageCount its damages. Repair costs and the inspector's value estimate are
the seller's and are never kept.
Which offer to answer first
GET /api/v1/offers/received does the arithmetic, so your app or agent reads
the facts instead of working them out. Since 1.9.0. Each offer carries:
| Field | Meaning |
|---|---|
waitingHours | Whole hours since the offer was made, while it waits for your answer (NEW or REVIEWED). null once you answered it or it was decided |
ofListing | The offer over your listing price, to three decimals: 0.962 is 96.2%. null without a listing price |
underFloorBy, aboveFloorBy | How far below or above your floor, in dollars. At the floor, aboveFloorBy is 0. Both are null with no floor |
answerOrder | Its place among the offers waiting for your answer: 1 is the one to answer first. null when it is not waiting for you |
answerWhy | Why that place, in plain words |
The order: offers at or above the floor first, then the one waiting longest, then the highest share of its listing price.
"answerOrder": 1,
"answerWhy": "above the floor by $600, waiting 20h, 96.2% of listing"The answer also carries a summary of the offers waiting for your answer:
"summary": {
"count": 4,
"answerFirst": { "offerId": "clx…a", "why": "above the floor by $600, waiting 20h, 96.2% of listing" },
"oldest": { "offerId": "clx…b", "waitingHours": 30 },
"closestToListing": { "offerId": "clx…a", "ofListing": 0.962 },
"underFloor": [{ "offerId": "clx…b", "by": 100 }, { "offerId": "clx…d", "by": 2000 }]
}answerOrder and summary cover every offer waiting for your answer that
search and vehicleId match, on every page, whatever the tab and sort.
So the one to answer first is never only the first page's. Each names its
offer by id: two offers on the same model stay apart.
On GET /api/v1/offers/made (list_offers_made), an offer with a live counter
from the seller carries counterHoursLeft: the whole hours left to accept it,
never below 0. It is null without a live counter. Since 1.9.0.
Decide
| Request | Body | Does |
|---|---|---|
POST /api/v1/offers/{id}/accept | none | Accepts an offer on your vehicle. The closing opens |
POST /api/v1/offers/{id}/decline | { "reason"? } (up to 500 characters) | Declines it; the buyer is told |
POST /api/v1/offers/{id}/counter | { "amount", "message"? } (up to 1,000 characters) | Answers with your price. The buyer has 48 hours (counterExpiresAt) |
POST /api/v1/offers/{id}/withdraw | none | Withdraws an open offer your organization made. The vehicle goes back to Listed only when no other offer on it is still open |
The answer is { "result": { "offerId", "status", "message" }, "offer": { … } },
the offer as it is after the decision. A counter's result also carries
counterAmount and counterExpiresAt.
curl -X POST "$BASE/api/v1/offers/clx…/counter" \
-H "authorization: Bearer $TOKEN" -H "content-type: application/json" \
-H "Idempotency-Key: counter-off_123-1" \
-d '{ "amount": 22000, "message": "Fresh tires, one owner." }'Dry runs. Every decision takes ?dryRun=true: the rules are checked and
the answer is { "dryRun": true, "result": { "status": "WOULD_ACCEPT", … } },
with nothing written and no event sent. A refusal is the same refusal either
way, so a dry run tells you whether the decision is yours to make.
Idempotency keys. Send Idempotency-Key (up to 200 characters) and a
retry with the same key and body gets the first answer, with
Idempotent-Replayed: true, instead of a second decision. The same key with a
different body is 422 IDEMPOTENCY_KEY_REUSED; a key whose first request is
still running answers 409 IDEMPOTENCY_IN_PROGRESS.
Decisions send the offer events
a decision in the app sends, to the seller's endpoints: OFFERS.ACCEPTED,
OFFERS.DECLINED and OFFERS.COUNTERED, and OFFERS.WITHDRAWN when a buyer
withdraws. The codes are in
Errors.
Picks that learn
Since 1.9.0. GET /api/v1/marketplace/picks (MCP: list_marketplace_picks)
ranks the cars for sale like the ones your person made offers on and the ones
your organization bought (a completed purchase counts twice an offer: the same
make, model and body, in your price band), each with a plain reason ("Ford
Transit: you bought 2 of these"). Picks are a person's: an API key is refused.
When a pick is not for you, say why:
POST /api/v1/marketplace/picks/{vehicleId}/dismiss
{ "reason": "PRICE", "note": "Over budget this quarter" }reason is PRICE, MILEAGE, MODEL, LOCATION or OTHER (MCP:
dismiss_marketplace_pick). The car never comes back in your organization's
picks, and cars like it rank lower by the reason: as dear (PRICE), with as
many miles (MILEAGE), the same model (MODEL), in the same state
(LOCATION). Saying it again replaces the reason. DELETE on the same path
(MCP: restore_marketplace_pick) takes it back. Only your organization reads
it: the seller is never told.
Your usual
Since 1.9.0. GET /api/v1/marketplace/purchase-profile (MCP:
get_purchase_profile, marketplace:read) reads what your organization
typically buys on Cypress, from its own completed purchases (an accepted offer
of yours on a car now sold) in the last year (days, 30 to 1,095): the price
you paid, the age and mileage when bought (each the middle half, the whole
range under four purchases, with the median), the model years that age reads
as today, and the makes, models, bodyClasses and states you bought in,
with cadenceDays and lastPurchaseAt. Use it to prefill a buy plan target.
Only your own trades: no seller is named.
{
"purchases": 12, "periodDays": 365,
"price": { "low": 18000, "median": 20500, "high": 24000 },
"age": { "low": 2, "median": 3, "high": 4 }, "years": { "min": 2022, "max": 2024 },
"mileage": { "low": 30000, "median": 40000, "high": 60000 },
"models": [{ "make": "Ford", "model": "Transit", "count": 7 }],
"bodyClasses": [{ "label": "Van", "count": 11, "share": 0.92 }],
"states": [{ "label": "TX", "count": 8, "share": 0.67 }],
"words": "Your usual, from 12 purchases in the last 365 days: 2022–2024, Ford Transit, $18,000–$24,000, 30,000–60,000 miles, mostly bought in TX."
}