Skip to content

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