DEV Community

life
life

Posted on

A Shopify Order From a China Warehouse Is Six Decision Points, Not One API Call

The mental model most sellers carry into a China fulfillment arrangement is a single arrow: an order lands in Shopify, a webhook fires, a warehouse prints a label. Everything after that is a tracking number.

That arrow hides six decisions. The reason it is worth naming them one by one is that each one has a place where it can be made automatically, a place where it has to be made by a person, and a place where it is currently being made by nobody. The third category is where parcels get stuck.

One: intake, keyed on something stable

An order arrives. Before anything physical happens, the question is whether this is a new instruction or a repeat of one you already acted on. Any intake path that can deliver the same event twice has to be keyed on a stable identifier rather than on arrival time, and the key has to be unique in the store, not just in the queue.

The failure looks like this: two warehouse tasks for one order, two labels, two charges, and a customer who receives the same item twice and is charged once. The fix is a constraint, not a check. If the intake table has a unique index on the order identifier, a duplicate cannot be created; if the only protection is a worker noticing, it will eventually not be noticed.

We subscribe to Shopify's orders/create topic, and the topic name is the easy part. The part that decides whether the integration survives is what happens to the second delivery of the same event.

Two: the address, before anything is printed

An address that will fail at the destination should fail at the origin. Shopify already models this as a first-class reason: INCORRECT_ADDRESS is one of the enumerated hold reasons on a fulfillment order, alongside INVENTORY_OUT_OF_STOCK, AWAITING_PAYMENT, HIGH_RISK_OF_FRAUD, AWAITING_RETURN_ITEMS, UNKNOWN_DELIVERY_DATE, ONLINE_STORE_POST_PURCHASE_CROSS_SELL and OTHER.

That list is worth reading once, because it tells you what the platform expects a fulfillment flow to be able to stop for. A warehouse integration that can only report "shipped" and "not shipped" has nowhere to put six of those eight states, so it puts them in email.

Three: inventory that is promised, not merely counted

A single stock pool shared by several sales channels needs a reservation ledger rather than a computed "available" number, because the number you compute at read time is already stale by the time the picker sees it. For a Shopify-only setup the same logic applies at the location level: the quantity you decrement is a statement about what a specific location owes, and the timing of that decrement determines whether two channels can both sell the last unit.

Four: the packing decision, which is a customs decision

Two items that would each clear a destination's low-value threshold become one consignment that does not when a picker puts them in the same box. The check therefore belongs before the parcel is created, in the packing decision, and not in a report afterwards.

This is the decision most often made by whoever is standing at the bench. It is also the one that is cheapest to encode, because it needs exactly two inputs: the declared value already captured on the order, and the destination's current threshold from a dated rule table. We keep our prep and packing rules in that shape, one row per rule with the date the rule was read from its source, because a threshold that moved last month should not silently change what a shipment from March was judged against.

Five: the hold state, which Shopify already gives you

This is the decision point that separates a real integration from a queue with a label printer attached.

A fulfillment order in Shopify has statuses, and two of them exist precisely for "do not ship this yet": ON_HOLD, and SCHEDULED, which carries a fulfill_at timestamp for deferred release. The mutations are fulfillmentOrderHold (it takes the fulfillment order id and a hold object, and the reason comes from the enumeration above) and fulfillmentOrderReleaseHold, which returns the order to OPEN.

Now compare what happens in a typical China-side arrangement when an order cannot go out. The order sits in the warehouse system's own "pending" bucket. The merchant's Shopify admin shows nothing unusual. The seller finds out from a customer message.

The mapping that has to exist is short:

What stopped the order Where it should be visible Shopify state
Payment or fraud review Store and warehouse ON_HOLD with AWAITING_PAYMENT / HIGH_RISK_OF_FRAUD
Address failed validation Store and warehouse ON_HOLD with INCORRECT_ADDRESS
Stock committed elsewhere Store and warehouse ON_HOLD with INVENTORY_OUT_OF_STOCK
Awaiting a restock date Store and warehouse SCHEDULED with fulfill_at
Documents incomplete Store and warehouse ON_HOLD with OTHER plus a note
Ready Warehouse queue OPEN

The row that matters is the last-but-one. OTHER is not a junk slot; it is where a provider's own reasons go, and a note attached to a hold in the merchant's store is a different service level from the same fact arriving in a weekly email.

Six: handoff and the loop back

A tracking number written into Shopify is not the end of the flow. The exception loop is what makes the previous five decisions improve: a parcel returned undelivered, a shipment held at a border, a carton rejected at a receiving center. Each of those has to come back as a row with an owner, not as a message in a thread.

If a provider cannot show you one closed exception from last quarter, with the rule that triggered it, who owned it, and how long it stayed open, then the exception log is a feature of their dashboard rather than a record of your operation.

What should stay human

Three cases, and it is worth being explicit because over-automating them is how sellers get surprised:

A hold whose reason is OTHER and whose note is ambiguous. An address that fails validation a second time. An item whose remaining shelf life clears the requirement by a thin margin in a category that expects a buffer. In each case the correct system behavior is to stop and ask, and the correct provider behavior is to have a named person who gets asked.

Automation in a fulfillment arrangement is not there to remove judgment. It is there to make judgment necessary only at the three or four places where judgment actually changes the outcome, and to make every one of those places visible in the same screen the seller is already looking at.

FulfillNexa by SBT (fulfillnexa.com) runs inbound, storage and outbound for Shopify and marketplace orders from three warehouses in China, with 3,000 square meters in Shenzhen, 13,000 in Suzhou and 8,000 in Dongguan. We do not publish rates or transit commitments, and nothing above depends on either. The status names, hold reasons and mutation names quoted here are Shopify's own published Admin GraphQL vocabulary, and the version to work against is whatever their docs say on the day you build.

Top comments (0)