The Harvest Lifecycle¶
A harvest starts when plants are cut, accumulates waste and packages while it dries, and ends when it is finished. Every step in that arc has an endpoint.
| Step | Endpoint | Result |
|---|---|---|
| Create | POST /v2/harvests/create | Flowering plants are consumed, a harvest appears |
| Manicure | POST /v2/harvests/manicure | Weight comes off a living plant, a harvest appears |
| Rename | POST /v2/harvests/rename | The harvest's name changes |
| Move | POST /v2/harvests/change-locations | The harvest's drying location changes |
| Record waste | POST /v2/harvests/waste | Current weight drops by the waste amount |
| Undo waste | POST /v2/harvests/waste/discontinue | A waste entry is reversed |
| Package | POST /v2/harvests/create/packages | Harvest weight becomes tagged packages |
| Finish | POST /v2/harvests/finish | The harvest closes out and moves to Inactive |
| Reopen | POST /v2/harvests/unfinish | A finished harvest returns to Harvested |
Like every mutation in this API, these are dry runs by default. Add submit=true to actually write to Metrc — see Getting Started.
Two ways to start a harvest¶
A harvest record can be opened either way, and everything below applies to both. The difference is what happens to the plant, and which plants are eligible:
POST /v2/harvests/create | POST /v2/harvests/manicure | |
|---|---|---|
| The plant | is consumed | survives, in the same growth phase |
| Eligible phases | Flowering only | Flowering and Vegetative |
| Repeatable on one plant | no | yes |
Only flowering plants can be harvested
Metrc offers Harvest on the Flowering tab alone, in every state. To harvest a vegetative plant, advance it with POST /v2/plants/change/growthphases first.
Manicuring is the more permissive of the two: Metrc exposes a separate manicure action per growth phase it allows, and of the states surveyed only California is flowering-only. No state allows manicuring a mother plant.
The request body is the same either way, and so is the reply from GET /v2/harvests/manicure/inputs — Metrc infers the phase from the plant ids you send. The growthPhase that inputs response echoes back is always Flowering and is not a limit on what you may manicure.
The payload for both takes one entry per plant:
requests.post(
f"{API}/v2/harvests/manicure",
params={"licenseNumber": "CUL00001", "submit": "true"},
headers=HEADERS,
json=[
{
"id": 28646, # a flowering OR vegetative plant
"harvestName": "Batch-A-Trim",
"weight": 12.5,
"unitOfWeightId": 4,
"dryingLocationId": 505,
"actualDate": "2026-09-17",
}
],
).raise_for_status()
Both draw their dryingLocationId and unitOfWeightId from an inputs endpoint — GET /v2/harvests/create/inputs and GET /v2/harvests/manicure/inputs respectively. The rest of this page picks up once the harvest exists.
Permissions
Harvest operations sit under the plants view. Metrc grants them per tab, so the harvest and manicure actions are qualified by the tab they appear on:
permissions = requests.get(
f"{API}/v2/permissions",
params={"licenseNumber": "CUL00001", "view": "plants"},
headers=HEADERS,
).json()
# Harvesting lives on the flowering tab and nowhere else.
assert "plants/plantsflowering:harvest_flowering_plants" in permissions
# Manicuring has one action per eligible phase.
assert "plants/plantsflowering:manicure_flowering_plant" in permissions
assert "plants/plantsvegetative:manicure_vegetative_plant" in permissions
# Everything after the harvest exists is granted on the harvested tab.
assert "plants/harvested:report_waste" in permissions
The tab qualifier is why there is no single plants:harvest grant — see the view/tab/action format.
See Permissions.
Reading a harvest¶
Three listings cover the lifecycle stages:
import requests
API = "https://api.trackandtrace.tools"
HEADERS = {"Authorization": f"Bearer {ACCESS_TOKEN}"}
PARAMS = {"licenseNumber": "CUL00001"}
harvests = requests.get(
f"{API}/v2/harvests/active", params=PARAMS, headers=HEADERS
).json()["data"]
harvest = harvests[0]
print(harvest["id"], harvest["name"], harvest["currentWeight"],
harvest["unitOfWeightAbbreviation"])
/v2/harvests/onhold and /v2/harvests/inactive return the same shape for held and finished harvests.
Four sub-resources hang off a single harvest, all keyed on harvestId:
| Endpoint | Returns |
|---|---|
GET /v2/harvests/plants | The plants that went into the harvest |
GET /v2/harvests/packages | The packages taken out of it |
GET /v2/harvests/waste | The waste recorded against it |
GET /v2/harvests/labresults | Lab test results for it |
GET /v2/harvests/history | Every change to it |
waste = requests.get(
f"{API}/v2/harvests/waste",
params={**PARAMS, "harvestId": harvest["id"]},
headers=HEADERS,
).json()["data"]
Recording waste¶
Waste reduces the harvest's current weight. Fetch the waste types first — the list is per-license, and only entries with forHarvests: true can be used here:
inputs = requests.get(
f"{API}/v2/harvests/waste/inputs", params=PARAMS, headers=HEADERS
).json()
waste_types = [w for w in inputs["wasteTypes"] if w["forHarvests"]]
The unit is unitOfWeightId, not unitOfMeasureId. The harvest record already carries the right one, so read it off the harvest rather than guessing:
res = requests.post(
f"{API}/v2/harvests/waste",
params={**PARAMS, "submit": "true"},
headers=HEADERS,
json=[
{
"id": harvest["id"],
"wasteWeight": 12.5,
"unitOfWeightId": harvest["unitOfWeightId"],
"wasteTypeId": waste_types[0]["id"],
"actualDate": "2026-05-14",
}
],
)
res.raise_for_status()
Metrc confirms unusually large waste amounts
Metrc's own UI shows a confirmation checkbox when the reported weight is large relative to the harvest. That check is client-side and has no API equivalent, so the API will not warn you — but Metrc may still reject the write server-side.
Undoing a waste entry¶
POST /v2/harvests/waste/discontinue takes the waste entry's id, not the harvest's. This is the easiest thing on this page to get wrong: both are integers, and passing the wrong one reverses an unrelated entry or fails outright.
Read the id from /v2/harvests/waste:
waste = requests.get(
f"{API}/v2/harvests/waste",
params={**PARAMS, "harvestId": harvest["id"]},
headers=HEADERS,
).json()["data"]
target = waste[0]
requests.post(
f"{API}/v2/harvests/waste/discontinue",
params={**PARAMS, "submit": "true"},
headers=HEADERS,
json={"id": target["id"]}, # the waste entry's id
).raise_for_status()
Discontinued entries disappear from the listing. Pass includeDiscontinued=true to see them again:
all_waste = requests.get(
f"{API}/v2/harvests/waste",
params={**PARAMS, "harvestId": harvest["id"], "includeDiscontinued": "true"},
headers=HEADERS,
).json()["data"]
assert any(w["isArchived"] for w in all_waste)
GET /v2/harvests/waste/history is also keyed on harvestWasteId rather than the harvest id.
Creating packages from a harvest¶
This is the heaviest payload on this page. Four lookups feed it, and each returns Metrc's own eligible set — the general listings are not a substitute, because Metrc filters eligibility server-side:
| Field | Where it comes from |
|---|---|
tagId | GET /v2/harvests/create/packages/source-tags |
itemId | GET /v2/harvests/create/packages/source-items |
ingredients[].harvestId | GET /v2/harvests/create/packages/source-harvests |
unitOfMeasureId, locationId, remediationMethodId | GET /v2/harvests/create/packages/inputs |
inputs = requests.get(
f"{API}/v2/harvests/create/packages/inputs", params=PARAMS, headers=HEADERS
).json()
tags = requests.get(
f"{API}/v2/harvests/create/packages/source-tags", params=PARAMS, headers=HEADERS
).json()["data"]
items = requests.get(
f"{API}/v2/harvests/create/packages/source-items", params=PARAMS, headers=HEADERS
).json()["data"]
sources = requests.get(
f"{API}/v2/harvests/create/packages/source-harvests", params=PARAMS, headers=HEADERS
).json()["data"]
payload = {
"tagId": tags[0]["id"],
"itemId": items[0]["id"],
"unitOfMeasureId": inputs["unitsOfMeasure"][0]["id"],
"actualDate": "2026-05-14",
"ingredients": [
{"harvestId": sources[0]["id"], "quantity": 100.5},
],
}
locations = inputs.get("locations") or []
if locations:
payload["locationId"] = locations[0]["id"]
requests.post(
f"{API}/v2/harvests/create/packages",
params={**PARAMS, "submit": "true"},
headers=HEADERS,
json=[payload],
).raise_for_status()
ingredients[].unitOfMeasureId can be omitted — Metrc applies the package's own unit to every ingredient row, and the API fills it in to match.
Fields that vary by state¶
The request schema is a union across states. None of these change the payload's shape; they are fields that exist in some states and not others.
| Field | Behavior |
|---|---|
locationId | Required in most states. inputs["locations"] is empty where it does not apply. |
sublocationId | Only used by states that model sublocations. |
remediateProduct, remediationMethodId, remediationDate, remediationSteps | Only where the state offers remediation on package creation. inputs["remediationMethods"] is empty otherwise. |
expirationDate, sellByDate, useByDate | Required only where the selected item's configuration demands it. |
isDonation, isTradeSample, productRequiresRemediation | Only applicable in some states. Do not send them at all where they do not apply — sending false is not the same as omitting. |
As with creating packages, the reliable test is the inputs response: if the list backing a field comes back empty or null, that field does not apply to the license you are writing to.
Renaming, moving, finishing¶
The four single-purpose writes all take an array and all key on the harvest's id.
# Rename
requests.post(f"{API}/v2/harvests/rename",
params={**PARAMS, "submit": "true"}, headers=HEADERS,
json=[{"id": harvest["id"], "name": "2026-05-14-Drying Room A-H"}])
# Move to another drying location
locations = requests.get(
f"{API}/v2/harvests/change-locations/inputs", params=PARAMS, headers=HEADERS
).json()["locations"]
requests.post(f"{API}/v2/harvests/change-locations",
params={**PARAMS, "submit": "true"}, headers=HEADERS,
json=[{"id": harvest["id"],
"locationId": locations[0]["id"],
"actualDate": "2026-05-14"}])
# Finish, and reopen
requests.post(f"{API}/v2/harvests/finish",
params={**PARAMS, "submit": "true"}, headers=HEADERS,
json=[{"id": harvest["id"], "actualDate": "2026-05-14"}])
requests.post(f"{API}/v2/harvests/unfinish",
params={**PARAMS, "submit": "true"}, headers=HEADERS,
json=[{"id": harvest["id"]}])
A finished harvest leaves /v2/harvests/active and appears in /v2/harvests/inactive. Unfinishing reverses that.
Scheduling harvests — Missouri only¶
Missouri's Metrc build has a harvest schedules page; no other state does. These endpoints exist for every license, but only Missouri will serve them.
locations = requests.get(
f"{API}/v2/harvests/schedules/create/inputs", params=PARAMS, headers=HEADERS
).json()["locations"]
requests.post(f"{API}/v2/harvests/schedules/create",
params={**PARAMS, "submit": "true"}, headers=HEADERS,
json=[{"locationId": locations[0]["id"],
"scheduledDate": "2026-05-14"}])
schedules = requests.get(
f"{API}/v2/harvests/schedules", params=PARAMS, headers=HEADERS
).json()["data"]
requests.post(f"{API}/v2/harvests/schedules/void",
params={**PARAMS, "submit": "true"}, headers=HEADERS,
json={"id": schedules[0]["id"]})
Metrc rejects dates in the past.
Next Steps¶
- Creating Packages — packaging from existing packages rather than from a harvest
- Supercollections — pull a harvest and its plants, packages and history in one request
- Reports and Spreadsheet Sync — harvest data into a spreadsheet