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:
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¶
- Get plants into the system with Creating Plant Batches.
- Move plants through their lifecycle with The Harvest Lifecycle.
- Check what your account may do first with Permissions.