Permissions¶
Two Metrc accounts pointed at the same license rarely see the same thing. The permissions endpoints tell you what your account can actually do, so you can check before you call rather than discovering it from a 403.
A T3+ subscription is not required to use these endpoints.
flowchart TB
V["View: packages<br/>a page you can open"]
S["Sub-view: packages/active<br/>a tab on that page you can read"]
P["Permission: packages/active:remediate_packages<br/>an action you can take"]
V --> S --> P
VE["GET /v2/permissions/views"] -.-> V
SE["GET /v2/permissions?view=packages"] -.-> S
SE -.-> P Why Permissions Vary¶
Metrc access is determined by several independent factors:
- Permissions granted by your Metrc admin. Employee-level grants, set inside Metrc.
- Class of license. A cultivator sees plant pages a manufacturer does not.
- US state. Each state runs its own Metrc deployment with its own enabled modules.
You cannot hardcode a permission list
A script that works for one license may not work for the next one, even under the same login. Query the permissions instead.
Views and Permissions¶
T3 describes Metrc access at three granularities.
| What it is | Example | Endpoint | |
|---|---|---|---|
| View | A page you can open | packages | GET /v2/permissions/views |
| Sub-view | A tab within that page you can read | packages/active | GET /v2/permissions |
| Permission | An action you can take | packages/active:remediate_packages | GET /v2/permissions |
A view is a Metrc page path, and it may itself contain a slash, transfers/licensed and admin/items are both single views.
A sub-view is one of the grid tabs the page renders: Active, On Hold, Inactive, In Transit. This is the only signal that a collection scoped to that tab can be read at all, and it varies by state: California renders no Vegetative plant tab, so /v2/plants/vegetative does not work on a California license no matter what your Metrc admin granted.
Both endpoints return a flat JSON array of strings. There is no wrapper object.
Both are scoped to one license, via the required licenseNumber query parameter. Permissions belong to the combination of who you authenticated as and which license you asked about, so you need one call per license.
Listing Views¶
GET /v2/permissions/views
| Parameter | Required | Description |
|---|---|---|
licenseNumber | yes | The license to check |
import requests
views = requests.get(
"https://api.trackandtrace.tools/v2/permissions/views",
params={"licenseNumber": "LIC-00001"},
headers={"Authorization": f"Bearer {access_token}"},
).json()
print(views)
[
"admin/items",
"admin/strains",
"admin/tags",
"packages",
"plants",
"transfers/hub",
"transfers/licensed"
]
The list is sorted and deduplicated. A view missing from this list means the account cannot open that page. That is the check to make before assuming a collection endpoint will work.
Listing Permissions Within a View¶
GET /v2/permissions
| Parameter | Required | Description |
|---|---|---|
licenseNumber | yes | The license to check |
view | yes | A view name, as returned by /v2/permissions/views |
import requests
permissions = requests.get(
"https://api.trackandtrace.tools/v2/permissions",
params={"licenseNumber": "LIC-00001", "view": "packages"},
headers={"Authorization": f"Bearer {access_token}"},
).json()
print(permissions)
[
"packages/active",
"packages/onhold",
"packages/inactive",
"packages/intransit",
"packages/transferred",
"packages:add_items",
"packages/active:new_packages",
"packages/active:remediate_packages",
"packages/active:adjust_packages",
"packages/active:change_notes"
]
Sub-views come first, then actions in the order the page defines them.
view must be a view name, one or more slash-separated alphanumeric segments, such as packages or transfers/licensed. Anything else is rejected with a 400 and the code INVALID_QUERY_PARAMETER.
The view[/tab][:action] Format¶
Every entry starts with the view you asked for. A tab and an action are each optional:
plants/plantsflowering:harvest_flowering_plants
└──┬─┘ └──────┬──────┘ └───────────┬──────────┘
view tab action
Four shapes occur:
| Entry | Meaning |
|---|---|
plants/plantsflowering | The Flowering tab exists and is readable |
plants/plantsflowering:harvest_flowering_plants | An action on that tab |
plants:new_address | An action on the page, not tied to any tab |
plants | The page itself, from /v2/permissions/views, not this endpoint |
Because a view can contain slashes, split on the colon, not on the slash:
location, _, action = permission.partition(":")
if not action:
print(f"{location} is a readable tab")
Tab names come from Metrc's internal grid id, not the visible label. The plants page shows "On Hold" three times (once each under Plant Batches, Plants and Harvests) so the labels cannot tell them apart. The ids can: plantbatchesonhold, plantsonhold, harvestsonhold.
Action names come from the title of the modal the action opens, lowercased with spaces replaced by underscores, Harvest Flowering Plants becomes harvest_flowering_plants.
They do not come from the button text, and this trips people up. The button on the plants toolbar reads simply Harvest; guessing plants:harvest from it matches nothing. Button labels could not work even in principle: Maine's plants page carries 61 buttons under only 31 distinct labels (Change Location appears five times, once per tab) while the modal titles they open are all distinct.
Action names are descriptive, not a fixed vocabulary: the available actions differ by state and license class, which is the same reason the view list does. Query them rather than guessing them.
Matching Actions Without Knowing the Tab¶
Most actions are scoped to a tab, and usually you do not care which. Match on the action half:
def grants(permissions: list[str], action: str) -> bool:
"""True if any entry grants `action`, on any tab."""
return any(p.partition(":")[2] == action for p in permissions)
print(grants(permissions, "harvest_flowering_plants"))
Compare the whole string only when you specifically mean that action on that tab.
Checking Before You Act¶
The two endpoints compose into a single check. Ask for the view first, then the action only if you need that level of detail:
import requests
BASE = "https://api.trackandtrace.tools"
HEADERS = {"Authorization": f"Bearer {access_token}"}
def can(*, license_number: str, permission: str) -> bool:
"""True if the account may perform `permission` on `license_number`.
Accepts a view ("packages"), a sub-view ("packages/active"), or an action
with or without its tab ("adjust_packages", "packages:adjust_packages",
"packages/active:adjust_packages").
"""
location, _, action = permission.partition(":")
view = location.split("/")[0] if location not in ("",) else ""
views = requests.get(
f"{BASE}/v2/permissions/views",
params={"licenseNumber": license_number},
headers=HEADERS,
).json()
# A view may itself contain a slash, so prefer the longest match.
view = next(
(v for v in sorted(views, key=len, reverse=True)
if location == v or location.startswith(f"{v}/")),
None,
)
if view is None:
return False
permissions = requests.get(
f"{BASE}/v2/permissions",
params={"licenseNumber": license_number, "view": view},
headers=HEADERS,
).json()
if not action:
# A bare view is granted; a sub-view has to appear in the list.
return location == view or location in permissions
return any(
p.partition(":")[2] == action
and p.partition(":")[0].startswith(location.split(":")[0])
for p in permissions
)
print(can(license_number="LIC-00001", permission="packages"))
print(can(license_number="LIC-00001", permission="packages/active"))
print(can(license_number="LIC-00001", permission="packages:remediate_packages"))
print(can(license_number="LIC-00001", permission="packages:new_transfer"))
If you are checking several actions on the same page, fetch the view's permissions once and test against the list rather than calling can() repeatedly.
When Metrc Refuses a Form¶
The /inputs endpoints load a Metrc form. If your account cannot open that form on the license, Metrc sends the request to the license's home page instead, and T3 answers 403 with the code METRC_RESOURCE_UNAUTHORIZED:
import requests
response = requests.get(
"https://api.trackandtrace.tools/v2/transfers/create/inputs",
params={"licenseNumber": "LIC-00001"},
headers={"Authorization": f"Bearer {access_token}"},
)
if response.status_code == 403:
print(response.json()["code"]) # METRC_RESOURCE_UNAUTHORIZED
print(response.json()["detail"]) # names the license and where Metrc redirected
Having the page is not enough. The New Transfer form needs packages:new_transfer, so check the action with can() above. A write sent with applyDynamicValidation=true loads the same form, so it returns the same 403.
Limits and Behavior¶
- One license per request. Both endpoints take exactly one
licenseNumber. Checking a whole account means one views call per license. - Detail costs one call per view.
/v2/permissionscovers a single view, so enumerating everything is one request per view. Fetch only the views you care about. - Results are live. Each call reflects your permissions at that moment. A grant changed inside Metrc shows up on the next request, with no cache to wait out.
- Free. No T3+ subscription required.
Examples¶
Substitute your API token and license number into each example.
List every view available on a license:
curl 'https://api.trackandtrace.tools/v2/permissions/views?licenseNumber=LIC-00001' \
-H 'Authorization: Bearer <TOKEN>'
List the actions available on the packages page:
curl 'https://api.trackandtrace.tools/v2/permissions?licenseNumber=LIC-00001&view=packages' \
-H 'Authorization: Bearer <TOKEN>'
A view containing a slash, URL-encode it, or quote the URL:
curl 'https://api.trackandtrace.tools/v2/permissions?licenseNumber=LIC-00001&view=transfers/licensed' \
-H 'Authorization: Bearer <TOKEN>'
Find every license that can create packages:
import requests
BASE = "https://api.trackandtrace.tools"
HEADERS = {"Authorization": f"Bearer {access_token}"}
licenses = requests.get(f"{BASE}/v2/licenses", headers=HEADERS).json()
for license in licenses:
number = license["licenseNumber"]
permissions = requests.get(
f"{BASE}/v2/permissions",
params={"licenseNumber": number, "view": "packages"},
headers=HEADERS,
).json()
if any(p.endswith(":new_packages") for p in permissions):
print(number)
Next Steps¶
- Getting Started: authenticate and make your first request
- Search: query across collections without knowing which one to look in
- Supercollections: load a collection and its metadata in one request