Book a 3PL's shipments

Turn a warehouse or 3PL's "it shipped" message into a shipment on the order, with the delivery-note number as the retry key, the carrier and tracking link, the stock steps that stay yours, and what a failed or wrong booking costs.

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.

3PL confirmationa delivery left, with its delivery-note numberorder foundexactly one order came backtracking linkoptional: url may be nullshipment bookednumber = the delivery-note numberstock committedif reserved: last part, or per part reference409 or 422: nothing bookednumber listed: doneelse a person looks at itGET /v1/orders?external_ref=POST /v1/shipping/trackingPOST /v1/orders/{id}/shipPOST /v1/inventories/commitsame number againGET /v1/orders/{id}your service, in orderthe platform refuses a repeat
Environment
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.

Create the key in Cockpit with custom scopes (see API keys and which scope covers which app). The link needs these, and no more:

ScopeFor
orders.listFinding the order by the id your system keeps for it (step 2)
orders.getReading the order's lines and the shipments already booked
orders.shippableAsking what is still open before you book
orders.shipBooking the shipment
orders.shipments.mark-deliveredOnly if the 3PL reports delivery
shipping.carriers.listOnly if you look up carrier codes (step 4)
shipping.trackingOnly if you build the tracking link from the carrier (step 4)
inventories.commitOnly if your checkout reserves stock (step 5)
inventories.stock.list, inventories.adjustOnly 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:

WhatThe platform's fieldWhat the 3PL sends back
The orderid, and external_ref if you wrote the 3PL's own order number into itYour order id, or the 3PL's order number
Each lineitems[].id, called order_item_id in a shipmentThe line id, or an sku you map to it
The shipmentnumber of the shipment, the delivery-note numberThe 3PL's delivery-note number
  • Write the 3PL's order number onto the order when you hand the order over. external_ref is stored on the order by POST /v1/orders/{id}/acknowledge, and GET /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 no external_ref: they carry an external_id that you may set when the order is placed, or you keep the items[].id next to the 3PL's line.
  • A shipment names a line by order_item_id, never by SKU or position. Read the ids from GET /v1/orders/{id} or from GET /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 a 409 and a shipment that was never booked.
  • external_id is 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.

Find the order
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:

Book a 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 positions with the real quantities. Leaving positions out ships everything still open, which is only right when the warehouse confirmed the whole order.
  • shipped_at is 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.
  • metadata on a line in positions keeps 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 201 with the shipment and the updated order. 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 repeatAnswer
The quantity it names is still open409
Nothing it names is open any more422. 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 order409, 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:

Is it booked?
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.

A shipment carries one carrier, one tracking code and one tracking link. The platform checks none of them (as of 2026-11).

  • carrier is free text, the name people and other systems read, such as DHL.
  • carrier_code is the carrier's code in the Shipping app, in lower case, such as dhl. List the codes with GET /v1/shipping/carriers (scope shipping.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_code is the consignment number as the carrier printed it, stored as text.
  • tracking_url is 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:
Build the tracking link
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-1 and LS-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_code and the others in the shipment's metadata. Nothing reads metadata (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:

Commit the stock
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.

Commit before you sync the count. The commit itself lowers 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 situationWhat it means and what to do
404No order has that id. The lookup or the id is wrong
400 positions[0]: unknown order_item_idThe 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
409See 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 shippingA hold blocks shipping. Release it, or park the message until the merchant does
422 order is cancelled — cannot ship, or order is completedThe goods left for an order the platform no longer ships. Park the message: the merchant decides about the parcel
422 order_pendingThe 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 403The 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: call orders.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

Was this page helpful?