Skip to content

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