South Arc Digital
Automation18 min read

Shopify to 3PL Integration: Fulfillment Orders API Patterns

Three shopify 3pl fulfillment orders api patterns that survived production: assignment-based routing, hybrid webhooks, and nightly state reconciliation.

Vignesh Ramakrishnan

The shopify 3pl fulfillment orders api sits at the fault line where a mid-market D2C brand's storefront meets a third-party warehouse's shipping stack. That line is where most integrations fail. A store adds a warehouse, the ops team plugs in a connector, order sync looks fine for six weeks, then Black Friday arrives and half the queue stalls because the connector was polling a fulfillment endpoint on a 5-minute cron while Shopify was rewriting fulfillment assignments in real time.

MetaPack's 2026 Ecommerce Delivery Benchmark Report found that fulfillment and delivery costs remain the top pressure on retailer margins, and consumer expectations for reliable delivery windows have not softened. Roughly 57% of ecommerce brands now outsource some or all of fulfillment, and the North American adoption rate is close to 46%. That is a lot of brands running a set of integration patterns that were not obvious when the Fulfillment Orders API replaced the legacy /fulfillments.json REST endpoints.

This post catalogs the three patterns that survived across the integrations we shipped: assignment-based routing through registered fulfillment services, a hybrid webhook-plus-poll flow, and nightly state reconciliation. We wrote it as a working reference for engineers wiring Shopify to ShipBob, ShipHero, or an in-house warehouse that speaks REST.

The business problem

The mid-market wall shows up around the third warehouse. A brand doing 3,000 orders a month with one warehouse can survive a fragile integration because the failure surface is small. At 12,000 orders across a domestic 3PL, a Canadian pass-through, and a Europe fulfillment partner, the same integration ships incorrect ETAs to customers, oversells a SKU that lives in only one bin, and misses cancellation windows because the connector processed an orders/cancel event ninety seconds after the 3PL had already picked the box.

The MetaPack 2026 report highlighted the same pattern from the retailer side. AI-augmented delivery orchestration and fulfillment cost pressure were the two top concerns cited by retailers heading into 2026, and both are downstream of how tightly the Shopify state machine is coupled to the 3PL's operational state. A brand that cannot answer "where is this order right now" inside its own admin, because the 3PL owns the definitive answer, will eat support tickets in proportion to volume.

The reason the wall shows up at the third warehouse is that Shopify's model of fulfillment shifted. The legacy fulfillments endpoint treated a fulfillment as a flat record attached to an order. The Fulfillment Orders API models a fulfillment order as a distinct object with its own state machine and its own assigned location. When a cart pulls two units from a Chicago warehouse and one unit from Atlanta, Shopify creates two fulfillment orders under the same order. Any integration that still treats fulfillment as an order-level concept will silently corrupt half of every split order.

The business cost is not one dramatic outage. It is drift. A finance team spending an afternoon a week reconciling shipments that never posted, a warehouse team hand-processing cancellations because the connector missed the cancellation request, a growth team unable to launch a second country because the current integration cannot support a second assigned location. The DCL 3PL performance metrics guide puts the industry benchmark for on-time shipping above 97% and order accuracy above 99%. Brands with drifting integrations rarely see either. We covered a version of the same drift problem from the finance side in why your Shopify-to-NetSuite sync keeps breaking; the shape is similar even when the counterparty is a warehouse instead of an ERP.

57%

of ecommerce brands outsource some or all fulfillment to a 3PL. North American adoption sits near 46% (Red Stag Fulfillment industry data, 2026).

The shopify 3pl fulfillment orders api model

The FulfillmentOrder object is the domain type worth understanding first, because every pattern that follows either respects its state machine or fights it. A fulfillment order represents a group of line items expected to ship from a single location. The parent order can contain multiple fulfillment orders. Each fulfillment order carries a status (from the FulfillmentOrderStatus enum) that walks through OPEN, IN_PROGRESS, and CLOSED, plus a requestStatus that tracks the fulfillment-service handshake (UNSUBMITTED, SUBMITTED, ACCEPTED, REJECTED, CANCELLATION_REQUESTED).

A minimal query looks like this:

// services/shopify/fulfillmentOrders.ts
export const GET_FULFILLMENT_ORDER = /* GraphQL */ `
  query FulfillmentOrder($id: ID!) {
    fulfillmentOrder(id: $id) {
      id
      status
      requestStatus
      assignedLocation { locationId name countryCode }
      lineItems(first: 50) {
        edges { node { id remainingQuantity variant { sku } } }
      }
      supportedActions { action externalUrl }
    }
  }
`;

