Skip to content

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:

  1. Create a list and populate it with the tags you expect to see.
  2. As you scan, either PATCH individual 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 the endpoint they 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.
  3. Read the list back. The detail endpoint's meta.scannedCount tells 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. handlesNotFound is 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/active to confirm every one is an active package before you create the transfer.
  • Audit trails. Per-entry scannedAt timestamps plus arbitrary metadata give you a dated record of what was accounted for, which survives after the physical work is done. scannedAt holds the latest scan only.
  • Narrowing other workflows. Resolving a list against a /super collection returns exactly the objects you care about, with related data attached through include, 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. scannedCount is the number of entries whose scannedAt is 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 The scannedAt Field).
  • 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 whose handle already exists in the list (or is duplicated within a single create/append request) is skipped. The response's created or appended count 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 a 422 listing 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 /super collection with include to attach related data, and use collectionMask to 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, onhold or intransit, as /super). An entry that is not exactly one package fails with a 422 and 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=coaResults on a package supercollection, super report or resolve returns 409 COA_EXTRACTION_REQUIRED if 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 FAILED and contributes no results. It does not block the include.
  • Shareable PDFs. Every stored COA has a publicPdfUrl that 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.