cypress.DOCS
Log inRegister
Cypress Docs
Developers

Planning API (Buy planning)

Keep a dealer's buy plan in step from your purchasing system — the quarter, targets, live matches and purchases, the same rules the app uses, and webhooks that tell you when a match appears, a target fills or a quarter ends short, since API 1.4.0.

TL;DRAI summary

This chapter explains how your purchasing system can use the Buy planning API to manage your vehicle buying plan. You can set goals, track desired vehicles, record purchases, and approve your plan to keep everything in sync with your system.

Dive deeper:

With Buy planning switched on for your organization, your purchasing system can keep the buy plan in step with what it already knows: the quarter's goal and budget, the vehicles you are hunting (targets), what is on the market that fits them, and what you have bought, including purchases made somewhere else. Every write goes through the same rule the Buy plan page uses, so the API and the app can never disagree.

Nothing here makes an offer, or touches a seller. Buy planning is your plan; an offer on a listing goes through Remarketing, not this API.

"Planning" has two sides in this API. This page is Buy planning, the dealer's plan. Sell planning, the plan that hands vehicles to Sell, is the Planning API (Sell planning). They are separate products with separate scopes, and an organization can have either or both.

You only ever see your own organization's data. A key acts as your organization itself: there is no delegation, and the audit trail says API · and the key's name.

Scopes and the product switch

Every route needs an organization with Buy planning switched on. Without it, each one answers 403 BUY_PLANNING_NOT_ENABLED, reads included, and nothing is written. It is a switch of its own: Sell planning being on does not turn it on, nor the other way round. Then:

