Skip to content

Managing Plant Batches

Creating Plant Batches covers getting plants into the system. This page covers what you can do to a plant batch once it exists: move it, restrain it, rename it, split it, adjust its count, destroy plants from it, and package it.

Endpoint What it does
POST /v2/plantbatches/change-locations Move batches to a different location
POST /v2/plantbatches/change-strains Reassign batches to a different strain
POST /v2/plantbatches/rename Rename batches
POST /v2/plantbatches/adjust Correct a batch's plant count
POST /v2/plantbatches/split Split plants out into a new batch
POST /v2/plantbatches/create/from-plantbatches Create a new batch from an existing one
POST /v2/plantbatches/destroy Destroy immature plants from a batch
POST /v2/plantbatches/create/packages Package immature plants

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

Metrc's own interface differs by state, and these endpoints mirror it exactly.

Endpoint Available in
rename Everywhere except California
adjust Everywhere except Missouri
replace-tags California only

Calling one where the state does not offer it returns whatever Metrc returns for an unknown path. The rest of the endpoints on this page are available everywhere.

Every write has a companion lookup

Metrc decides which batches are eligible for each operation on its own server, and the eligible set is different for each one. A batch that can be renamed is not necessarily one that can be adjusted.

That is why each write has its own source-plantbatches endpoint rather than sharing GET /v2/plantbatches/active:

import requests

API = "https://api.trackandtrace.tools"
HEADERS = {"Authorization": f"Bearer {ACCESS_TOKEN}"}
PARAMS = {"licenseNumber": "CUL00001"}

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

print([b["name"] for b in eligible])

Use the general listing to browse, and the source-plantbatches endpoint to confirm a batch is eligible for the specific write you are about to make.

Every write has an inputs endpoint

GET /v2/plantbatches/<operation>/inputs returns the option lists that operation needs, read from the same Metrc form the write posts to. This is the only reliable source for ids like wasteReasonId and adjustmentReasonId, which are state-specific and appear on no record.

inputs = requests.get(
    f"{API}/v2/plantbatches/destroy/inputs", params=PARAMS, headers=HEADERS
).json()

for reason in inputs["actionReasons"]:
    print(reason["id"], reason["name"], reason.get("requiresWasteWeight"))

Moving and restraining

Both take a list, so you can move or restrain many batches in one call.

requests.post(
    f"{API}/v2/plantbatches/change-locations",
    params={**PARAMS, "submit": "true"},
    headers=HEADERS,
    json=[{"id": 8891, "locationId": 12345, "actualDate": "2026-05-14"}],
)

sublocationId is accepted in Maine and Michigan, the only states that model sublocations. Omit it elsewhere.

Changing strain is the same shape, and takes no date:

requests.post(
    f"{API}/v2/plantbatches/change-strains",
    params={**PARAMS, "submit": "true"},
    headers=HEADERS,
    json=[{"id": 8891, "strainId": 737}],
)

Both change fields you may be searching on. GET /v2/plantbatches/active cannot filter by id, so if you find batches by name or strain, do that first and keep the ids.

Destroying immature plants

Four of the fields are required only for some waste reasons. Metrc reveals wasteWeight, wasteUnitOfMeasureId, plantWasteMethodId and materialMixed only when the chosen reason demands a weight, which is why they are not required by the schema. Read requiresWasteWeight off the inputs response to know which case you are in.

requests.post(
    f"{API}/v2/plantbatches/destroy",
    params={**PARAMS, "submit": "true"},
    headers=HEADERS,
    json=[{
        "id": 8891,
        "countToDestroy": 5,
        "wasteReasonId": 3,
        "reasonNote": "Failed phenotype",
        "actualDate": "2026-05-14",
    }],
)

Adjusting a count

newQuantity is what the batch should hold afterwards, and adjustmentQuantity is the difference applied. Metrc's own form collects both, so send both.

requests.post(
    f"{API}/v2/plantbatches/adjust",
    params={**PARAMS, "submit": "true"},
    headers=HEADERS,
    json=[{
        "plantBatchId": 8891,
        "adjustmentQuantity": -5,
        "newQuantity": 95,
        "adjustmentReasonId": 12,
        "reasonNote": "Miscount at intake",
        "adjustmentDate": "2026-05-14",
    }],
)

Splitting and creating

Both create a new batch, so both need to name it, and how a new batch is named differs by state. California identifies a plant batch by tag, so send tagId. Maine, Michigan and Missouri use a free-text name, so send plantBatchName.

This is unlike POST /v2/plantbatches/create/from-packages, where a single plantBatchName string serves every state.

requests.post(
    f"{API}/v2/plantbatches/split",
    params={**PARAMS, "submit": "true"},
    headers=HEADERS,
    json=[{
        "sourcePlantBatchId": 8891,
        "plantsCount": 10,
        "strainId": 737,
        "locationId": 12345,
        "plantBatchName": "Spring Batch A-2",
        "actualDate": "2026-05-14",
    }],
)

Splitting consumes a tag in California. Tags cannot be replenished through the API, so plan for that before scripting a loop.

A plant batch name can never be reused

Metrc reserves a plant batch name permanently. Once a name has been used it is refused forever, even after that batch has been emptied and has left the active grid:

A Plant Batch with name "Spring Batch A-2" already exists and cannot be used again.

So you cannot pick a name by checking what is free. GET /v2/plantbatches/active will not show you the reserved names, and neither will GET /v2/plantbatches/inactive, because the reservation outlives whatever either listing holds.

Generate names that are unique by construction instead, for example by including a timestamp:

from datetime import datetime, timezone

stamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
name = f"Spring Batch-{stamp}"

Packaging immature plants

requests.post(
    f"{API}/v2/plantbatches/create/packages",
    params={**PARAMS, "submit": "true"},
    headers=HEADERS,
    json=[{
        "plantBatchId": 8891,
        "count": 25,
        "itemId": 7316223,
        "tagId": 442199,
        "locationId": 12345,
        "actualDate": "2026-05-14",
    }],
)

Three endpoints back this one, because the ids come from three places:

Field Source
plantBatchId GET /v2/plantbatches/create/packages/source-plantbatches
itemId GET /v2/plantbatches/create/packages/source-items
tagId GET /v2/plantbatches/create/packages/source-tags
locationId GET /v2/plantbatches/create/packages/inputs

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.

isFromMotherPlant is what marks a package as cut from a mother plant batch rather than an ordinary immature batch. It is accepted in California.

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:

batches = requests.get(
    f"{API}/v2/plantbatches/active",
    params={**PARAMS, "filter": "name__eq:Spring Batch A"},
    headers=HEADERS,
).json()["data"]

print(batches[0]["untrackedCount"], batches[0]["locationName"])

GET /v2/plantbatches/active does not support filtering by id, so match on name or strainName.

Next Steps