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:
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:
When requiresPackagesGrossWeight is true, every package entry carries its own weight:
When transactionType is "Wholesale", each package carries its price:
Finding the ids¶
| Field | Where it comes from |
|---|---|
transferTypeId | GET /v2/transfers/create/inputs → transferTypes |
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/inputs → unitsOfMeasure |
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¶
- Creating Packages — build the packages a transfer carries
- Supercollections — load transfers with their packages in one request
- Permissions — check what your account may do before calling