Managing Plants¶
Once a plant is tracked, these endpoints cover what you can do to it: move it, restrain it, take clones off it, retag it, package it, and destroy it.
| Endpoint | What it does |
|---|---|
POST /v2/plants/change-locations | Move plants to a different location |
POST /v2/plants/change-strains | Reassign plants to a different strain |
POST /v2/plants/changes-by-location/flowering | Page-level location and growth phase changes |
POST /v2/plants/changes-by-location/vegetative | The same, for vegetative plants |
POST /v2/plants/create/plantings | Take clones off a plant into a new plant batch |
POST /v2/plants/replace-tags | Move plants onto replacement tags |
POST /v2/plants/create/packages | Package vegetative plants |
POST /v2/plants/assign-tags | Assign a run of tags to untagged plants |
POST /v2/plants/destroy | Destroy plants |
POST /v2/plants/merge | Merge one plant group into another |
POST /v2/plants/split | Split a plant group in two |
Like every mutation in this API, these are dry runs by default. Add submit=true to actually write to Metrc. See Getting Started.
Not every endpoint exists in every state¶
These mirror Metrc's own interface, which differs by state.
| Endpoint | Available in |
|---|---|
merge, split | Maine only |
create/packages, assign-tags | Everywhere except California |
changes-by-location/vegetative | Everywhere except California |
California has no vegetative plant grid and no mother plant grid, which is why the vegetative-only operations are absent there.
Growth phase decides which form you get¶
Metrc opens most of these modals for a named growth phase, and the reference data it serves is the same whichever phase you pick. So each write has one /inputs endpoint rather than three.
Changing growth phase is the exception, and it has three:
| Endpoint | Offers |
|---|---|
GET /v2/plants/changegrowthphase/inputs/vegetative | Vegetative, Flowering, Mother |
GET /v2/plants/changegrowthphase/inputs/flowering | Vegetative, Flowering |
GET /v2/plants/changegrowthphase/inputs/mother | Vegetative, Flowering |
Only a vegetative plant may become a Mother, so read the variant matching the phase your plant is in rather than assuming the lists agree.
Promoting immature plants out of a batch has a single GET /v2/plantbatches/promote/inputs, because Metrc opens that form without naming a phase.
Every write has an inputs endpoint¶
import requests
API = "https://api.trackandtrace.tools"
HEADERS = {"Authorization": f"Bearer {ACCESS_TOKEN}"}
PARAMS = {"licenseNumber": "CUL00001"}
inputs = requests.get(
f"{API}/v2/plants/destroy/inputs", params=PARAMS, headers=HEADERS
).json()
for reason in inputs["actionReasons"]:
print(reason["id"], reason["name"], reason.get("requiresWasteWeight"))
This is the only reliable source for ids like wasteReasonId, which are state-specific and appear on no plant record.
Every write has its own eligibility lookup¶
Metrc decides which plants are eligible for each operation on its own server, and the eligible set differs per operation. Use the source-plants endpoint belonging to the write you are about to make, not the general plant listing:
eligible = requests.get(
f"{API}/v2/plants/destroy/source-plants",
params={**PARAMS, "filter": "label__contains:1A40"},
headers=HEADERS,
).json()["data"]
Moving and restraining¶
requests.post(
f"{API}/v2/plants/change-locations",
params={**PARAMS, "submit": "true"},
headers=HEADERS,
json=[{"id": 90210, "locationId": 12345, "actualDate": "2026-05-14"}],
)
sublocationId is accepted in Maine and Michigan only.
Changing strain takes no date:
requests.post(
f"{API}/v2/plants/change-strains",
params={**PARAMS, "submit": "true"},
headers=HEADERS,
json=[{"id": 90210, "strainId": 737}],
)
A harvested plant cannot be restrained¶
A manicure takes product off a plant and leaves it flowering, so a plant that has already given a harvest sits in GET /v2/plants/flowering looking like any other. Metrc refuses a strain change on it:
There is no eligibility endpoint to ask, because the change-strains form carries no lookup. Read harvestCount off the plant instead, and only send plants where it is 0:
plants = requests.get(
f"{API}/v2/plants/flowering", params=PARAMS, headers=HEADERS
).json()["data"]
restrainable = [p for p in plants if p["harvestCount"] == 0]
Moving a harvested plant is still allowed. This restriction is specific to changing its strain.
Changes by location¶
This one is unusual: Metrc's own form is page-level, not driven by a grid selection. Every change column is optional, so send only what changes.
requests.post(
f"{API}/v2/plants/changes-by-location/flowering",
params={**PARAMS, "submit": "true"},
headers=HEADERS,
json=[{
"id": 90210,
"changeDate": "2026-05-14",
"newLocationId": 12345,
"newGrowthPhase": "Flowering",
}],
)
Destroying¶
wasteWeight, wasteUnitOfMeasureId, plantWasteMethodId and materialMixed are required only for waste reasons that demand a weight.
A reason carries two flags, not one. requiresMatureWasteWeight governs tracked plants and requiresImmatureWasteWeight governs plants inside a plant batch. Read the one matching what you are destroying:
inputs = requests.get(
f"{API}/v2/plants/destroy/inputs", params=PARAMS, headers=HEADERS
).json()
for reason in inputs["actionReasons"]:
print(reason["name"], reason["requiresMatureWasteWeight"])
In some states every reason requires a weight for a tracked plant, so there may be no weight-free option. Omitting the weight is not rejected: Metrc answers 200 and does not perform the destruction, so always read the plant back.
requests.post(
f"{API}/v2/plants/destroy",
params={**PARAMS, "submit": "true"},
headers=HEADERS,
json=[{
"id": 90210,
"wasteReasonId": 3,
"reasonNote": "Failed phenotype",
"actualDate": "2026-05-14",
}],
)
countToDestroy is carried by Maine alone. Everywhere else the whole plant goes.
Packaging plants¶
Packages are cut from a list of plants, so the payload nests them under ingredients:
requests.post(
f"{API}/v2/plants/create/packages",
params={**PARAMS, "submit": "true"},
headers=HEADERS,
json=[{
"tagId": 442199,
"itemId": 7316223,
"locationId": 12345,
"ingredients": [{"plantId": 90210}, {"plantId": 90211}],
"actualDate": "2026-05-14",
}],
)
quantity inside an ingredient is carried by Maine alone.
isDonation, isTradeSample and isFromMotherPlant are checkboxes: send true to set one and omit it otherwise. Sending false is not the same as omitting it, because Metrc's own form omits an unchecked box entirely.
Recording waste¶
Recording waste is not destruction. The plants stay in the grid; only the waste is recorded. Metrc offers three forms, all posting to the same endpoint and differing in what the waste is attributed to:
| Endpoint | Attributed to |
|---|---|
POST /v2/plants/waste/record | named plants |
POST /v2/plantbatches/waste/record | a plant batch |
POST /v2/plants/waste/record-by-location | every plant in a location |
Every waste field is required here, unlike the destroy endpoints where the four waste fields are revealed only for reasons that demand a weight.
Note the note field: this form calls it wasteReasonNote, where destroy calls the same idea reasonNote.
requests.post(
f"{API}/v2/plants/waste/record",
params={**PARAMS, "submit": "true"},
headers=HEADERS,
json=[{
"plants": [{"id": 90210}],
"plantWasteMethodId": 1,
"materialMixed": "Soil",
"wasteWeight": 1.5,
"wasteUnitOfMeasureId": 4,
"wasteReasonId": 22,
"wasteReasonNote": "Routine pruning",
"actualDate": "2026-05-14",
}],
)
Reading waste back¶
waste = requests.get(
f"{API}/v2/plants/waste", params=PARAMS, headers=HEADERS
).json()["data"]
plants = requests.get(
f"{API}/v2/plants/waste/plants",
params={**PARAMS, "plantWasteId": waste[0]["id"]},
headers=HEADERS,
).json()["data"]
The sub-resources are addressed by plantWasteId, not id. The same shape applies to additives, addressed by plantAdditiveId:
| Endpoint | Returns |
|---|---|
GET /v2/plants/waste | every waste record |
GET /v2/plants/waste/plants | the plants in one record |
GET /v2/plants/waste/packages | packages produced by one record |
GET /v2/plants/additives | every additive application |
GET /v2/plants/additives/plants | the plants one application covered |
GET /v2/plants/additives/active-ingredients | its declared active ingredients |
Recording an additive application is not yet available through the API; the read endpoints above are.
Assigning a run of tags¶
startingTag and endingTag bound a contiguous run, and the plants in plants consume it in order. The run must be exactly as long as that list.
requests.post(
f"{API}/v2/plants/assign-tags",
params={**PARAMS, "submit": "true"},
headers=HEADERS,
json=[{
"startingTag": 442199,
"endingTag": 442201,
"plants": [{"id": 90210}, {"id": 90211}, {"id": 90212}],
"actualDate": "2026-05-14",
}],
)
Verifying the result¶
Metrc answers a write it silently discarded with the same 200 as one it applied, so read the state back rather than trusting the status. Unlike plant batches, a plant can be fetched by id:
plant = requests.get(
f"{API}/v2/plants/flowering/90210", params=PARAMS, headers=HEADERS
).json()
print(plant["locationName"], plant["strainName"])
That matters after a tag replacement in particular: the plant keeps its id, so reading by id is the only lookup the write does not move.
Next Steps¶
- Get plants into the system with Creating Plant Batches.
- Work with plant batches in Managing Plant Batches.
- Harvest what you grow with The Harvest Lifecycle.