Receiving Transfers¶
Creating Transfers covers the shipper's side. This page covers everything after the manifest exists: the recipient accepting or refusing it, the transporter moving it, and the notes both sides leave along the way.
| Step | Endpoint | Who does it |
|---|---|---|
| Approve | POST /v2/transfers/approve | Recipient |
| Reject | POST /v2/transfers/reject | Recipient |
| Receive | POST /v2/transfers/receive | Recipient |
| Take back a rejection | POST /v2/transfers/rejected/receive | Shipper |
| Crew the return leg | POST /v2/transfers/transporters/update | Shipper |
| Accept · Depart · Check-in · Check-out · Arrive | POST /v2/transfers/hub/{action} | Transporter |
| Correct transport details | POST /v2/transfers/hub/update | Transporter |
| Add a note | POST /v2/transfers/notes/create | Either |
| Void a note | POST /v2/transfers/notes/void | Author |
Like every mutation in this API, these are dry runs by default. Add submit=true to actually write to Metrc — see Getting Started.
import requests
API = "https://api.trackandtrace.tools"
HEADERS = {"Authorization": f"Bearer {ACCESS_TOKEN}"}
PARAMS = {"licenseNumber": "MAN000030"}
The two IDs, and why transfers are confusing¶
Almost every mistake on this page is the wrong ID. A transfer is three nested things:
Transfer (id) — the manifest
└─ Delivery (deliveryId) — one leg to one recipient
└─ Package (id) — one package on that delivery
A transfer with one destination still has a delivery, and its deliveryId is usually not the same number as the transfer's id. Endpoints on this page want:
id— the transfer, from/v2/transfers/incoming/activeor/v2/transfers/rejectedshipmentDeliveryId— the delivery, from/v2/transfers/deliveries- package
id— from/v2/transfers/packages, not from/v2/packages
The hub endpoints are the exception: they take deliveryId directly, read from the deliveryId field of /v2/transfers/hub.
Package IDs are delivery-scoped
The id on a package in /v2/transfers/packages identifies that package on that delivery. It is a different number from the package's own ID in /v2/packages, and sending the latter is the most common cause of a rejected receive payload.
Approving, before you can receive¶
Some states put an approval step in front of receiving: the transfer arrives pending approval and cannot be received until the recipient approves it. States that do not use the step never produce transfers in it, and you can skip straight to Receive.
requests.post(
f"{API}/v2/transfers/approve",
params={**PARAMS, "submit": "true"},
headers=HEADERS,
json=[{"id": 12345, "shipmentDeliveryId": 6789}],
)
Set autoApprove to approve later transfers from the same shipper automatically — but only on a transfer whose record allows it, or Metrc refuses the whole call.
Refusing the whole transfer¶
reasons = requests.get(
f"{API}/v2/transfers/reject/inputs", params=PARAMS, headers=HEADERS
).json()["actionReasons"]
reason = next(r for r in reasons if r["forRejectedTransferApprovals"])
requests.post(
f"{API}/v2/transfers/reject",
params={**PARAMS, "submit": "true"},
headers=HEADERS,
json=[{
"id": 12345,
"shipmentDeliveryId": 6789,
"rejectReasonId": reason["id"],
"rejectReasonNote": "Manifest does not match the delivery",
}],
)
rejectReasonNote is required whatever reason you pick — unlike elsewhere, where the reason's own requiresNote flag decides.
To refuse only some packages, do not use this endpoint. Receive the transfer and mark those packages rejected instead; see Rejecting individual packages.
Receiving¶
Receiving is one call that books every package on the delivery. A package left out of packages is not left untouched — Metrc receives the delivery as a whole, so omitting one is not a way to defer it.
Step 0 — fetch the inputs¶
inputs = requests.get(
f"{API}/v2/transfers/receive/inputs", params=PARAMS, headers=HEADERS
).json()
locations = inputs["locations"]
units = inputs["unitsOfMeasure"]
Step 1 — read what is on the delivery¶
packages = requests.get(
f"{API}/v2/transfers/packages",
params={**PARAMS, "deliveryId": 6789},
headers=HEADERS,
).json()["data"]
Step 2 — receive it¶
requests.post(
f"{API}/v2/transfers/receive",
params={**PARAMS, "submit": "true"},
headers=HEADERS,
json=[{
"id": 12345,
"shipmentDeliveryId": 6789,
"packages": [
{
"id": p["id"],
"receivedQuantity": p["shippedQuantity"],
"receivedUnitOfMeasureId": p["shippedUnitOfMeasureId"],
"locationId": locations[0]["id"],
}
for p in packages
],
}],
)
Receiving the shipped quantity unchanged is the simple case. Two things complicate it.
Variances¶
If receivedQuantity differs from what was shipped, that is a variance and Metrc refuses it unless you say so:
{"id": 222333, "receivedQuantity": 9.0, "receivedUnitOfMeasureId": 1,
"locationId": 505, "confirmVariance": True}
Rejecting individual packages¶
A rejected package needs a reason, and locationId / receivedQuantity stop being required for it — it is not entering your inventory.
reason = next(
r for r in inputs["actionReasons"] if r["forRejectedTransferPackages"]
)
{"id": 222333, "rejected": True, "rejectedReasonId": reason["id"],
"reasonNote": "Seal broken in transit"}
reasonNote is required when the reason you picked has requiresNote: true. Check the flag rather than always sending one — an unexpected note is as rejectable as a missing one.
Fields that vary by state¶
| Field | Sent in |
|---|---|
locationId | everywhere except California |
sublocationId | Maine and Michigan only |
rIdQrScanned | California and Michigan only |
T3 strips the fields your state does not use, so sending locationId from a California session is harmless. It does not invent the ones your state requires.
wholesalePrice¶
Required per-package only when the transfer type has transactionType="Wholesale" — see Choosing a transfer type.
Taking back a rejection¶
When a recipient rejects packages, they travel back and the shipper books them in. This is a different endpoint from receiving, with different field names:
requests.post(
f"{API}/v2/transfers/rejected/receive",
params={**PARAMS, "submit": "true"},
headers=HEADERS,
json=[{
"id": 12345,
"shipmentDeliveryId": 6789,
"packages": [{
"id": 222333,
"locationId": 505,
"receiverRejectedReasonId": 9,
"receiverReasonNote": "Seal broken in transit",
}],
}],
)
receiverRejectedReasonId, not rejectedReasonId
The two forms look alike and Metrc even gives them the same HTML form ID, but the return form uses receiverRejectedReasonId and receiverReasonNote. A payload built for /v2/transfers/receive will not work here. There is also no receivedQuantity, receivedUnitOfMeasureId, confirmVariance or wholesalePrice — nothing is being accepted, only booked back in.
The return trip needs its own transporters, set with POST /v2/transfers/transporters/update. Its payload mirrors the transporters block of /v2/transfers/update.
The transporter's view — the hub¶
A transporter carrying someone else's manifest works from the hub, not the transfer listing. Each leg moves through five states, each its own endpoint, all taking the same two fields:
delivery = requests.get(f"{API}/v2/transfers/hub", params=PARAMS, headers=HEADERS).json()["data"][0]
body = {
"deliveryId": delivery["deliveryId"],
"direction": delivery["transporterDirectionName"], # "Outbound" or "Return"
}
for action in ["accept", "depart", "check-in", "check-out", "arrive"]:
requests.post(
f"{API}/v2/transfers/hub/{action}",
params={**PARAMS, "submit": "true"},
headers=HEADERS,
json=body,
)
That loop is illustrative, not a recipe — each transition has preconditions, and Metrc rejects one taken out of order:
| Action | Meaning | Requires |
|---|---|---|
accept | The transporter takes the job | — |
depart | Left the origin | accepted |
check-in | Arrived at a layover | departed |
check-out | Left a layover | checked in |
arrive | Reached the destination | departed |
check-in and check-out only apply to a layover. A direct run is accept → depart → arrive.
Metrc's UI chains accept and depart
Pressing Depart in Metrc on a delivery that has not been accepted quietly sends accept and then depart. The T3 endpoints are the primitives, so if you want that behaviour, send both.
Correcting transport details¶
Route, times, drivers and vehicles on a delivery already in flight:
requests.post(
f"{API}/v2/transfers/hub/update",
params={**PARAMS, "submit": "true"},
headers=HEADERS,
json=[{
"id": delivery["id"],
"shipmentDeliveryId": delivery["deliveryId"],
"transporterDirection": "Outbound",
"estimatedDepartureDateTime": "2026-05-23T21:22:35",
"estimatedArrivalDateTime": "2026-05-24T09:00:00",
"plannedRoute": "I-70 west to Columbia, then US-63 north",
"transporterDetails": [{
"driverName": "Jane Doe",
"driverOccupationalLicenseNumber": "LIC12345",
"driverLicenseNumber": "D1234567",
"vehicleMake": "Ford",
"vehicleModel": "Transit",
"vehicleLicensePlateNumber": "ABC1234",
}],
}],
)
This cannot change what is being shipped — use /v2/transfers/update for that. Saved drivers and vehicles are available from /v2/transfers/create/inputs, but every field here is free text, so a driver who is not on file can still be entered.
Notes¶
A note is attached to a transfer, visible to both parties, and cannot be edited — correcting one means voiding it and adding a replacement.
reasons = requests.get(
f"{API}/v2/transfers/notes/inputs", params=PARAMS, headers=HEADERS
).json()["actionReasons"]
reason = next(r for r in reasons if r["forTransferNotes"])
requests.post(
f"{API}/v2/transfers/notes/create",
params={**PARAMS, "submit": "true"},
headers=HEADERS,
json=[{
"shipmentPlanId": 12345,
"reasonId": reason["id"],
"note": "Driver rerouted around a road closure on I-70",
}],
)
shipmentPlanId is the transfer's own id. Voiding takes the note's ID:
requests.post(
f"{API}/v2/transfers/notes/void",
params={**PARAMS, "submit": "true"},
headers=HEADERS,
json={"id": 4321},
)
Next Steps¶
- Creating Transfers — the shipper's side
- Supercollections — pull a transfer with its deliveries, packages and transporters in one request
- Reports and Spreadsheet Sync — transfer data into a spreadsheet