Book a 3PL's shipments
Your 3PL packs the goods, hands them to a carrier and tells you so. This guide is what happens next: the message becomes one POST /v1/orders/{id}/ship call on the order, made so that a retried message books nothing twice (when you send the delivery-note number as number), with the carrier and tracking link the buyer needs and the stock steps that the shipment does not do for you.
It is for the developer who owns the link between a warehouse and the platform: a small service of your own, or the 3PL's own integration, that holds an API key. The call itself, its part shipments and its refusals are in Ship, cancel and return an order. A workflow that books placed orders in an ERP and takes the reports back is Hand an order to the ERP. Here the caller is the warehouse, so the questions are different: which key it holds, which of its numbers joins the two systems, and what to do when its message arrives twice or is wrong. You need a tenant with the Orders app, orders that already carry an external_ref (set by the ERP acknowledgement, see step 2) if you find orders by the 3PL's number, and Shipping and Inventories if you use steps 4 and 5.
export REVENEXX_TENANT="<TENANT_SLUG>"
export REVENEXX_API_KEY="rvxk_..."
REVENEXX_API_KEY is the 3PL link's key from step 1. Every call goes through the API gateway with the same two headers. The examples use curl and jq, and a value in angle brackets is an id from your own data.
1. Give the 3PL link its own key
Create the key in Cockpit with custom scopes (see API keys and which scope covers which app). The link needs these, and no more:
| Scope | For |
|---|---|
orders.list | Finding the order by the id your system keeps for it (step 2) |
orders.get | Reading the order's lines and the shipments already booked |
orders.shippable | Asking what is still open before you book |
orders.ship | Booking the shipment |
orders.shipments.mark-delivered | Only if the 3PL reports delivery |
shipping.carriers.list | Only if you look up carrier codes (step 4) |
shipping.tracking | Only if you build the tracking link from the carrier (step 4) |
inventories.commit | Only if your checkout reserves stock (step 5) |
inventories.stock.list, inventories.adjust | Only if the 3PL's counts feed the shop's stock (step 5) |
Narrow scopes limit which operations the key can call, not which orders. A key with orders.ship can book a shipment on any order of the tenant, so keep it in the service that talks to the 3PL, never in a browser or a script the warehouse can edit.
2. Pick the join keys
A confirmation from the warehouse has to name three things that the platform can resolve:
| What | The platform's field | What the 3PL sends back |
|---|---|---|
| The order | id, and external_ref if you wrote the 3PL's own order number into it | Your order id, or the 3PL's order number |
| Each line | items[].id, called order_item_id in a shipment | The line id, or an sku you map to it |
| The shipment | number of the shipment, the delivery-note number | The 3PL's delivery-note number |
- Write the 3PL's order number onto the order when you hand the order over.
external_refis stored on the order byPOST /v1/orders/{id}/acknowledge, andGET /v1/orders?external_ref=<3PL_ORDER_NUMBER>finds the order again. How that call behaves, and why it can be made only once, is in Hand an order to the ERP. Order items have noexternal_ref: they carry anexternal_idthat you may set when the order is placed, or you keep theitems[].idnext to the 3PL's line. - A shipment names a line by
order_item_id, never by SKU orposition. Read the ids fromGET /v1/orders/{id}or fromGET /v1/orders/{id}/shippable, and map the warehouse's line to one of them. - Use the 3PL's delivery-note number as
number. It is the key that makes a retry safe (step 3), and the number a buyer quotes when asking after the parcel. It is unique across the whole tenant, not per order. A warehouse that restarts its numbering, or reuses one across orders, gets a409and a shipment that was never booked. external_idis optional. It holds the key the warehouse system uses for the shipment, if that differs from the document number, and it is unique across the tenant as well. Send it only if your system looks the shipment up by that key.
3. Book the shipment, safely
When the warehouse confirms, find the order, check what is open, and book exactly what the warehouse says left.
ORDER_ID=$(curl -s "https://api.revenexx.com/v1/orders?external_ref=<3PL_ORDER_NUMBER>" \
-H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
-H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
| jq -er 'if (.items | length) == 1 then .items[0].id else error("expected one order") end') || exit 1
external_ref has no unique index, so the lookup can return no order or more than one. Check that exactly one came back, as above. Then book the shipment:
curl -s -X POST "https://api.revenexx.com/v1/orders/$ORDER_ID/ship" \
-H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
-H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"number": "LS-80012345",
"external_id": "wms_delivery_55120",
"carrier": "DHL",
"carrier_code": "dhl",
"tracking_code": "00340434161094042557",
"tracking_url": "https://www.dhl.de/de/privatkunden/pakete-empfangen/verfolgen.html?piececode=00340434161094042557",
"shipped_at": "2026-11-05T14:30:00Z",
"positions": [
{ "order_item_id": "<ORDER_ITEM_ID>", "quantity": 4, "metadata": { "batch": "CH-2026-0912" } }
]
}'
- Book what the warehouse says left, not what you expect. Send
positionswith the real quantities. Leavingpositionsout ships everything still open, which is only right when the warehouse confirmed the whole order. shipped_atis when the goods left, an RFC 3339 timestamp. It defaults to now, so send the warehouse's time when your service books later than the handover.metadataon a line inpositionskeeps what belongs to the line and not the parcel, such as a batch or a bin. It is stored and returned untouched, and nothing reads it.- Name each line once in
positions. Each entry is checked against the open quantity as it stood before the call, so two entries for one line can together ship more than is open (read from the source, not tested, as of 2026-11). - The answer is
201with theshipmentand the updatedorder. A tenant that ships complete orders only, or an order on hold, refuses the call: those cases are in Ship, cancel and return an order.
Partial shipments
A warehouse that ships in parts sends one confirmation per part. Book each with its own delivery-note number and the quantities of that part. The first one sets fulfillment_status to partial, and the one that leaves nothing open sets it to fulfilled. Whether the order then completes is the tenant's auto_complete_on setting. If a backordered line will never ship, cancel its open quantity with items/cancel instead of waiting, as described in Ship, cancel and return an order.
A stock commit takes the whole order_ref, so with reserved stock either commit only on the last part, or reserve each part under its own reference from the start and commit the reference that shipped (step 5).
When the same confirmation arrives twice
A 3PL resends a message after a timeout, and your own service retries a call whose answer was lost. With your own number, a repeat books nothing:
| The repeat | Answer |
|---|---|
| The quantity it names is still open | 409 |
| Nothing it names is open any more | 422. With positions: positions[0]: ship quantity N exceeds the remaining 0. Without positions: nothing left to ship on this order. On an order the repeat completed: order is completed — cannot ship |
| The delivery-note number belongs to a shipment on another order | 409, and nothing is booked for this order |
A 409 or 422 therefore does not tell you whether your first call worked. Read the order and look for the delivery-note number:
curl -s "https://api.revenexx.com/v1/orders/$ORDER_ID" \
-H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
-H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
| jq '.shipments[] | select(.number == "LS-80012345") | {id, number, tracking_code}'
If the shipment is listed under that order, the message is done: acknowledge it to the warehouse. If it is not, the 409 was a real conflict, such as a number or external_id that another shipment holds, and the message goes to a person. Never answer a 409 by retrying with a different number: that books the same goods a second time.
A call without number draws the next delivery-note number from the tenant's range and books a second shipment as long as quantity is open. For a warehouse integration, always send the number.
4. Carrier, tracking code and link
A shipment carries one carrier, one tracking code and one tracking link. The platform checks none of them (as of 2026-11).
carrieris free text, the name people and other systems read, such asDHL.carrier_codeis the carrier'scodein the Shipping app, in lower case, such asdhl. List the codes withGET /v1/shipping/carriers(scopeshipping.carriers.list). Orders stores the code as you send it and does not check that it exists, so a typo books a shipment that no carrier report will group. Map the 3PL's carrier names to codes in your service, and send both fields.tracking_codeis the consignment number as the carrier printed it, stored as text.tracking_urlis not built by the platform when you book. If you leave it out, the shipment has no link. Take the one your 3PL sends, or ask the Shipping app to render the carrier's template:
curl -s -X POST https://api.revenexx.com/v1/shipping/tracking \
-H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
-H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
-H "Content-Type: application/json" \
-d '{"carrier":"dhl","tracking_code":"00340434161094042557"}' | jq -r '.url'
The answer is 200 even when no link can be built: url is then null with a reason, for example no_template for a carrier that publishes no link. A carrier code the tenant does not keep is a 404. Pass postal_code or country only when the template names them. Check for null before you book, and decide whether a shipment without a link is acceptable.
Several parcels in one shipment
One shipment holds one tracking code. When what the warehouse sent in one confirmation leaves in several parcels, you have two options:
- One shipment per parcel, each with its own delivery-note number such as
LS-80012345-1andLS-80012345-2, naming the lines and quantities in that parcel. Each parcel then has its own tracking code, and the order's open quantities add up correctly. This only works if you can tell which quantity is in which parcel. - One shipment, with the main code in
tracking_codeand the others in the shipment'smetadata. Nothing readsmetadata(as of 2026-11), so the buyer sees one code.
Telling the buyer
Booking a shipment sends no email and prints no label. Nothing in the platform mails the buyer on a shipment that we could find (as of 2026-11), so the message to the buyer is yours. The order.shipment_created event carries the carrier, tracking_code and tracking_url, which is what a mail needs. Subscribe with a webhook or a workflow trigger, and read the exact topic from the event catalog as in Hand an order to the ERP.
Report delivery
If the 3PL also reports delivery, POST /v1/orders/shipments/{id}/mark-delivered (with an optional delivered_at and note) records it on the shipment, marked as set by hand. It books no quantities. A repeat is a 422. No carrier tracking feeds this status on its own (as of 2026-11).
5. Stock stays your job
Booking a shipment does not touch stock (as of 2026-11): Orders and Inventories do not read each other. Two things are left, and which of them applies depends on who holds the stock numbers.
Commit the reservation, if your checkout reserved stock. Do it when the goods leave, under the order_ref the reservation used:
curl -s -X POST https://api.revenexx.com/v1/inventories/commit \
-H "X-Revenexx-Tenant: $REVENEXX_TENANT" \
-H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
-H "Content-Type: application/json" \
-d '{"order_ref":"<ORDER_REF>"}'
A commit takes the whole reference and lowers both on_hand and reserved by the reserved quantity. A cart conversion reserves stock only when the tenant's convert_reserves_stock setting is request or require; its default is never, so there may be nothing to commit. A repeat answers 422 no active reservations for order_ref: treat it as done. Part shipments and the cart-conversion reference are in Ship, cancel and return an order.
Sync the levels, if the 3PL counts the stock. The Inventories API has no write that sets a balance. A PUT on a stock row drops on_hand, and a body with nothing else is a 422. The 3PL's absolute count has to become a signed POST /v1/inventories/adjust: the count minus the row's current on_hand, read with GET /v1/inventories/stock?sku=<SKU>. Run a nightly price and stock sync walks the arithmetic and the retry traps, and POST /v1/inventories/receive is the call for goods that arrive.
on_hand. If the 3PL's count after the shipment says 90 for a SKU the shop shows at 100, and the order held 10, adjusting by -10 and then committing leaves 80. Commit first, read on_hand again, then adjust by the remaining difference (as of 2026-11).6. When a booking fails or is wrong
| Answer or situation | What it means and what to do |
|---|---|
404 | No order has that id. The lookup or the id is wrong |
400 positions[0]: unknown order_item_id | The line id is not one of this order's items[].id. Map the warehouse's line to the id, not to the SKU or the position |
409 | See the table in step 3. Read the order before you retry |
422 … exceeds the remaining … | The warehouse says more left than is open, often because part of the line was cancelled. First look for the delivery-note number on the order's shipments (step 3): a repeat of a booked shipment ends here too. If it is not there, read GET /v1/orders/{id}/shippable, and do not cap the quantity silently: a person has to reconcile it |
422 order is on hold … unhold before shipping | A hold blocks shipping. Release it, or park the message until the merchant does |
422 order is cancelled — cannot ship, or order is completed | The goods left for an order the platform no longer ships. Park the message: the merchant decides about the parcel |
422 order_pending | The order waits for approval and was never placed. Do not book against it |
422 this merchant ships complete orders only … | The tenant refuses part shipments. Hold the confirmation until the rest left too |
401 or 403 | The tenant slug or key is wrong, or the key lacks the scope |
Three cases need a decision of your own, because the platform offers no undo:
- A shipment cannot be changed or removed once it is booked. Orders has no route to edit its tracking code, correct a quantity or void it (as of 2026-11); the only route on a shipment after booking is
mark-delivered. So validate before you book: callorders.shippable, compare quantities, and book only a confirmation the warehouse has closed. - A wrong tracking code or link stays on the shipment. The buyer's mail is yours, so send the corrected code there, and keep your own record of which code is right.
- Goods that never left, or came back, are not un-shipped. A return books what came back: see Ship, cancel and return an order. Whether that is right for a parcel that was lost, or one that never left the warehouse, is a business decision for the merchant. Neither is documented as a pattern (as of 2026-11).
Where to go next
- Read this next: Ship, cancel and return an order — what each booking does to the order, and the stock, promotion and money steps after it.
- Hand an order to the ERP — the other direction: sending the placed order to the system that fulfils it.
- Run a nightly price and stock sync — turning absolute counts into signed adjustments.
- Receive platform events at your own endpoint — reacting to
order.shipment_createdfrom a server of your own. - Orders — shipping, holds and the order statuses route by route.