Skip to content

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:

Cannot change the Plant Strain because the Plant has been Harvested.

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