The two fields we came back to on every integration were assignedLocation and supportedActions. The assigned location is how Shopify tells you where this shipment is meant to originate. If the assigned location is a fulfillment service you registered, you own the next state transition. If it is a merchant-managed location, you should stay out of the way. The supported actions list is the API's way of saying "here is what you are allowed to call right now against this fulfillment order." A fulfillment order in IN_PROGRESS might expose CREATE_FULFILLMENT and REQUEST_CANCELLATION, but not MOVE. Trying to call an unsupported action returns a user error that no amount of retry logic will fix.

The state machine surface matters because Shopify makes some transitions final. Once a fulfillment order is CLOSED with a fulfillment attached, the shipment is committed. Cancellation from that point requires the fulfillmentOrderSubmitCancellationRequest mutation from the merchant side and a matching fulfillmentOrderAcceptCancellationRequest from the 3PL side. Integrations that treat cancellation as "delete the order and move on" break the handshake and leave orphaned records in both systems.

The other detail that matters is inventory ownership. A fulfillment order tied to a fulfillment service inherits inventory management semantics from how the service was registered. That decision is made at registration time and quietly shapes every subsequent write. Pattern 1 walks through the registration mutation and the trade-offs the flags create.

The legacy /admin/api/{version}/orders/{order_id}/fulfillments.json REST endpoints are deprecated. The pre-2022 flow of "POST a fulfillment against the order" no longer works cleanly through a fulfillment service. Use fulfillmentCreate off a fulfillment order ID; the older fulfillmentCreateV2 mutation is also deprecated in the latest Admin API. If your codebase still references /fulfillments.json, treat it as legacy code that needs migration before your next Shopify API version bump, not as a working endpoint.

Pattern 1: Assignment-based routing via fulfillmentServiceCreate

The first pattern is structural, not runtime. Before any of the sync logic matters, the 3PL has to exist as a fulfillment service inside Shopify. Registering the service is a one-time write, but it changes how Shopify routes every subsequent order.

The fulfillmentServiceCreate mutation registers the service and automatically provisions a matching location. Shopify then treats that location as the assigned location for any line item flagged as fulfilled by the service. This is how Shopify auto-splits fulfillment orders when a cart pulls inventory from multiple warehouses.

// services/shopify/registerService.ts
export const REGISTER_SERVICE = /* GraphQL */ `
  mutation ServiceCreate($name: String!, $callbackUrl: URL!) {
    fulfillmentServiceCreate(
      name: $name
      callbackUrl: $callbackUrl
      trackingSupport: true
      inventoryManagement: true
    ) {
      fulfillmentService { id location { id name } }
      userErrors { field message }
    }
  }
`;

The callbackUrl is not decoration. Shopify uses it as the base URL for the fulfillment service's expected endpoints: /fetch_stock.json, /fetch_tracking_numbers.json, and, for services with inventoryManagement: true, an inventory levels endpoint. The fulfillment service apps guide covers the required contract. We shipped a version where the callback URL pointed at a Cloudflare Worker that proxied to the 3PL, because ShipBob and ShipHero do not expose Shopify-shaped endpoints natively, and the mid-tier connectors that promised to close the gap were unreliable in ways that were hard to debug from inside Shopify's admin.

Two behaviors of fulfillmentServiceCreate caught teams off guard.

First, inventoryManagement: true transfers inventory ownership for any SKU stocked at that location to the service. Shopify stops accepting manual inventory adjustments for those SKUs through the admin UI and expects the callback URL to answer for stock. If the service is unreachable, admin inventory looks stale. We split the flag decision per SKU on our end and only turned inventory management on for the SKUs the 3PL actually held, because letting Shopify think the 3PL owned all inventory broke the finance team's ability to fix mistakes in the admin.

Second, the auto-created location cannot be renamed to match the shop's country if the shop is in a different country. As of API version 2023-10, you have to run the locationEdit mutation after fulfillmentServiceCreate to change the country code and address. Skipping this step causes Shopify to compute tax and shipping rates against the wrong origin, and the failure is invisible until a customer sees an incorrect duties estimate at checkout.

Once the service is registered, Shopify's routing engine will assign new fulfillment orders to it automatically for SKUs stocked at the associated location. We treated the registration as a piece of infrastructure code, checked into our repo as a migration, and re-ran it in staging before any change to the callback URL. It is easier to test than to reason about, and treating it as configuration rather than one-off setup work saved us more than one incident. The same principle applied to the transport choice we made in Shopify GraphQL vs REST for real-time inventory sync: treat the substrate as something the CI pipeline touches, not something a person configures once through a UI.

