Transfers
Move stock between locations with a structured request, approve, ship, and receive workflow — every unit tracked individually from source bin to destination bin.
An inventory transfer is a formal request to move units from one location to another. Rather than silently teleporting stock, transfers give a source location the chance to approve and assign specific units, then let the destination location receive them into a bin — keeping the audit trail honest and the unit statuses accurate throughout the journey.
Transfers only appear in multi-location accounts. The Transfers menu item is hidden when your account has a single location, because moving stock between bins within one location is done directly on the unit.
The transfer lifecycle #
A transfer moves through a fixed sequence of statuses:
- Draft — being built; no units are committed yet.
- Requested — the origin location has been notified. The transfer appears as a pending action for users at the source.
- Approved — a user at the source location has reviewed the request, assigned specific units to each line, and confirmed shipment.
- In Transit — the units are physically on the way.
- Delivered — the shipment has arrived at the destination and is ready to be received.
- Received — all units have been scanned or confirmed into a destination bin. Units land as In Stock at the new location.
- Cancelled — the transfer was rejected or abandoned. Cancellation is available from Draft, Requested, and Approved; once a transfer is In Transit or later it cannot be cancelled.
Each status transition is timestamped on the transfer record (requested_at,
approved_at, shipped_at, delivered_at, received_at).
Creating a transfer #
New transfers are created from Inventory → Transfers → New Transfer.
- From Location — where the stock is currently held. Required.
- To Location — where it needs to go. Must differ from the source. Required.
- Requested By — the user initiating the request. Optional; defaults to unknown.
- Status — starts as Draft. Change to Requested to notify the source location.
- Expected Delivery Date — an optional target date used for planning.
- Notes — free-text context for the approver.
- Requested Units (repeater) — one or more lines, each specifying a Product and a requested quantity. You can add as many lines as needed with Add Line.
Save the transfer in Draft to keep editing it, or set the status to Requested when it's ready for the source location to act on.
Approving a transfer #
When a transfer reaches Requested status, users at the source location see a badge on the Transfers menu item. Opening the transfer and clicking Approve opens a dedicated approval screen.
On the approval screen each requested line is shown alongside the available units at the source location, grouped by bin, lot, and expiration date. You assign units to each line by:
- Clicking individual units to toggle them in or out,
- Entering a quantity for a group to auto-select that many units from it, or
- Scanning barcodes — scan a bin barcode first to set context, then scan unit or Product barcodes to assign them one by one.
The screen shows whether each line is satisfied (assigned quantity meets the requested quantity) or still short. You can approve a transfer even if some lines are only partially filled — if the destination location has another source configured with auto-transfer enabled, the remaining shortfall is automatically routed to that source as a new Requested transfer.
Clicking Reject All cancels the transfer entirely and notifies the requester.
Once approved, the assigned units are placed On Hold at the source location and the transfer advances to Approved.
Shipping and delivery #
After approval, mark the transfer In Transit when the units leave the source (optionally recording a tracking number on the edit form). When the shipment arrives at the destination, advance the status to Delivered — this queues it for receiving.
Receiving a transfer #
When a transfer is Delivered, users at the destination location see it flagged for action. Opening it and clicking Receive opens the receiving screen.
Receiving needs update_inventory_transfers at the destination — see
Permissions below.
Before receiving any units, set the destination bin — either choose from the dropdown or scan a bin barcode. The location's default receiving bin is pre-selected if one is configured and it actually stands at that location — if the default has been pointed at another site's shelf, it is silently not offered, and you pick one yourself. Only bins at the transfer's destination are offered, and receiving into a bin anywhere else is refused: a bin belongs to a location, so choosing one at another site would move the stock there rather than shelve it where the truck actually arrived. Then receive units by:
- Scanning a unit barcode — receives that specific unit.
- Scanning a Product barcode — receives all in-transit units for that Product at once.
- Clicking Receive on an individual row.
- Receive Group — receives all in-transit units for a Product/lot/expiration group.
- Receive All — marks every remaining in-transit unit as received in one click.
Each received unit is moved to the selected destination bin, its status becomes In Stock, and a Transfer movement is written to its history. When every unit is received the transfer status automatically advances to Received.
Transfer lines and units #
A transfer has two levels of detail:
- Lines — one per Product, recording the
requested_quantityandapproved_quantity. Lines define what was asked for and what was committed. - Transfer Units — one per individual inventory unit selected during
approval, linked to a line. Each unit tracks its own status:
pending,approved,rejected,in_transit,received, ormissing.
Both are visible as tabs on the transfer's view page, alongside the full change history log.
Permissions and visibility #
Transfers respect per-location roles.
The location context filter narrows the list to transfers touching a specific
location. Creating requires create_inventory_transfers at at least one
location; editing a Draft requires create_inventory_transfers at its
destination, and editing one at any later status requires
update_inventory_transfers at whichever end owns that status — the destination
for Requested, Delivered, Received and Cancelled, the source for Approved,
In Transit, Delivered and Cancelled.
The transfer list narrows to transfers touching a site you hold a role at —
either end counts, since a transfer you are sending is as much yours as one you
are receiving. An account-wide role is not narrowed at all. What is enforced
beyond that is who can act: approving asks for update_inventory_transfers at
the source, and receiving asks for it at the destination.
The API is not narrowed the same way, and this is worth reading before you hand
out an API token. GET /api/v1/inventory/transfers returns every transfer in the
account whatever locations your role covers, because the narrowing above lives on
the panel's list rather than on the record itself. Filter with
from_location_id / to_location_id if you want one site's.
Nor are the actions all enforced the same way. Approving and receiving ask the
same question over the API that they ask in the panel — the source and the
destination respectively — but the other four status transitions ask only for the
endpoint permission, so a token whose role covers one site can move somebody
else's transfer to requested, in_transit, delivered or cancelled:
| Transition | Panel | API |
|---|---|---|
approved |
source location | source location |
received |
destination location | destination location |
requested, in_transit, delivered, cancelled |
your own sites only | any transfer in the account |
Two things limit what that costs. The lifecycle is enforced, so only a legal
next step is accepted from wherever the transfer currently stands: rewinding a
Delivered transfer to requested, or cancelling one already in transit, is
refused with a 422 whoever asks. And no transition over the API moves stock —
including the two that are guarded. They write a status column and a timestamp
and nothing more; shelving units is the panel's receive screen. What the guarded
pair protect is the record: a transfer marked Received while the lorry is still
full.
What remains is that a token can drive a transfer it does not own through the steps that are legal from where it is — cancelling one that has not shipped yet, or marking an approved transfer in transit and then delivered. Measured as a caller holding a role at neither end: all three return 200. Which end of a transfer should own each of those steps has not been settled, so nothing is enforced there rather than the wrong thing being enforced. Give transfer-writing API tokens to people you would trust with every site's transfers until it is.
Receiving being tied to the destination is newer than the rest of this page, and is the change most likely to be noticed: somebody who could previously open the receive screen for any transfer in the account now gets a refusal unless their role reaches the site the goods arrive at. That is deliberate. Booking in a delivery you are not standing next to marked the transfer Received while the stock never moved, because the units at a site you hold no role at are not visible to you — a closed transfer over a full lorry.
To Location offers every site, greying out the ones you may not transfer into and saying why on the option. It used to hide them, which left a destination silently missing from a six-site account. From Location is not narrowed at all, and deliberately: a transfer is raised by the site that wants the stock, so the source is nearly always somebody else's warehouse.
Doing it from the API #
# Create a transfer request with two lines
curl -X POST "https://your-domain.com/api/v1/inventory/transfers" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"from_location_id": "<source-location-id>",
"to_location_id": "<destination-location-id>",
"expected_delivery_date": "2026-06-15",
"notes": "Restocking for seasonal demand",
"lines": [
{"product_id": "<product-id>", "requested_quantity": 10},
{"product_id": "<another-product-id>", "requested_quantity": 5}
]
}'
# Advance the transfer to "in_transit"
curl -X POST "https://your-domain.com/api/v1/inventory/transfers/{id}/transition" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"to": "in_transit"}'
Filter the list by status, from_location_id, or to_location_id as query
parameters. Valid to values for the transition endpoint are: requested,
approved, in_transit, delivered, received, and cancelled.