Managing Packages¶
Creating Packages covers cutting a new package. This page covers what you can do to a package once it exists: move it, change its item, change which lab tests it requires, note it, adjust it, finish and unfinish it, return quantity to the package it was cut from, remediate it, flag it as a trade sample or for donation, and discontinue it.
| Endpoint | What it does |
|---|---|
POST /v2/packages/change-locations | Move packages to a different location |
POST /v2/packages/change-items | Reassign packages to a different item |
POST /v2/packages/change-required-labtest-batches | Replace the lab test batches packages must pass |
POST /v2/packages/notes | Add a note to packages |
POST /v2/packages/adjust | Correct a package's quantity |
POST /v2/packages/finish | Finish packages |
POST /v2/packages/unfinish | Return finished packages to active |
POST /v2/packages/return | Return quantity from a package to the package it was cut from |
POST /v2/packages/remediate | Remediate packages that failed lab testing |
POST /v2/packages/discontinue | Discontinue one package |
POST /v2/packages/trade-sample/flag, /unflag | Mark or clear packages as trade samples |
POST /v2/packages/donation/flag, /unflag | Mark or clear packages for donation |
Like every mutation in this API, these are dry runs by default. Add submit=true to actually write to Metrc. See Getting Started.
Add applyDynamicValidation=true to have T3 check the rules below before Metrc does: an adjusted package's item cannot change, and only a sample package's required lab test batches can.
Every write has a companion lookup¶
Metrc decides which packages are eligible for each operation on its own server, and the eligible set differs per operation. Use the general listing to browse, and the write's own lookup to confirm a package is eligible:
| Write | Packages | Other ids |
|---|---|---|
change-locations | GET /v2/packages/change-locations/source-packages | locationId: GET /v2/packages/change-locations/inputs |
change-items | GET /v2/packages/change-items/source-packages | itemId: GET /v2/packages/change-items/source-items |
change-required-labtest-batches | GET /v2/packages/active | batch ids: GET /v2/packages/change-required-labtest-batches/inputs |
notes | GET /v2/packages/notes/source-packages | none |
adjust | GET /v2/packages/adjust/source-packages | reasons and units: GET /v2/packages/adjust/inputs |
finish | GET /v2/packages/active | none |
unfinish | GET /v2/packages/inactive | none |
return | GET /v2/packages/active | source package: GET /v2/packages/return/inputs?packageId= |
remediate | GET /v2/packages/remediate/source-packages | methods: GET /v2/packages/remediate/inputs |
discontinue | GET /v2/packages/active | none |
trade-sample/flag, /unflag | GET /v2/packages/trade-sample/flag/source-packages, /unflag/source-packages | none |
donation/flag, /unflag | GET /v2/packages/donation/flag/source-packages, /unflag/source-packages | none |
Each write also has an /inputs endpoint read from the same Metrc form, for example GET /v2/packages/notes/inputs. Metrc offers some forms only to some license types; their /inputs endpoint answers 403 where the license cannot open the form.
import requests
API = "https://api.trackandtrace.tools"
HEADERS = {"Authorization": f"Bearer {ACCESS_TOKEN}"}
PARAMS = {"licenseNumber": "CUL00001"}
eligible = requests.get(
f"{API}/v2/packages/change-locations/source-packages",
params={**PARAMS, "filter": "label__contains:0003"},
headers=HEADERS,
).json()["data"]
print([p["label"] for p in eligible])
Moving¶
locations = requests.get(
f"{API}/v2/packages/change-locations/inputs", params=PARAMS, headers=HEADERS
).json()["locations"]
requests.post(
f"{API}/v2/packages/change-locations",
params={**PARAMS, "submit": "true"},
headers=HEADERS,
json=[{"id": 12345, "locationId": locations[0]["id"], "actualDate": "2026-05-14"}],
)
Metrc's form also sends the package's current location. T3 fills that in when you omit currentLocationId, so you only need it if you have it. sublocationId and currentSublocationId are accepted in Maine and Michigan only.
Changing the item¶
Metrc refuses to change the item of a package that has been adjusted, even though the change-items lookup still lists it. Change the item first.
items = requests.get(
f"{API}/v2/packages/change-items/source-items",
params={**PARAMS, "filter": "name__contains:Flower"},
headers=HEADERS,
).json()["data"]
requests.post(
f"{API}/v2/packages/change-items",
params={**PARAMS, "submit": "true"},
headers=HEADERS,
json=[{"id": 12345, "itemId": items[0]["id"]}],
)
Required lab test batches¶
Only a testing package, one created with POST /v2/packages/create/testing, can have its required batches changed. Metrc refuses any other package.
The list you send replaces the package's set. Read the current set first. Name the package on the inputs call too: Metrc lists no batches without one.
current = requests.get(
f"{API}/v2/packages/required-labtest-batches",
params={**PARAMS, "packageId": 12345},
headers=HEADERS,
).json()["data"]
batches = requests.get(
f"{API}/v2/packages/change-required-labtest-batches/inputs",
params={**PARAMS, "packageId": 12345},
headers=HEADERS,
).json()["labTestBatches"]
wanted = {row["labTestBatchId"] for row in current} | {batches[0]["id"]}
requests.post(
f"{API}/v2/packages/change-required-labtest-batches",
params={**PARAMS, "submit": "true"},
headers=HEADERS,
json=[{"id": 12345, "requiredLabTestBatches": sorted(wanted)}],
)
GET /v2/packages/labresult-batches takes the same filters Metrc's package page uses: requiredOnly=true, selectedOnly=true or voluntaryOnly=true, one at a time.
Adjusting and finishing¶
inputs = requests.get(
f"{API}/v2/packages/adjust/inputs", params=PARAMS, headers=HEADERS
).json()
requests.post(
f"{API}/v2/packages/adjust",
params={**PARAMS, "submit": "true"},
headers=HEADERS,
json=[
{
"id": 12345,
"adjustmentQuantity": -2.5,
"adjustmentUnitOfMeasureId": inputs["unitsOfMeasure"][0]["id"],
"newQuantity": 0,
"adjustmentReasonId": inputs["actionReasons"][0]["id"],
"adjustmentDate": "2026-05-14",
"finishDate": "2026-05-14",
}
],
)
Sending finishDate also finishes the package on that date. Leave it out to keep the package active.
To finish a package without adjusting it:
requests.post(
f"{API}/v2/packages/finish",
params={**PARAMS, "submit": "true"},
headers=HEADERS,
json=[{"id": 12345, "actualDate": "2026-05-14"}],
)
POST /v2/packages/unfinish takes [{"id": 12345}] and returns it to active.
Returning quantity to the source¶
A package cut from a single source can give quantity back to it. Name the package on the inputs call to read its source and the most it can return:
row = requests.get(
f"{API}/v2/packages/return/inputs",
params={**PARAMS, "packageId": 12345},
headers=HEADERS,
).json()["details"][0]
requests.post(
f"{API}/v2/packages/return",
params={**PARAMS, "submit": "true"},
headers=HEADERS,
json=[
{
"sourcePackageId": row["sourcePackage"]["id"],
"childPackageId": 12345,
"returnQuantity": 0.5,
}
],
)
An empty details with isAnySelectedPackageInvalid: true means Metrc will not return from that package.
Remediating¶
Metrc remediates only product that failed lab testing.
methods = requests.get(
f"{API}/v2/packages/remediate/inputs", params=PARAMS, headers=HEADERS
).json()["remediationMethods"]
requests.post(
f"{API}/v2/packages/remediate",
params={**PARAMS, "submit": "true"},
headers=HEADERS,
json=[
{
"id": 12345,
"remediationMethodId": methods[0]["id"],
"remediationDate": "2026-05-14",
"remediationSteps": "Dried and cured for an additional week.",
}
],
)
Trade samples and donations¶
Each flag has a flag and an unflag write, and each write its own lookup: the flag lookup offers packages that are not flagged, the unflag lookup packages that are. The package record reads isTradeSample or isDonation.
requests.post(
f"{API}/v2/packages/trade-sample/flag",
params={**PARAMS, "submit": "true"},
headers=HEADERS,
json=[{"id": 12345}],
)
| Flag | Available in |
|---|---|
| Trade sample | California, Maine, Michigan |
| Donation | California, Michigan |
Discontinuing¶
Discontinue takes one package, as an object rather than a list:
requests.post(
f"{API}/v2/packages/discontinue",
params={**PARAMS, "submit": "true"},
headers=HEADERS,
json={"id": 12345},
)
Notes¶
requests.post(
f"{API}/v2/packages/notes",
params={**PARAMS, "submit": "true"},
headers=HEADERS,
json=[{"id": 12345, "note": "Moved to vault"}],
)
Verifying the result¶
Metrc answers a write it silently discarded with the same 200 as one it applied, so read the package back:
package = requests.get(
f"{API}/v2/packages/active",
params={**PARAMS, "filter": "label__eq:1A4000000000022000000003"},
headers=HEADERS,
).json()["data"][0]
print(package["locationName"], package["item"]["name"], package["note"])
Next Steps¶
- Cut a new package with Creating Packages.
- Ship it with Creating Transfers.
- Look up one eligible package by id with Object Lookup.