Skip to content

Creating Transfers

A transfer moves packages from your license to another facility. The payload nests four levels deep — a shipment holds destinations, each destination holds transporters and packages, and each transporter holds driver and vehicle details.

Step Endpoint Result
0 GET /v2/transfers/create/inputs and its companion lists Transfer types, destinations, transporters
1 POST /v2/transfers/create An outgoing transfer with a manifest number

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

Transfers are initiated from the packages page in Metrc, so the permission is packages:new_transfer — not something under transfers. You also need the transfers/licensed view to read the result back.

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

assert "packages:new_transfer" in permissions

See Permissions.

Step 0 — Fetch the input options

import requests

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

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

print([t["name"] for t in inputs["transferTypes"]])
print(inputs["defaultPhoneNumberForQuestions"])

Destinations and transporters come from companion endpoints, both paginated:

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

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

A license with no eligible destination or transporter cannot create a transfer at all — that is a Metrc configuration matter, not something the payload can work around.

The inputs response also carries drivers and vehicles, the facility's saved records. Use them to fill transporterDetails rather than retyping driver and plate values:

driver = inputs["drivers"][0]
vehicle = inputs["vehicles"][0]

Choosing a transfer type

This is the decision that determines the rest of the payload. Each entry in inputs["transferTypes"] carries flags saying which conditional fields become mandatory:

Flag on the transfer type Makes this required
requiresDestinationGrossWeight grossWeight + grossUnitOfWeightId on the destination
requiresPackagesGrossWeight grossWeight + grossUnitOfWeightId on each package
transactionType == "Wholesale" wholesalePrice on each package
requiresInvoiceNumber invoiceNumber on the destination

forLicensedShipments tells you the type is valid for a standard licensed transfer. Wholesale types also carry minimumWholesalePrice and maximumWholesalePrice, which bound the wholesalePrice you may send.

Note

requiresInvoiceNumber is not part of the documented inputs response shape, though the create payload schema refers to it. Read it off the raw transfer type object rather than relying on it being present.

Which types exist, and which flags they carry, is state-specific — there is no fixed list to code against. Read the flags rather than matching on type names.

The simplest possible transfer is one whose type demands none of the conditional fields:

def is_minimal(transfer_type):
    """A licensed type needing no weight, invoice, or price fields."""
    return (
        transfer_type.get("forLicensedShipments")
        and not transfer_type.get("requiresDestinationGrossWeight")
        and not transfer_type.get("requiresPackagesGrossWeight")
        and not transfer_type.get("requiresInvoiceNumber")
        and transfer_type.get("transactionType") != "Wholesale"
    )

transfer_type = next(t for t in inputs["transferTypes"] if is_minimal(t))

If your license offers no such type, add whichever fields its flags demand — see Conditional fields below.

Step 1 — Create the transfer

The body is a list of shipments, and each shipment's destinations is itself a list, so one call can create a multi-stop transfer.

import datetime as dt
import requests

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

departure = dt.datetime.now(dt.timezone.utc).replace(microsecond=0)
arrival = departure + dt.timedelta(hours=1)

payload = {
    "destinations": [
        {
            "recipientId": destinations[0]["id"],
            "transferTypeId": transfer_type["id"],
            "plannedRoute": "I-70 west to the facility",
            "estimatedDepartureDateTime": departure.strftime("%Y-%m-%dT%H:%M:%S"),
            "estimatedArrivalDateTime": arrival.strftime("%Y-%m-%dT%H:%M:%S"),
            "transporters": [
                {
                    "transporterId": transporters[0]["id"],
                    "phoneNumberForQuestions": inputs["defaultPhoneNumberForQuestions"],
                    "transporterDetails": [
                        {
                            "driverName": "Jane Doe",
                            "driverOccupationalLicenseNumber": "LIC12345",
                            "driverLicenseNumber": "D1234567",
                            "vehicleMake": "Ford",
                            "vehicleModel": "Transit",
                            "vehicleLicensePlateNumber": "ABC1234",
                        }
                    ],
                }
            ],
            "packages": [{"id": package_id}],
        }
    ]
}

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

response.raise_for_status()

Required on every destination: recipientId, transferTypeId, plannedRoute, estimatedDepartureDateTime, and estimatedArrivalDateTime.

Required on every transporter: transporterId, phoneNumberForQuestions, and transporterDetails. Every field of transporterDetails is required — driverName, driverOccupationalLicenseNumber, driverLicenseNumber, vehicleMake, vehicleModel, and vehicleLicensePlateNumber. Only driverLayoverLeg is optional.

Datetime format

estimatedDepartureDateTime and estimatedArrivalDateTime take YYYY-MM-DDTHH:MM:SS with no timezone suffix. Metrc interprets them in the license's local time.

packages[].id is the Metrc package id from GET /v2/packages/active — the numeric id, not the tag label. GET /v2/transfers/create/packages lists the packages eligible for a transfer if you would rather filter server-side.

Conditional fields

Add these only when the chosen transfer type's flags call for them. Sending a field the type does not expect is rejected.

When requiresDestinationGrossWeight is true:

{
    # ... other destination fields
    "grossWeight": 123.45,
    "grossUnitOfWeightId": 4,
}

When requiresPackagesGrossWeight is true, every package entry carries its own weight:

{
    # ... other destination fields
    "packages": [
        {
            "id": package_id,
            "grossWeight": 123.45,
            "grossUnitOfWeightId": 4,
        }
    ],
}

When transactionType is "Wholesale", each package carries its price:

{
    # ... other destination fields
    "packages": [
        {
            "id": package_id,
            "wholesalePrice": 200.00,
        }
    ],
}

When requiresInvoiceNumber is true:

{
    # ... other destination fields
    "invoiceNumber": "123456-ABC",
}

Finding the ids

Field Where it comes from
transferTypeId GET /v2/transfers/create/inputstransferTypes
recipientId GET /v2/transfers/create/destinations
transporterId GET /v2/transfers/create/transporters
packages[].id GET /v2/packages/active, or GET /v2/transfers/create/packages
grossUnitOfWeightId GET /v2/transfers/create/inputsunitsOfMeasure

Verifying the result

The create response does not carry the new transfer's id, so find it by diffing the outgoing list around the call:

def outgoing_ids():
    listing = requests.get(
        f"{API}/v2/transfers/outgoing/active",
        params={"licenseNumber": "CUL00001", "pageSize": 100},
        headers=HEADERS,
    ).json()["data"]
    return {t["id"] for t in listing}


before = outgoing_ids()
# ... POST /v2/transfers/create ...
new_ids = outgoing_ids() - before

assert len(new_ids) == 1
transfer_id = new_ids.pop()

Read the manifest number off the listing once you have the id:

listing = requests.get(
    f"{API}/v2/transfers/outgoing/active",
    params={"licenseNumber": "CUL00001", "pageSize": 100},
    headers=HEADERS,
).json()["data"]

transfer = next(t for t in listing if t["id"] == transfer_id)
print(transfer["manifestNumber"])

Transferred packages leave GET /v2/packages/active and appear in GET /v2/packages/intransit.

Undoing a transfer

POST /v2/transfers/void reverses a transfer that has not yet been received. Note the body is a single object, not a list:

requests.post(
    f"{API}/v2/transfers/void",
    params={"licenseNumber": "CUL00001", "submit": "true"},
    headers=HEADERS,
    json={"id": transfer_id},
)

The voided transfer leaves GET /v2/transfers/outgoing/active and its packages return to active inventory.

Next Steps