RoutesScope
GET under /api/v1/buy-planning/*buy-planning:read
PUT, POST, PATCH and DELETE under /api/v1/buy-planning/*buy-planning:write

buy-planning:write implies buy-planning:read, and buy-planning:read (beside webhooks) is the scope that lets a key subscribe to the BUY.* webhooks. A key made without picking scopes never gets either. Tick Buy planning (read) or Buy planning (read and write) when you create the key on the Integrations page; the boxes are offered when your organization has Buy planning. GET /api/v1/organization tells you what the key can do: products.buyPlanning and the capabilities buy-planning.read and buy-planning.write. See Your organization and key.

Quick guide

Six steps, from a new key to an approved quarter. Every request carries your key as x-api-key; $BASE is https://staging.app.getcypress.ai while you build.

Make a key and check it. On the Integrations page, create a key with Buy planning (read and write), then ask what it may do:

curl "$BASE/api/v1/organization" -H "x-api-key: $KEY"

Look for products.buyPlanning.enabled: true and buy-planning.write with allowed: true:

{
  "products": {
    "remarketing": { "enabled": true },
    "planning": { "enabled": false, "requestedAt": null },
    "buyPlanning": { "enabled": true, "requestedAt": "2026-09-14T15:20:00.000Z" }
  },
  "capabilities": [
    { "capability": "buy-planning.read", "allowed": true },
    { "capability": "buy-planning.write", "allowed": true }
  ]
}

Read the quarter. current is the quarter in progress (a key like 2026-Q4 works too):

curl "$BASE/api/v1/buy-planning/quarters/current" -H "x-api-key: $KEY"
{
  "periodKey": "2026-Q4", "label": "Q4 2026", "start": "2026-10-01", "end": "2026-12-31", "closed": false,
  "unitsGoal": null, "budget": null, "mandate": null,
  "approval": null,
  "progress": { "bought": 0, "inClosing": 0, "spent": 0, "inClosingAmount": 0, "spentInPlan": 0, "spentOutsidePlan": 0 },
  "digest": "9c4e1a07b2d35f68"
}

A quarter nobody has set yet is an empty one, not a 404. Set its goal, budget and mandate with one PUT. Send unitsGoal and budget every time (null clears one); leave mandate out to keep it:

curl -X PUT "$BASE/api/v1/buy-planning/quarters/current" \
  -H "x-api-key: $KEY" -H "content-type: application/json" \
  -d '{ "unitsGoal": 40, "budget": 900000, "mandate": "USUAL_MIX" }'

It answers the quarter again, with the new digest.

Add a target. A vehicle you want (a make and model, or a vehicle class), how many, and the week you need them by:

curl -X POST "$BASE/api/v1/buy-planning/targets" \
  -H "x-api-key: $KEY" -H "content-type: application/json" \
  -d '{
    "make": "Toyota", "model": "RAV4", "trims": ["XLE"],
    "yearMin": 2021, "yearMax": 2023, "mileageMax": 60000, "states": ["FL", "GA"],
    "quantity": 6, "needBy": "2026-11-27",
    "targetUnitPrice": 25000, "maxUnitPrice": 27000,
    "priority": 1, "track": "BALANCED"
  }'
{
  "id": "clx…", "label": "Toyota RAV4 XLE", "status": "OPEN", "displayStatus": "OPEN", "source": "MANUAL",
  "track": "BALANCED", "priority": 1,
  "make": "Toyota", "model": "RAV4", "trims": ["XLE"], "planningClass": null, "bodyClasses": [], "fuelTypes": [], "conditions": [],
  "yearMin": 2021, "yearMax": 2023, "mileageMin": null, "mileageMax": 60000, "states": ["FL", "GA"],
  "quantity": 6, "needFrom": null, "needBy": "2026-11-29", "needByWeek": "W48", "periodKey": "2026-Q4",
  "targetUnitPrice": 25000, "maxUnitPrice": 27000,
  "bought": 0, "inClosing": 0, "remaining": 6,
  "note": null, "createdAt": "2026-10-11T14:02:00.000Z", "updatedAt": "2026-10-11T14:02:00.000Z"
}

The needBy you sent snapped to the week's Sunday, 2026-11-29 (week 48), as the plan speaks in weeks. A target that names no track takes the plan's default.

Read its matches. The listings on the Marketplace that fit the target right now:

curl "$BASE/api/v1/buy-planning/targets/$TARGET_ID/matches" -H "x-api-key: $KEY"
{
  "targetId": "clx…", "track": "BALANCED", "totalCount": 2,
  "items": [
    {
      "vehicleId": "clx…", "url": "https://app.getcypress.ai/marketplace/clx…",
      "year": 2022, "make": "TOYOTA", "model": "RAV4", "trim": "XLE", "mileage": 31000, "state": "FL",
      "ask": 26400, "isNew": true, "score": 100, "kind": "EXACT", "misses": []
    },
    {
      "vehicleId": "clx…", "url": "https://app.getcypress.ai/marketplace/clx…",
      "year": 2022, "make": "TOYOTA", "model": "RAV4", "trim": "LE", "mileage": 38200, "state": "GA",
      "ask": 24900, "isNew": false, "score": 92, "kind": "NEAR", "misses": ["Trim LE, not XLE"]
    }
  ]
}

To stop seeing one for this target, hide it with POST /api/v1/buy-planning/targets/{id}/matches/{vehicleId}/dismiss. Only a match of the target can be hidden: any other vehicle id answers 404, whether or not such a vehicle exists.

Record a purchase made elsewhere. Bought at an auction, or from another dealer? Record it, so the plan's progress is complete. Read first, then record (see the note below):

curl -X POST "$BASE/api/v1/buy-planning/purchases" \
  -H "x-api-key: $KEY" -H "content-type: application/json" \
  -d '{
    "targetId": "clx…", "purchasedAt": "2026-10-09", "unitPrice": 24000,
    "description": { "year": 2022, "make": "Toyota", "model": "RAV4", "vin": "2T3W1RFV0NW000001", "mileage": 31000 },
    "note": "Bought at the Orlando auction"
  }'
{
  "id": "clx…", "state": "FILLED", "source": "RECORDED", "targetId": "clx…", "targetLabel": "Toyota RAV4 XLE",
  "unitPrice": 24000, "purchasedAt": "2026-10-09T00:00:00.000Z", "periodKey": "2026-Q4",
  "vehicle": { "id": null, "label": "2022 Toyota RAV4" }, "note": "Bought at the Orlando auction"
}

It counts toward the target (bought: 1, remaining: 5) and toward the quarter's progress, which you can read again with step 2.

Approve the quarter. Read the quarter, and send its digest back. The approval is bound to the plan you read:

curl -X POST "$BASE/api/v1/buy-planning/quarters/current/approve" \
  -H "x-api-key: $KEY" -H "content-type: application/json" \
  -d '{ "digest": "3b7d0c91e5a8f204" }'

It answers the quarter, approved:

{
  "periodKey": "2026-Q4", "label": "Q4 2026", "start": "2026-10-01", "end": "2026-12-31", "closed": false,
  "unitsGoal": 40, "budget": 900000, "mandate": "USUAL_MIX",
  "approval": { "approvedAt": "2026-10-11T14:09:00.000Z", "changesSince": [] },
  "progress": { "bought": 1, "inClosing": 0, "spent": 24000, "inClosingAmount": 0, "spentInPlan": 24000, "spentOutsidePlan": 0 },
  "digest": "3b7d0c91e5a8f204"
}

The approved plan is the baseline the app's Review compares what happens against. The plan stays live and editable afterwards: read the quarter again and changesSince lists what differs from the approved plan, one plain-words line each ("Budget $900,000 → $950,000", "Added Chevy Malibu").

Send an Idempotency-Key when you record a purchase

A purchase is a fact, so the same body sent twice without a key is recorded twice. Send an Idempotency-Key (since 1.5.0): a retry with the same key answers the first time's answer and records nothing more. Without one, if your request timed out, read GET /api/v1/buy-planning/purchases and look for it before you send it again. A purchase you recorded by hand can be removed with DELETE /api/v1/buy-planning/purchases/{id}.

The routes

All under /api/v1/buy-planning. Dates are ISO strings, and money is a number in US dollars. Every write takes ?dryRun=true (the same checks and refusals, { "dryRun": true, "changes": … }, nothing written) and an Idempotency-Key (a retry answers the same, once). Since 1.5.0.

RouteScopeDoes
GET /quarters/{periodKey}readThe quarter: label, first and last day, closed, unitsGoal, budget, mandate, its approval (or null), progress and the digest. periodKey is 2026-Q4 or current.
PUT /quarters/{periodKey}writeSets unitsGoal, budget and mandate (null clears one). Answers the quarter.
POST /quarters/{periodKey}/approvewriteBody { "digest" } from the read. Answers the quarter, approved.
GET /targets?periodKey=&status=readThe quarter's targets. periodKey is a quarter, current (the default) or all; status is OPEN, FILLED, PAUSED, CLOSED or ENDED.
POST /targetswriteAdds a target. 201 with the target.
GET /targets/{id}readOne target.
PATCH /targets/{id}write{ "status": "OPEN" | "PAUSED" | "CLOSED" } alone, or the whole target body to edit its fields. Answers the target.
DELETE /targets/{id}writeRemoves a target that has no purchases (409 HAS_PURCHASES otherwise: close it instead).
GET /targets/{id}/matchesreadThe target's live matches: exact first, then near, with the score and the misses.
POST /targets/{id}/matches/{vehicleId}/dismisswriteHides a match from this target. Body {} or { "undo": true } to bring it back. 404 for a vehicle that is not a match of the target.
GET /purchases?periodKey=readThe purchases of a quarter (current by default, or all), newest first.
POST /purchaseswriteRecords a purchase made elsewhere. 201 with the purchase.
PATCH /purchases/{id}write{ "targetId": … } moves it to another target, or to none with null.
DELETE /purchases/{id}writeRemoves a purchase that was recorded by hand.
POST /draftswriteSequoia's draft of a quarter: body { "mandate", "periodKey" }. Writes nothing. Since 1.5.0.
POST /drafts/applywriteKeeps a draft: the quarter's goal, budget and mandate, the plan's markets and the kept targets in one step; "approve": true approves the quarter too. 201. Since 1.5.0.

Another organization's id is 404, exactly like an id that does not exist.

The quarter

FieldMeans
periodKey, label, start, end2026-Q4, Q4 2026, and the first and last day (YYYY-MM-DD).
closedThe quarter's last day has passed, UTC. Its plan no longer changes: see Closed quarters.
unitsGoal, budget, mandatenull when unset. A goal of zero and a budget of $0 read as unset, as in the app. mandate is RESTOCK_FAST, USUAL_MIX, MOST_UNITS or AHEAD_OF_SUPPLY.
approvalnull until approved. Then approvedAt and changesSince, a list of plain-words lines. They are text for people: don't parse them.
progressThe purchases made in the quarter, by their date. bought and spent are the ones that are done; inClosing and inClosingAmount are closings in progress. spentInPlan and spentOutsidePlan split spent by whether a target claimed the purchase (since 1.9.0). These are the app's own numbers: the header's "bought", "in closing", and the Budget card's "spent".
digestWhat an approval binds to. It changes when the goal, the budget, the mandate or a target of the quarter changes.

The PUT takes no note: the note on a quarter is the dealer's, and a key never clears it.

The plan's quarter

GET /plan (MCP: get_buy_plan) carries the quarter as the Buy plan page shows it, in quarter. Its money comes from the same computation as the page and progress above, so the three never disagree:

FieldMeans
spentMoney on vehicles already bought this quarter, in the plan and outside it: the page's "$171,300 of $650,000 spent". budgetPct is measured on it
spentInPlan, spentOutsidePlanspent, split by whether a target claimed the purchase. outsidePlan counts those purchases (done or in closing)
inClosingAmount, inClosingOutsidePlanMoney on closings in progress (not spent yet), and the part of it outside the plan
spentOrInClosing, spentOrInClosingPctspent + inClosingAmount, and its share of the budget: the Plan tab's "Spent or in closing" tile

Changed in 1.9.0: quarter.spent used to add the closings in progress, so it read higher than the page's "spent". That figure is spentOrInClosing now.

Targets

The fields of a target have the names of the body you POST, so a target you read can be edited and sent back to PATCH: the fields that are only read (id, displayStatus, needByWeek, periodKey, bought, inClosing, remaining, createdAt, updatedAt) are ignored. A whole-body PATCH replaces the editable fields, as the app's edit does.

FieldMeans
statusThe dealer's decision, as stored: OPEN, PAUSED or CLOSED.
displayStatusWhat a person reads, worked out once for every screen. FILLED when nothing remains to buy, whatever the stored status; ENDED when its quarter is over with units still short; CLOSED for one the dealer closed; otherwise the stored status. ?status= filters by this.
make, model, trims, planningClass, bodyClasses, fuelTypes, conditions, yearMin, yearMax, mileageMin, mileageMax, statesWhat the target fits. One make and model, or a vehicle class (CAR, SUV, TRUCK, VAN) alone. states empty means the plan's markets.
quantity, needFrom, needBy, needByWeekHow many, and when. A date snaps to its week: needFrom to the Monday, needBy to the Sunday. needByWeek is W48.
periodKeyThe quarter the target counts in: the quarter of its needed-by week's Thursday, the plan's own rule. A week that ends in the next quarter can belong to this one.
targetUnitPrice, maxUnitPriceThe price you aim for, and the most you would pay (at or above the target).
track, priorityAGGRESSIVE (matches from 80%), BALANCED (from 90%) or CONSERVATIVE (exact only); priority 1 is high.
bought, inClosing, remainingThe purchases counting toward it that are done, those with a closing in progress, and the units still to buy (never below zero).
source, label, note, createdAt, updatedAtMANUAL for a target added by hand or here. A label left out is made from the spec.

Matches

Each match carries the listing's public facts: vehicleId, a public Marketplace url, year, make, model, trim, mileage, state and the public ask (null when the listing shows none). Beside them: score (100 is exact), kind (EXACT or NEAR), isNew (first seen in the last three days) and misses, what a near match is not, in plain words. The list is in the app's order: exact first, then by score; within a score, new ones first, then by ask.

A target finds the listings that fit it at its track's floor (80, 90 or exact only), without those of your own organization and the ones you hid. Demo and real organizations are two worlds: a listing reaches a dealer of its own world only.

Purchases

FieldMeans
stateWorked out from the closing every time, never stored. COMMITTED: a closing is in progress. FILLED: bought, because the closing completed or you recorded it. CANCELLED: the closing did not complete, and the purchase counts for nothing.
sourceCYPRESS: it came from a closing on Cypress, and counted toward a target by the app's rule. RECORDED: you recorded it, here or in the app.
targetId, targetLabelThe target it counts toward, or null for a purchase outside the plan. The rule never moves a purchase on its own once you have: a PATCH is final.
unitPrice, purchasedAt, periodKeyThe price, the day, and the quarter it counts in by that day.
vehicleThe vehicle as the app shows it: its Cypress id when it was bought on Cypress (null for one bought elsewhere), and a label.
noteYours, on a purchase you recorded.

A purchase from a Cypress closing follows its closing: it cannot be removed here (409 FROM_CLOSING).

Closed quarters

A quarter whose last day has passed is history, and its plan no longer changes, over the API as in the app. A quarter is over from the first instant after its last day, UTC. closed on the quarter says it, and a target says which quarter it counts in with periodKey.

What answers 409 QUARTER_CLOSED, with nothing written:

CallRefused when
PUT /quarters/{periodKey}the quarter is over (a closed quarter's goal is what Review compares it against)
POST /targetsthe target is needed in a quarter that is over
PATCH /targets/{id}the target is needed in a quarter that is over, for a pause, a reopen, a close and an edit alike, or the edit would move it into one
DELETE /targets/{id}the target is needed in a quarter that is over
{
  "error": "This quarter is closed. A closed quarter is history, so its plan no longer changes.",
  "code": "QUARTER_CLOSED"
}

It is not retryable: waiting does not reopen a quarter. Add the target to a week of a quarter in progress instead. error is one fixed sentence: switch on code.

Purchases are never refused for a closed quarter. A purchase is a fact: it happened. Record one late, against a target of a closed quarter too; move one to a target of a closed quarter; remove one you recorded. A late sync from your purchasing system is natural.

The approval keeps its own codes, as in the app: approving a quarter that is over answers 409 PERIOD_CLOSED. Hiding a match is not a change to the plan, so a closed quarter does not refuse it either.

Approval refusals

POST /quarters/{periodKey}/approve answers 409 with one of three codes. The messages are fixed text; nothing you sent is echoed back.

CodeMeansWhat to do
PLAN_CHANGEDThe digest is not the live plan's: the plan moved since you read it ("The plan changed. Refresh and approve again.").Read the quarter again and approve the new digest.
PERIOD_CLOSEDThe quarter is over ("This quarter is over.").Nothing: it can no longer be approved.
NOTHING_TO_APPROVEThe quarter has no goal, no budget and no target ("Set a goal or add a target first."). A mandate alone is not a plan.Set a goal or add a target, then approve.

What the matches never carry

A match is a listing's public face, and that is all of it, in the reads and in the BUY.TARGET.MATCHED webhook alike. It never carries a seller's name or id, a seller's floor, thresholds or limits, a VIN, or the Cypress staff view that fits a seller's limits to your target. That fit is staff-only and has no door here: a key is a dealer. Planned supply from other sellers reaches you in the app only as an anonymous count, and only when at least two sellers plan it. Your own targets and prices are never shown to a seller either.

Sequoia's draft

Since 1.5.0 the app's Plan my quarter is on the API. POST /drafts answers the starting plan the wizard shows: a unitsGoal, a budget, and three to eight targets, each with its spec, quantity, needBy, prices, track and why, read from your purchases and the market (basis says which, words says it in plain words). Nothing is written.

Keep it with POST /drafts/apply: send the quarter's periodKey, mandate, unitsGoal and budget, and the targets you keep, changed or not (each with the draft's key). A target you add yourself carries "origin": "DEALER". The answer is 201 with the targets made, in the shape of GET /targets. A kept target needed in a quarter that is over answers 409 QUARTER_CLOSED and nothing is written; a target that does not validate answers 422 with issues naming it (targets.2.make). With ?dryRun=true it is all written in a transaction that is rolled back: the same refusals, and { "dryRun": true, "changes": … } with the targets it would keep (without ids). An Idempotency-Key makes a retry safe.

Approving is a person's. "approve": true approves the quarter in the same step for an API key or an app acting for a person. An agent's token that sends it answers 403 PEOPLE_ONLY and nothing is written: an agent keeps the draft, and a person approves the quarter. The same holds for POST /quarters/{periodKey}/approve.

What stays in the app

Sequoia's rail, the forecast (the next weeks), the Market board and the Cypress desk are the app's. So are the Buy at the ask offers: an offer on a listing goes through Remarketing, not this API. How Buy planning works is in the Planning manual: Buy planning.

Webhooks

Your purchasing system does not have to poll. Six events tell it when the plan moves, each with the same target and match objects the reads above return:

EventFires whenbody
BUY.TARGET.MATCHEDA live listing fits an open target for the first time. Once per target and listing, whatever the target's track and notice setting.target (id, label, periodKey) and match, as in Matches
BUY.TARGET.FILLEDA target has nothing left to buy: a closing that fits it starts, a purchase is recorded or moved to it, or its quantity is lowered to what you have bought.target, as GET /targets/{id} returns it
BUY.TARGET.ENDEDA target's quarter closed with units still short, told in the first days after the quarter's last day.target, shortfall and bought
BUY.TARGET.CREATEDA target was added: on the Targets tab, over the API, or kept from Sequoia's draft. Since 1.5.0.target
BUY.TARGET.DUEAn open target's needed-by day came with units still to buy. Once per target and day. Since 1.5.0.target and remaining
BUY.QUARTER.APPROVEDA person approved the quarter, on the quarter bar or with Sequoia's draft kept. Since 1.5.0.quarter: periodKey, approvedAt, approvedBy, unitsGoal, budget, targets

To receive them, register a subscription that lists the events. They are opt-in (a subscription without an eventTypes filter never receives them), Cypress dialect only, and they need the buy-planning:read scope beside webhooks, on an organization with Buy planning:

curl -X POST "$BASE/api/v1/webhooks/subscriptions" \
  -H "x-api-key: $KEY" -H "content-type: application/json" \
  -d '{
    "callback": "https://purchasing.example.com/cypress",
    "eventTypes": ["BUY.TARGET.MATCHED", "BUY.TARGET.FILLED", "BUY.TARGET.ENDED"]
  }'

A key without the scope gets 403 SCOPE_MISSING; an organization without Buy planning, 403 BUY_PLANNING_NOT_ENABLED; a subscription that would use the Manheim names, 422. A new match arrives like this:

{
  "href": "https://app.getcypress.ai/api/v1/buy-planning/targets/clx…/matches",
  "eventType": "BUY.TARGET.MATCHED",
  "resource": "buy",
  "body": {
    "target": { "id": "clx…", "label": "Toyota RAV4 XLE", "periodKey": "2026-Q4" },
    "match": {
      "vehicleId": "clx…", "url": "https://app.getcypress.ai/marketplace/clx…",
      "year": 2022, "make": "TOYOTA", "model": "RAV4", "trim": "XLE", "mileage": 31000, "state": "FL",
      "ask": 26400, "isNew": true, "score": 100, "kind": "EXACT", "misses": []
    }
  },
  "createdOn": "2026-10-11T14:00:03.120Z",
  "relatedResources": []
}

Each event is delivered to an endpoint once, however many times its cause is noticed, and the body is the moment it happened (read href for the target as it is now). The envelope, the signature, the retries, a body example for each event and the rules for subscribing are in Webhooks.

Try it

The integration kit's 6-buy-planning folder walks the whole quick guide in nine requests — check the key, read the quarter, set it, add a target, read its matches, record a purchase made elsewhere, read the quarter again, approve it and subscribe to the buy plan's webhooks — with the target's id and the quarter's digest captured for you. See the Quickstart.