Pattern 2: Poll and accept vs webhook and push

The runtime pattern is where most integrations pick the wrong default. Shopify offers two ways to learn that a new fulfillment order needs your attention: subscribe to a webhook and react, or poll the fulfillmentOrders query on a schedule. Neither is complete on its own.

The polling math is worth doing before defaulting to it. A fulfillmentOrders query filtered on status:open assigned_location_id:12345 costs roughly 3 to 7 points depending on the fields you request. On a standard Shopify plan with a 1,000-point bucket and 50-point-per-second restore rate, polling that query every 30 seconds burns roughly 12 to 14 points per minute, which is comfortable. Polling every second, which is where teams end up when the SLA is measured in seconds, will drain the bucket in under three minutes and start returning THROTTLED errors. Shopify Plus doubles both the bucket (2,000 points) and the restore rate (100 points/second), which the Shopify API limits page documents, but the shape of the problem does not change.

Webhooks look like the answer, and mostly they are. The relevant topics are fulfillment_orders/order_routing_complete, fulfillment_orders/moved, fulfillment_orders/cancellation_request_submitted, and fulfillment_orders/cancellation_request_accepted. Delivery is at-least-once, ordering is not guaranteed, and Shopify retries on a decaying schedule for up to 48 hours per the webhook delivery documentation.

The gotcha we hit in production: fulfillment_orders/order_routing_complete does not re-fire when an order is edited to add a line item that routes to a different location. A Shopify community thread documents the behavior. Teams that assumed the webhook covered all routing events discovered the gap when a customer service rep added a replacement line to an existing order and it never showed up at the 3PL.

Our production integrations settled on webhooks as the primary trigger and a nightly poll as the reconciliation floor. Every webhook handler verified the HMAC, parked the payload in a durable queue with an idempotency key, and let a downstream worker fetch the current fulfillment order state from GraphQL before taking any action.

// services/shopify/webhookHandler.ts
export async function handleFulfillmentOrderWebhook(req: Request) {
  const raw = await req.text();
  const sig = req.headers.get('x-shopify-hmac-sha256') ?? '';
  const expected = createHmac('sha256', SHOPIFY_SECRET).update(raw).digest('base64');
  if (!timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) {
    return new Response('bad hmac', { status: 401 });
  }
  const webhookId = req.headers.get('x-shopify-webhook-id') ?? '';
  const payload = JSON.parse(raw);
  await queue.enqueueOnce(`fo:${payload.id}:${webhookId}`, payload);
  return new Response('ok', { status: 200 });
}

The enqueueOnce call uses a Redis SETNX with a 48-hour TTL keyed on the webhook ID. Shopify's own webhook ID, delivered in X-Shopify-Webhook-Id, is unique per delivery attempt, which makes it a durable dedup key across Shopify's retries. We do not rely on the fulfillment order ID alone because the same fulfillment order can legitimately move through multiple states in quick succession and each is a distinct event we do want to process.

Compute the HMAC against the raw request body before any body parser touches it. Node body parsers (Express, most Cloudflare Worker frameworks) will silently rewrite whitespace and JSON key order, which breaks the signature comparison. Read the raw text, verify, then parse. The exact ordering is the difference between webhook verification working in local dev and failing in prod behind a middleware chain nobody remembers writing.

Pattern 3: State reconciliation and drift

Even with clean webhooks and idempotent handlers, Shopify's model of a fulfillment order and the 3PL's model of the same shipment drift. A ShipBob shipment marked Delivered in their portal does not always correspond to a CLOSED fulfillment order in Shopify, because a tracking event failed to propagate or the 3PL's webhook was delayed past its retry budget. Silent drift compounds. Two weeks after a launch, the ops team is chasing "why does Shopify show 40 unfulfilled orders that already shipped?"

The reconciliation pattern that worked for us ran nightly, treated the 3PL as the source of truth for physical state, and treated Shopify as the source of truth for order intent.

