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.
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:
| Routes | Scope |
|---|---|
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.
| Route | Scope | Does |
|---|---|---|
GET /quarters/{periodKey} | read | The 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} | write | Sets unitsGoal, budget and mandate (null clears one). Answers the quarter. |
POST /quarters/{periodKey}/approve | write | Body { "digest" } from the read. Answers the quarter, approved. |
GET /targets?periodKey=&status= | read | The quarter's targets. periodKey is a quarter, current (the default) or all; status is OPEN, FILLED, PAUSED, CLOSED or ENDED. |
POST /targets | write | Adds a target. 201 with the target. |
GET /targets/{id} | read | One 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} | write | Removes a target that has no purchases (409 HAS_PURCHASES otherwise: close it instead). |
GET /targets/{id}/matches | read | The target's live matches: exact first, then near, with the score and the misses. |
POST /targets/{id}/matches/{vehicleId}/dismiss | write | Hides 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= | read | The purchases of a quarter (current by default, or all), newest first. |
POST /purchases | write | Records 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} | write | Removes a purchase that was recorded by hand. |
POST /drafts | write | Sequoia's draft of a quarter: body { "mandate", "periodKey" }. Writes nothing. Since 1.5.0. |
POST /drafts/apply | write | Keeps 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
| Field | Means |
|---|---|
periodKey, label, start, end | 2026-Q4, Q4 2026, and the first and last day (YYYY-MM-DD). |
closed | The quarter's last day has passed, UTC. Its plan no longer changes: see Closed quarters. |
unitsGoal, budget, mandate | null 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. |
approval | null until approved. Then approvedAt and changesSince, a list of plain-words lines. They are text for people: don't parse them. |
progress | The 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". |
digest | What 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:
| Field | Means |
|---|---|
spent | Money 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, spentOutsidePlan | spent, split by whether a target claimed the purchase. outsidePlan counts those purchases (done or in closing) |
inClosingAmount, inClosingOutsidePlan | Money on closings in progress (not spent yet), and the part of it outside the plan |
spentOrInClosing, spentOrInClosingPct | spent + 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.
| Field | Means |
|---|---|
status | The dealer's decision, as stored: OPEN, PAUSED or CLOSED. |
displayStatus | What 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, states | What 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, needByWeek | How many, and when. A date snaps to its week: needFrom to the Monday, needBy to the Sunday. needByWeek is W48. |
periodKey | The 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, maxUnitPrice | The price you aim for, and the most you would pay (at or above the target). |
track, priority | AGGRESSIVE (matches from 80%), BALANCED (from 90%) or CONSERVATIVE (exact only); priority 1 is high. |
bought, inClosing, remaining | The 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, updatedAt | MANUAL 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
| Field | Means |
|---|---|
state | Worked 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. |
source | CYPRESS: 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, targetLabel | The 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, periodKey | The price, the day, and the quarter it counts in by that day. |
vehicle | The vehicle as the app shows it: its Cypress id when it was bought on Cypress (null for one bought elsewhere), and a label. |
note | Yours, 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:
| Call | Refused when |
|---|---|
PUT /quarters/{periodKey} | the quarter is over (a closed quarter's goal is what Review compares it against) |
POST /targets | the 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.
| Code | Means | What to do |
|---|---|---|
PLAN_CHANGED | The 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_CLOSED | The quarter is over ("This quarter is over."). | Nothing: it can no longer be approved. |
NOTHING_TO_APPROVE | The 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:
| Event | Fires when | body |
|---|---|---|
BUY.TARGET.MATCHED | A 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.FILLED | A 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.ENDED | A 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.CREATED | A target was added: on the Targets tab, over the API, or kept from Sequoia's draft. Since 1.5.0. | target |
BUY.TARGET.DUE | An 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.APPROVED | A 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.
Planning API (Sell planning)
Push your fleet into Sell planning, keep it current, read the plan, drive it and plan the quarter — the same rules the app uses, since API 1.3.0. Closed quarters since 1.4.0; the strategy, goals, the quarter's approval and Sequoia's schedule since 1.5.0.
Sales channels
Where a vehicle sells — public marketplace, branded storefront, direct links — and how the API selects them.