Skip to content

Creating Packages

A new package is cut from one or more existing active packages. There is no way to conjure one from nothing — every package traces back to material already in the license, which is why the payload is built around an ingredients list.

Step Endpoint Result
0 GET /v2/packages/create/inputs and its companion lists The ids the payload needs
1 POST /v2/packages/create Source packages are drawn down, a new tagged package appears

Like every mutation in this API, this is a dry run by default. Add submit=true to actually write to Metrc — see Getting Started.

Permissions

Creating a package requires packages:new_packages. Check before you call:

permissions = requests.get(
    f"{API}/v2/permissions",
    params={"licenseNumber": "CUL00001", "view": "packages"},
    headers=HEADERS,
).json()

assert "packages:new_packages" in permissions

See Permissions.

Step 0 — Fetch the input options

GET /v2/packages/create/inputs returns the option lists for the create payload:

import requests

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

inputs = requests.get(
    f"{API}/v2/packages/create/inputs",
    params={"licenseNumber": "CUL00001"},
    headers=HEADERS,
).json()

print([u["name"] for u in inputs["unitsOfMeasure"]])
print([loc["name"] for loc in inputs["locations"]])

tags, items, and packages are not served here

The inputs response declares tags, items, and packages keys, but they come back empty even on a license holding thousands of tags. Each is served from a companion endpoint instead:

You need Call this, not inputs
tagId GET /v2/packages/create/source-tags
itemId GET /v2/packages/create/source-items
ingredients[].packageId GET /v2/packages/create/source-packages

All three are paginated — pass pageSize.

tags = requests.get(
    f"{API}/v2/packages/create/source-tags",
    params={"licenseNumber": "CUL00001", "pageSize": 100},
    headers=HEADERS,
).json()["data"]

items = requests.get(
    f"{API}/v2/packages/create/source-items",
    params={"licenseNumber": "CUL00001", "pageSize": 100},
    headers=HEADERS,
).json()["data"]

sources = requests.get(
    f"{API}/v2/packages/create/source-packages",
    params={"licenseNumber": "CUL00001", "pageSize": 100},
    headers=HEADERS,
).json()["data"]

If source-tags returns nothing, no package can be created — tags must be assigned to the license in Metrc first.

Step 1 — Create the package

The body is a list, so several packages can be created in one call.

import requests

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

source = sources[0]

# Match the source package's unit. Falling back to the abbreviation is worth
# doing -- the id on a package record does not always appear in unitsOfMeasure.
unit = next(
    (u for u in inputs["unitsOfMeasure"] if u["id"] == source.get("unitOfMeasureId")),
    None,
) or next(
    u
    for u in inputs["unitsOfMeasure"]
    if u["abbreviation"] == source.get("unitOfMeasureAbbreviation")
)

payload = {
    "actualDate": "2024-08-08",
    "ingredients": [
        {
            "packageId": source["id"],
            "quantity": 5.0,
            "unitOfMeasureId": unit["id"],
        }
    ],
    "itemId": items[0]["id"],
    "quantity": 5.0,
    "tagId": tags[0]["id"],
    "unitOfMeasureId": unit["id"],
    "note": "Cut for retail",
}

response = requests.post(
    f"{API}/v2/packages/create",
    params={"licenseNumber": "CUL00001", "submit": "true"},
    headers=HEADERS,
    json=[payload],
)

response.raise_for_status()

Required fields are actualDate, ingredients, itemId, quantity, tagId, and unitOfMeasureId. Everything else is optional or state-dependent.

A few worth knowing:

  • ingredients[].quantity is what you take from the source, and the top-level quantity is what the new package holds. They are usually equal, but need not be — a process that loses weight draws more than it yields.
  • ingredients[].finishDate finishes a source package you have fully consumed. Omit it to leave the remainder active.
  • useSameItem is for sublotting material this license does not own the item for. You must still send itemId, copied from the source package.
  • Multiple ingredients combine several sources into one package. Each entry needs its own packageId, quantity, and unitOfMeasureId.

Fields that vary by state

Unlike plant batch naming, none of these change the payload's shape — they are fields that exist in some states and are rejected in others. Send them only where they apply.

Field Behavior
locationId Required in most states, not required in some. inputs["locations"] is empty where it does not apply.
sublocationId Unused in some states, optional in others.
expirationDate, useByDate, sellByDate Only applicable in some states.
isDonation, isTradeSample Only applicable in some states. Do not send them at all where they do not apply — sending false is not the same as omitting.

The reliable test is the inputs response: if a list backing a field comes back empty or null, that field does not apply to the license you are writing to.

locations = inputs.get("locations") or []
if locations:
    payload["locationId"] = locations[0]["id"]

Finding the ids

Field Where it comes from
tagId GET /v2/packages/create/source-tags
itemId GET /v2/packages/create/source-items
ingredients[].packageId GET /v2/packages/create/source-packages
unitOfMeasureId GET /v2/packages/create/inputsunitsOfMeasure
locationId GET /v2/packages/create/inputslocations

Verifying the result

The new package appears in GET /v2/packages/active under its tag label:

created = requests.get(
    f"{API}/v2/packages/active",
    params={"licenseNumber": "CUL00001", "filter": f"label__eq:{tags[0]['label']}"},
    headers=HEADERS,
).json()["data"]

assert len(created) == 1
print(created[0]["quantity"])

The source package should have been drawn down by the ingredient quantity. Re-read it to confirm the write actually took effect:

remaining = requests.get(
    f"{API}/v2/packages/active",
    params={"licenseNumber": "CUL00001", "filter": f"label__eq:{source['label']}"},
    headers=HEADERS,
).json()["data"]

# A fully consumed source leaves the active list entirely
if remaining:
    assert remaining[0]["quantity"] < source["quantity"]

A source that was fully consumed drops out of /v2/packages/active rather than showing a zero balance, so an empty result here is a correct outcome, not a failed write.

Next Steps