Medusa fulfillment providers cannot see variant metadata
In Medusa 2.19.0 the admin create-fulfillment flow drops items.variant.metadata, so dropship and print-on-demand providers get line items they cannot map.
- Medusa.js
- Migrations
- Operations

Your dropship provider passes every unit test. Then the first real order goes through the admin — POST /admin/orders/:id/fulfillments — and the provider throws before it can talk to the vendor at all. You log the items argument it was handed and find that item.variant is undefined. So is item.variant_id. Not just the metadata you wanted: the entire variant reference is gone.
This is Medusa 2.19.0 (@medusajs/core-flows, @medusajs/fulfillment), reported upstream as medusajs/medusa#16621. If you are running a print-on-demand or dropship integration that keys on per-variant data — a vendor SKU cross-reference, a print file URL, a warehouse code — this is why your payloads are empty.
The manual provider never surfaces the problem
The stock manual provider does not read the items, so it does not care what shape they are in. Every non-manual provider does. The signature you implement against is createFulfillment(data, items, order, fulfillment), and the natural reading of that is items[].variant.metadata. That relation does not exist on the objects you actually receive, and nothing in the type flow tells you so before runtime.
The data is dropped in three places, not one
Traced through 2.19.0:
- The order query omits the field.
create-fulfillment.tshydrates the order withuseQueryGraphStepagainst a fixed field list. It asks foritems.variant.manage_inventory,items.variant.sku,items.variant.weight,items.variant.hs_code,items.variant.origin_countryand friends. It never asks foritems.variant.metadata. prepareFulfillmentDataflattens the lines. Each order item is reduced to{ line_item_id, inventory_item_id, quantity, title, sku, barcode }. Anything else on the variant is discarded here regardless of what step 1 fetched.fulfillment_itemcannot carry it anyway. The model is{ id, title, sku, barcode, quantity, line_item_id, inventory_item_id, fulfillment }. Novariantrelation, novariant_id. Those persisted rows are what the module service hands to your provider, verbatim.
That ordering matters. Fixing the query alone does not put a variant on items — the model has nowhere to put one.
Your fixtures are lying to you
The reason this ships to production is that test fixtures construct hydrated items, because that is what a human writes when reading the type. The real caller never produces them. If you have a fulfillment provider under test, throw away the hand-built item fixtures and assert against the shape a real admin fulfillment produces. One integration test that hits the admin route against a seeded order catches this class of bug permanently; a hundred unit tests with generous fixtures catch none of it.
The provider container is not the application container
The obvious escape hatch — resolve query inside the provider and re-fetch the variant yourself — does not work. A fulfillment provider is registered in the fulfillment module's container, not the app's. Enumerated at runtime on 2.19.0 it holds the module's own repositories and services plus logger, manager, configModule, event_bus, caching and __pg_connection__. There is no query, no remoteQuery, no product module. The module boundary is doing exactly what it is designed to do, and it is doing it to you.
Four workarounds, roughly in the order we would reach for them
Correlate against the order argument. The order is forwarded to the provider untouched. Match item.line_item_id against order.items[].id and read the variant from there. This is the correct pattern and it is not discoverable from the signature. It gets you whatever the hydration field list fetched — sku, variant_title, weights, HS codes — but not metadata, because of point 1 above.
Patch the field list. Adding "items.variant.metadata" to the query in create-fulfillment.ts is a one-line diff, and the report verifies the effect on a live 2.19.0 instance: the keys on items[0].variant go from manage_inventory, sku, weight, hs_code, id to including metadata. Ship it with patch-package or pnpm patch. You then own re-applying it on every upgrade until upstream merges something equivalent. Combine with the correlation above — the patch puts metadata on the order, never on items.
Hoist the fields into line item metadata at cart time. When the item is added to the cart, copy the vendor SKU, print file reference or warehouse code onto the line item's own metadata. This is the option we would default to for anything running long-term. It is independent of the variant relation, it survives the flattening, and it snapshots what the vendor agreed to at the moment of sale — which is what you want when a customer requests a reprint eighteen months later and the variant metadata has since changed. Cost: a backfill for open orders and one more thing to keep correct in the cart flow. Log Object.keys(order.items[0]) on your own instance before you rely on any particular field surviving; the hydration list is version-specific.
Move the vendor handoff into a subscriber. Subscribe to the fulfillment created event (check the event constants for your version) and do the vendor API call there, where you have the application container and a real query. The provider shrinks to a record-keeper. The trade is real: you lose the synchronous failure, so an unmappable line no longer blocks the admin fulfillment — it fails minutes later in a queue. Take this route only with retry, a dead-letter path, and somewhere an operator can actually see stuck handoffs.
One related note: additional_data on the admin fulfillment route does survive the path to the provider. It is useful for per-fulfillment operator input — a rush flag, a chosen packaging — and it is a poor substitute for variant data that should have been on the line all along.
Do not plan around an upstream merge date
The issue includes the one-line patch and an offer to open the PR. It does not name a release that fixes it. Treat the fix as unscheduled and pick a workaround you would be comfortable keeping.
If you are moving off Magento 2, budget for this whole category
In Magento a shipment item keeps you one hop from the product, and integrations are written on that assumption without anyone noticing they made it. Medusa's modules do not share a container, and fulfillment_item is deliberately narrow. That is the trade you accept for module boundaries, and it is mostly a good trade — but it means every integration that quietly assumed "I can reach the product from here" needs an explicit, designed data path before it works.
The practical move during a Magento 2 to Medusa migration is to enumerate them in discovery: for each outbound integration, write down which fields it needs, which argument they arrive in, and what happens when they are absent. It is a boring afternoon that prevents this exact 400 on launch day.
Start by logging the real items and order shapes on your own instance rather than trusting the signature. Then decide between metadata-at-cart-time and a subscriber, and write the integration test that goes through the admin route. If you are scoping a migration and want the integration inventory done before you commit to a cutover date, get in touch.
Need this done on a real stack?
Magento 2, Adobe Commerce, migrations to Medusa.js or Vendure, enterprise Next.js, WordPress, and AI automation.
Contact us