Tag Lists¶
Overview¶
A tag list is a named, ordered set of Metrc tag handles with per-entry metadata. Tag lists are a T3-side grouping construct; they live inside the T3 platform and never write back to Metrc. A list can be resolved against a Metrc collection to fetch the objects its handles name.
Tag lists are the primary unit of work in OpenTag and are also exposed as a set of endpoints in the T3 API, so the same list can be built on a phone, queried from a script, or edited from either.
How Tag Lists Are Built¶
Tag lists can be built in two ways.
One at a time. Start with an empty list, then append entries as you encounter them; for example, scanning package tags with a phone or an RFID reader, or POSTing entries to the API one by one as they come in from another system.
Bulk-generated from an existing source. Populate a list in a single request from data you already have; for example, the package list from a transfer manifest, the rows of a Metrc report, or a CSV of handles. A list can also be seeded with initial entries at creation time and appended to later.
The two paths are not mutually exclusive. A common pattern is to seed a list from a manifest, then scan physical tags against it to verify the shipment.
How Tag Lists Are Used¶
Checking Off Inventory¶
Each entry has a nullable scannedAt timestamp. Marking an entry as scanned (setting scannedAt to the current time) is how tag lists record that a physical tag has been accounted for. Unmarking simply sets scannedAt back to null.
The standard workflow:
- Create a list and populate it with the tags you expect to see.
- As you scan, either
PATCHindividual entries or (for a batch of handles like an RFID sweep) hit the bulk scan endpoint (POST /v2/tag-lists/{id}/scan). The bulk endpoint takes an array of handles and theendpointthey belong to, stamps every matching entry in one call, and returns any supplied handles that weren't in the list. Like resolve, it ignores case and surrounding whitespace. - Read the list back. The detail endpoint's
meta.scannedCounttells you how many entries are accounted for; the list endpoint returns the same summary without pulling every entry across the wire.
Membership Lookup¶
Given a single tag handle, is it in this list? This is the second core use case: reading back a list and checking whether a specific handle appears in entries. This powers workflows like "did this package ship on manifest X?" or "is this plant part of the harvest batch I'm auditing?".
Other Use Cases¶
Beyond inventory check-offs and membership, tag lists are useful for:
- Reconciliation. Build a list of what Metrc says is on hand, then bulk scan what's physically present.
handlesNotFoundis what's on the floor but not in the list; entries left unscanned are what's missing. - Pre-transfer checks. Assemble the packages intended for an outgoing transfer, then resolve the list against
/v2/packages/activeto confirm every one is an active package before you create the transfer. - Audit trails. Per-entry
scannedAttimestamps plus arbitrarymetadatagive you a dated record of what was accounted for, which survives after the physical work is done.scannedAtholds the latest scan only. - Narrowing other workflows. Resolving a list against a
/supercollection returns exactly the objects you care about, with related data attached throughinclude, in place of filtering a whole collection.
Anatomy of a Tag List¶
A tag list has two layers: the list itself, and the entries inside it.
List-level fields:
id: numeric identifier.name: optional human-readable label ("Room A inventory", "Manifest M-0123"). Can be set at creation and edited later.allowDuplicateHandles: whether the list allows the same handle to appear more than once (see Duplicate Handling). Fixed at creation.isArchived: archive flag (see Archiving).entryCount,scannedCount: summary counts. Always returned on both the list and detail endpoints.scannedCountis the number of entries whosescannedAtis set.createdAt,createdBy: when the list was created, and the Metrc username that created it.
Lists are scoped to the combination of Metrc username + Metrc hostname + license number. You only ever see the lists you created, under the license you created them for.
Entry-level fields:
id: numeric identifier, used to update or delete one entry.handle: the tag string itself (for example a Metrc package label).name: optional freeform string identifier describing what the row is ("Vault shelf 3", "Top drawer"). Editable after creation.metadata: a freeform JSON object (see Entry Metadata).scannedAt: nullable timestamp (see ThescannedAtField).createdAt,createdBy: when the entry was added, and by whom.
The detail endpoint returns entries newest first. Resolve returns them in the order they were added.
Entry Metadata¶
Every entry carries a metadata object that accepts any JSON. Typical uses:
- Display hints: a color or icon name so the UI can group entries visually.
- Contextual notes: free text the scanner entered on-device.
- Source tracking: where the handle came from (
{"source": "manifest-M-0123"}), which makes it easy to filter later. - Workflow state: anything downstream tooling needs to remember between runs.
Metadata is opaque to the T3 API: it stores and returns whatever you put in. Treat it as a small per-entry scratchpad.
The scannedAt Field¶
scannedAt is the field that makes the inventory check-off workflow possible. It's nullable and starts out null. You flip it to a timestamp to mark an entry as scanned, and back to null to undo.
Updates go through the entry-update endpoint (PATCH /v2/tag-lists/{id}/entries/{entryId}), which can update metadata and name in the same request, or through bulk scan for many handles at once. Only PATCH can set scannedAt back to null.
Because scannedAt is just a timestamp, it doubles as an audit record: reading the list back tells you which tags were scanned, and when. Scanning an entry again overwrites the earlier time.
Duplicate Handling¶
When a list is created, allowDuplicateHandles fixes its deduplication behavior:
false(default): any entry whosehandlealready exists in the list (or is duplicated within a single create/append request) is skipped. The response'screatedorappendedcount tells you how many were kept.true: every entry is kept, including repeats of the same handle.
The right choice depends on what the handle represents. If a handle is a unique Metrc tag, leave duplicates off: adding the same tag twice should be a no-op. If a handle is something that can legitimately appear multiple times (say, a SKU being counted), turn duplicates on.
Duplicate handling is fixed at creation
This setting cannot be changed after the list is created. Create a new list if you need to switch modes.
Archiving¶
Lists cannot be deleted; they're archived. Setting isArchived to true with PATCH /v2/tag-lists/{id} hides the list from the default list query but keeps every entry intact and still retrievable by ID. GET /v2/tag-lists?isArchived=true returns only archived lists. Setting it back to false restores normal visibility.
Archiving is a convenient way to close out a completed inventory run or shipment verification without losing the record.
Access and Pricing¶
Tag lists are scoped per user, per Metrc hostname, per license number. There is no cross-user visibility: two users on the same license see two independent sets of lists.
Reads are free. Fetching all lists and fetching a single list with its entries work on any Metrc login without a T3+ subscription.
Writes require T3+. Creating and renaming lists, archiving, appending, updating and deleting entries, and bulk scanning all require a T3+ subscription.
Resolving requires T3+. Resolving a list reads Metrc, so it requires a T3+ subscription.
Extracting COA results requires T3+. Checking extraction status is free.
Using Tag Lists via the T3 API¶
Every tag list endpoint is documented in the interactive API docs under the Tag Lists tag:
https://api.trackandtrace.tools/v2/docs/#/Tag%20Lists
| Method | Path | Does |
|---|---|---|
GET | /v2/tag-lists | Your lists, with counts |
POST | /v2/tag-lists | Create a list, optionally seeded with entries |
GET | /v2/tag-lists/{id} | One list's meta and every entry, newest first |
PATCH | /v2/tag-lists/{id} | Rename (name) or archive (isArchived) |
POST | /v2/tag-lists/{id}/entries | Append entries |
PATCH | /v2/tag-lists/{id}/entries/{entryId} | Set scannedAt, name or metadata on one entry |
DELETE | /v2/tag-lists/{id}/entries/{entryId} | Remove one entry |
POST | /v2/tag-lists/{id}/scan | Mark entries scanned by handle |
GET | /v2/tag-lists/{id}/resolve | The Metrc object for every entry |
POST, GET | /v2/tag-lists/{id}/coa-extraction | Schedule COA extraction, and check on it |
Every request carries licenseNumber: in the query string on a GET, in the JSON body otherwise.
The list collection endpoint (GET /v2/tag-lists) is paginated like other T3 collections: pageSize defaults to 100 and is capped at 500. By default it returns entryCount and scannedCount summaries without the actual entries. Pass ?includeEntries=true to embed every list's full entries inline; this is significantly more expensive for users with many or large lists, so prefer the default and fetch individual lists by ID when you need their entries.
Typed endpoints take endpoint. A tag list stores plain strings and has no type. Endpoints that treat handles as Metrc objects (bulk scan, resolve and COA extraction) take an endpoint naming the collection the handles belong to, such as /v2/packages/active or /v2/plants/flowering. Endpoints that only store or read entries do not.
A minimal Python example covering the end-to-end check-off flow with the bulk scan endpoint:
import requests
BASE = "https://api.trackandtrace.tools"
HEADERS = {"Authorization": f"Bearer {T3_JWT}"}
# 1. Create a named list, seeded with one handle.
create = requests.post(
f"{BASE}/v2/tag-lists",
headers=HEADERS,
json={
"licenseNumber": "LIC-00001",
"name": "Manifest M-0123",
"allowDuplicateHandles": False,
"entries": [{"handle": "1A4..."}],
},
).json()
tag_list_id = create["data"]["id"]
# 2. Append another handle.
requests.post(
f"{BASE}/v2/tag-lists/{tag_list_id}/entries",
headers=HEADERS,
json={
"licenseNumber": "LIC-00001",
"entries": [{"handle": "1A5...", "metadata": {"source": "manifest-M-0123"}}],
},
)
# 3. Bulk mark matching handles scanned in a single call.
# `handlesNotFound` lists any supplied handles that weren't in the list.
result = requests.post(
f"{BASE}/v2/tag-lists/{tag_list_id}/scan",
headers=HEADERS,
json={
"licenseNumber": "LIC-00001",
"endpoint": "/v2/packages/active",
"handles": ["1A4...", "1A5...", "UNKNOWN..."],
},
).json()
print(result["data"]) # {"scanned": 2, "scannedAt": "...", "handlesNotFound": ["UNKNOWN..."]}
Correcting entries and closing out a list:
# Entries come back newest first, each with its `id`.
detail = requests.get(
f"{BASE}/v2/tag-lists/{tag_list_id}",
headers=HEADERS,
params={"licenseNumber": "LIC-00001"},
).json()["data"]
entry_id = detail["entries"][0]["id"]
# Undo a scan.
requests.patch(
f"{BASE}/v2/tag-lists/{tag_list_id}/entries/{entry_id}",
headers=HEADERS,
json={"licenseNumber": "LIC-00001", "scannedAt": None},
)
# Remove an entry.
requests.delete(
f"{BASE}/v2/tag-lists/{tag_list_id}/entries/{entry_id}",
headers=HEADERS,
json={"licenseNumber": "LIC-00001"},
)
# Archive the list once the run is done.
requests.patch(
f"{BASE}/v2/tag-lists/{tag_list_id}",
headers=HEADERS,
json={"licenseNumber": "LIC-00001", "isArchived": True},
)
Resolving a Tag List¶
Resolving turns a list's handles into the Metrc objects they name. Choose one collection with endpoint; every entry comes back as its object, in the order the entries were added.
resolved = requests.get(
f"{BASE}/v2/tag-lists/{tag_list_id}/resolve",
headers=HEADERS,
params={"licenseNumber": "LIC-00001", "endpoint": "/v2/packages/active"},
)
if resolved.status_code == 422:
for error in resolved.json()["errors"]:
print(error["handle"], error["reason"]) # "1A4... NOT_FOUND"
else:
packages = resolved.json()["data"] # one package per entry, in list order
- All or nothing. If any entry matches no object (
NOT_FOUND) or more than one (AMBIGUOUS), the response is a422listing every failing entry, and nothing else is returned. - Forgiving matches. Case and surrounding whitespace are ignored.
- Repeats repeat. A handle listed twice returns its object twice.
- Up to 5,000 entries per list.
- Extras. Resolve against a
/supercollection withincludeto attach related data, and usecollectionMaskto trim fields.
| Collection | Matches on |
|---|---|
/v2/packages/active, onhold, inactive, intransit | label |
/v2/packages/transferred | packageLabel |
/v2/plants/vegetative, flowering, onhold, inactive, mother/active, mother/onhold, mother/inactive | label |
/v2/plantbatches/active, onhold, inactive | name |
/v2/tags/available, used, voided | label |
/v2/transfers/incoming/active, incoming/inactive, outgoing/active, outgoing/inactive, rejected, hub | manifestNumber |
/v2/harvests/active, onhold, inactive | name |
/v2/items | name |
/v2/strains | name |
Every collection above also accepts its /super path.
Extracting COA Results¶
A package's lab results link to COA PDFs. T3 can read those PDFs into structured results, metadata.indexedCoaResults, keyed exactly like indexedLabResults. Reading a PDF takes about a minute, so it happens ahead of time: schedule a tag list, wait for it, then request the results.
import time
def schedule():
response = requests.post(
f"{BASE}/v2/tag-lists/{tag_list_id}/coa-extraction",
headers=HEADERS,
json={"licenseNumber": "LIC-00001", "endpoint": "/v2/packages/active/super"},
)
response.raise_for_status()
return response.json()["data"]
# 1. Schedule every COA the list's packages reference.
print(schedule()) # {"scheduledCount": 40, "alreadyScheduledCount": 47, ...}
# 2. Wait until every package is READY. A COA whose download gave up
# returns to NOT_SCHEDULED; scheduling again picks it back up.
while True:
time.sleep(30)
packages = requests.get(
f"{BASE}/v2/tag-lists/{tag_list_id}/coa-extraction",
headers=HEADERS,
params={"licenseNumber": "LIC-00001"},
).json()["data"]["packages"]
statuses = {package["status"] for package in packages}
if statuses <= {"READY", "NO_DOCUMENTS"}:
break
if "NOT_SCHEDULED" in statuses:
schedule()
# 3. Read the results.
packages = requests.get(
f"{BASE}/v2/tag-lists/{tag_list_id}/resolve",
headers=HEADERS,
params={
"licenseNumber": "LIC-00001",
"endpoint": "/v2/packages/active/super",
"include": "coaResults",
},
).json()["data"]
print(packages[0]["metadata"]["indexedCoaResults"]["totalTHC"]["full"]) # "Total THC: 25.32 %"
- Each COA is read once. Many packages share a COA. A document already scheduled or read, by anyone, is never read again, so scheduling the same list twice schedules nothing new beyond COAs that returned to
NOT_SCHEDULED. - Strict, like resolve. The list is resolved against one package collection (
active,inactive,onholdorintransit, as/super). An entry that is not exactly one package fails with a422and nothing is scheduled. Up to 500 entries. - Rate limited. At most 10 schedule requests per minute per T3+ subscription; more return
429. - Status follows the last schedule. Status reports the packages the latest schedule request resolved. Entries added since appear after you schedule again.
- Final results only. Revoked and unreleased lab results are ignored.
- The include never waits.
include=coaResultson a package supercollection, super report or resolve returns409 COA_EXTRACTION_REQUIREDif any package on the page has a COA that is not read yet, and returns nothing else. - Unreadable COAs. A COA that cannot be read is marked
FAILEDand contributes no results. It does not block the include. - Shareable PDFs. Every stored COA has a
publicPdfUrlthat needs no login and never expires, so it can go on a label as a QR code.
See Supercollection Includes for the shape of the results.
Using Tag Lists in OpenTag¶
On mobile, tag lists are the OpenTag app's central feature. OpenTag creates and edits the same tag lists the T3 API exposes, so a list scanned on-device can be read back from a script, and vice versa. See the OpenTag page for the device workflow.
Next Steps¶
- Read the OpenTag page for the mobile scanning workflow that drives tag lists.
- Browse the T3 API documentation for how to integrate tag lists into scripts and automations.
- Subscribe to T3+ to unlock tag list mutations and OpenTag's premium features.
- Try the interactive API docs to explore every tag list endpoint.