Skip to content

Managing Plants

Once a plant is tracked, these endpoints cover what you can do to it: move it, change its strain, 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/create/from-packages Unpack a package of 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 Writing to Metrc.

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, create/from-packages Everywhere except California
changes-by-location/vegetative Everywhere except California
changegrowthphase 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 inputs you get

Each write has one /inputs endpoint, whatever phase the plant is in. 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

Read the variant matching the phase your plant is in rather than assuming the lists agree. Although the vegetative list offers Mother, POST /v2/plants/changegrowthphase accepts only Vegetative and Flowering.

Promoting immature plants out of a batch has a single GET /v2/plantbatches/promote/inputs.

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.

Most writes have their own eligibility lookup

Metrc decides which plants are eligible for each operation, 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. change-locations and change-strains have none:

eligible = requests.get(
    f"{API}/v2/plants/destroy/source-plants",
    params={**PARAMS, "filter": "label__contains:1A40"},
    headers=HEADERS,
).json()["data"]

Moving and changing strain

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 change strain

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. 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: it is not tied to a selection of plants. 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.

Unpacking plants

The reverse of packaging plants: vegetative plants come back out of a package made by POST /v2/plants/create/packages. A package of immature plants cut from a plant batch cannot be unpacked. Find the package with GET /v2/plants/create/from-packages/source-packages, and the type, strain and location ids on GET /v2/plants/create/from-packages/inputs:

inputs = requests.get(
    f"{API}/v2/plants/create/from-packages/inputs", params=PARAMS, headers=HEADERS
).json()

requests.post(
    f"{API}/v2/plants/create/from-packages",
    params={**PARAMS, "submit": "true"},
    headers=HEADERS,
    json=[{
        "packageId": 12345,
        "plantBatchTypeId": inputs["plantBatchTypes"][0]["id"],
        "plantsCount": 1,
        "strainId": inputs["strains"][0]["id"],
        "locationId": inputs["locations"][0]["id"],
        "plantedDate": "2026-05-14",
        "vegetativeDate": "2026-05-14",
        "actualDate": "2026-05-14",
    }],
)

startingTagId and endingTagId tag the plants from GET /v2/plants/create/from-packages/source-tags. Maine also requires tagTypeId, from inputs["tagTypes"], and Maine and Michigan accept a sublocationId.

Recording waste

Recording waste is not destruction. The plants stay in the grid; only the waste is recorded. There are three endpoints, 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: these endpoints call 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

Read the state back rather than trusting the 200 (why). 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