// jobs/reconcile.ts
export async function reconcile(runDate: string) {
  const openFOs = await shopify.paginate(GET_OPEN_FULFILLMENT_ORDERS);
  for (const fo of openFOs) {
    if (fo.assignedLocation.name !== '3pl-primary') continue;
    const shipment = await threePl.getShipmentByExternalRef(fo.id);
    if (shipment?.status === 'delivered' && fo.status !== 'CLOSED') {
      await shopify.createFulfillment(fo.id, {
        trackingInfo: shipment.tracking,
        notifyCustomer: false,
        idempotencyKey: `reconcile:${runDate}:${fo.id}`,
      });
    }
  }
}

We ran this at 3am against the previous day's window plus a 7-day lookback. Two rules turned out to matter. First, do not touch the 3PL during reconciliation. It is a read-only source from the reconciler's perspective. Writes go only to Shopify, because the drift is almost always Shopify catching up to physical reality rather than the reverse. Second, cap the batch. A drifting integration will produce hundreds of reconciling updates on the first run, and pushing all of them at once will drain the GraphQL bucket even on Plus. We used a per-run cap of 200 fulfillments with a smaller batch of 50 during business hours, so a bad run could not silently starve the support tool sharing the API bucket.

The reconciler is also a signal channel. When the number of fulfillment orders needing reconciliation jumps from a normal 5-10 per night to 100+, something in the live flow has broken. In one integration, the spike caught a rotated 3PL API key that had silently expired for outbound calls but not inbound ones. In another, it caught a Shopify API version bump that changed a payload field name for cancellation requests. The reconciler saw the drift before the ops team saw the tickets, which is the point.

Webhooks only. Fulfillment drift averaged 3.1% of orders per week across 14,000 monthly orders. Ops team spent 3-4 hours a week chasing shipped-but-unfulfilled orders in Shopify. Support tickets referencing wrong tracking status: 8-12 per week.

Webhooks plus nightly reconciler. Drift held at 0.4% across the same order volume. Ops review dropped to 30 minutes a week, mostly spent on the small tail of genuine 3PL errors the reconciler surfaces but does not auto-fix. Wrong-tracking tickets fell to under 2 per week.

Cross-cutting: idempotency, error taxonomy, observability

Three concerns thread through every pattern above.

Idempotency. Shopify's stance on idempotency has moved in the last two API versions. As of API version 2026-01, inventoryAdjustQuantities accepts an optional @idempotent directive; as of 2026-04, the directive is required. The pattern is not the HTTP Idempotency-Key header familiar from Stripe. Shopify uses a GraphQL directive on the mutation itself.

// services/shopify/inventoryAdjust.ts
export const ADJUST = /* GraphQL */ `
  mutation Adjust($input: InventoryAdjustQuantitiesInput!, $key: String!) {
    inventoryAdjustQuantities(input: $input) @idempotent(key: $key) {
      inventoryAdjustmentGroup { id }
      userErrors { field message }
    }
  }
`;

Keys are tracked for 24 hours. A duplicate within that window returns the cached response. A duplicate arriving while the first request is still processing returns IDEMPOTENCY_CONCURRENT_REQUEST. We derive the key from the logical operation, not the HTTP call: for a fulfillment write, the key is ${runDate}:${fulfillmentOrderId}:create, which means a retry of the same logical action reuses the key and a genuinely new attempt the next day generates a fresh one. The retry-safe architecture principles we wrote up in idempotent AI agents apply verbatim to fulfillment writes, and the discipline is easier to enforce here than in an LLM-shaped workflow because the operations are all named up front.

Error taxonomy. Shopify GraphQL surfaces errors in three places: HTTP status, top-level errors, and userErrors inside the mutation payload. Retriable failures usually appear as HTTP 429 or a THROTTLED GraphQL error. Terminal failures, such as an unsupported action or a stale fulfillment order state, appear as userErrors with codes like INVALID or NOT_FOUND. We built a classifier that mapped every observed error into retriable | terminal | needs_human and refused to retry anything that classified as terminal. The failure mode we saw in earlier drafts was a worker retrying a userErrors INVALID_FULFILLMENT_STATE for two hours because the retry loop did not check the payload's user errors before deciding to back off.

The reconciler saw the drift before the ops team saw the tickets. That is the point.

Observability. The two dashboards that paid for themselves: one showing the age of the oldest fulfillment order stuck in OPEN per assigned location, and one showing the ratio of webhook-driven fulfillments to reconciler-driven fulfillments. When the second ratio inverts, the live flow is failing and the reconciler is doing the work it was not supposed to do. Same signal as the drift spike in Pattern 3, surfaced as a leading indicator instead of a nightly one.

Observations

What worked. Treating the fulfillment order as the domain object, not the order. Once we rewrote our own internal state model to key on fulfillment order ID rather than order ID, the split-shipment logic simplified and the reconciler could compare like to like against the 3PL. Registering the fulfillment service through code and re-running it in staging before any callback URL change. Building the reconciler in week one instead of week ten.

What didn't. Trying to derive fulfillment state by polling. On a Plus store, we got away with a 15-second poll for a few months, then a marketing push tripled order volume and the bucket started throttling other GraphQL calls we cared about (order lookups from a support tool). We pulled the poll and kept only the nightly reconciler plus webhooks. The support tool stopped hitting the same rate limit and product started shipping normally.

What we'd do differently. We would build the callback URL proxy first, before touching Shopify. The 3PL side is where most of the integration debt lives (undocumented header requirements, inconsistent shipment ID formats, timezone assumptions in tracking events), and the proxy is the abstraction that makes swapping 3PLs feasible later. We would also enforce the @idempotent directive on every inventory-adjacent mutation from day one, not wait for the 2026-04 forcing function. If you are still writing Shopify integrations without idempotency keys, the API is about to make that impossible; move now.

References

  1. Metapack, "Ecommerce Delivery Benchmark Report 2026," Metapack, February 2026. https://www.metapack.com/resources/guides/ecommerce-delivery-benchmark-report-2026/
  2. Red Stag Fulfillment, "What Percentage of Ecommerce Brands Use a 3PL? 2026 Stats," 2026. https://redstagfulfillment.com/what-percentage-of-ecommerce-brands-use-3pl/
  3. DCL Logistics, "What KPIs Should You Track for Your 3PL? Metrics, Benchmarks, and SLA Guidance," 2026. https://dclcorp.com/blog/3pl/3pl-performance-metrics/
  4. Shopify, "FulfillmentOrder object," Shopify Admin GraphQL API documentation, 2026. https://shopify.dev/docs/api/admin-graphql/latest/objects/FulfillmentOrder
  5. Shopify, "FulfillmentOrderStatus enum," Shopify Admin GraphQL API documentation, 2026. https://shopify.dev/docs/api/admin-graphql/latest/enums/FulfillmentOrderStatus
  6. Shopify, "fulfillmentServiceCreate mutation," Shopify Admin GraphQL API documentation, 2026. https://shopify.dev/docs/api/admin-graphql/latest/mutations/fulfillmentServiceCreate
  7. Shopify, "fulfillmentOrderSubmitCancellationRequest mutation," Shopify Admin GraphQL API documentation, 2026. https://shopify.dev/docs/api/admin-graphql/latest/mutations/fulfillmentOrderSubmitCancellationRequest
  8. Shopify, "fulfillmentOrderAcceptCancellationRequest mutation," Shopify Admin GraphQL API documentation, 2026. https://shopify.dev/docs/api/admin-graphql/latest/mutations/fulfillmentOrderAcceptCancellationRequest
  9. Shopify, "Build for fulfillment services," Shopify developer documentation, 2026. https://shopify.dev/docs/apps/build/orders-fulfillment/fulfillment-service-apps
  10. Shopify, "API rate limits," Shopify developer documentation, 2026. https://shopify.dev/docs/api/usage/limits
  11. Shopify, "Deliver webhooks through HTTPS," Shopify developer documentation, 2026. https://shopify.dev/docs/apps/build/webhooks/subscribe/https
  12. Shopify Community, "Fulfillment_orders/order_routing_complete does not trigger on order edits," 2025. https://community.shopify.com/t/fulfillment-orders-order-routing-complete-does-not-trigger-on-order-edits/577610
  13. Shopify, "Adding idempotency for inventory adjustments and refund mutations," Shopify developer changelog, 2026. https://shopify.dev/changelog/adding-idempotency-for-inventory-adjustments-and-refund-mutations
  14. Shopify, "locationEdit mutation," Shopify Admin GraphQL API documentation, 2026. https://shopify.dev/docs/api/admin-graphql/latest/mutations/locationEdit

Stack: Node.js 20 and TypeScript on Cloudflare Workers for the webhook receiver and the 3PL callback proxy, Postgres for integration state, Redis for webhook dedup, Shopify Admin GraphQL API version 2026-04, ShipBob API version 2026-01, ShipHero's public GraphQL API. The nightly reconciler runs as a Google Cloud Run job; the live path runs as a long-lived worker behind the Cloudflare Worker webhook endpoint.


Want to build something like this?

We design and ship AI products, automation systems, and custom software.

Get in touch

Related