Reading Shopify purchase orders in n8n (new 2026-10 API): a morning list of late supplier stock

Shopify’s purchase orders have been readable from the Admin API since version 2026-10 (inventoryPurchaseOrders). Before that they were preview-only. I built a small workflow on it that emails one morning list: shipments that are overdue, arriving this week, partly received, and POs the supplier hasn’t shipped yet.

How it works (11 nodes + an error alert)
Schedule → Settings → HTTP Request (Shopify OAuth2 credential, GraphQL) → Code (collect page) → IF (more pages? loops back through a Wait that only pauses when Shopify’s cost bucket runs low) → Code (sort) → Switch (email / optional Google Sheet).

The query, with status filter and everything needed for the four lists:

query($after: String) {
  inventoryPurchaseOrders(first: 50, after: $after, query: "status:ORDERED") {
    pageInfo { hasNextPage endCursor }
    nodes {
      id name orderedAt origin { supplierName } destination { name }
      lineItems(first: 50) { nodes { title variantTitle totalQuantity } }
      transfers(first: 10) { nodes {
        totalQuantity receivedQuantity
        shipments(first: 10) { nodes {
          status dateReceived
          tracking { arrivesAt company trackingNumber trackingUrl }
        } }
      } }
    }
  }
}

The expected date lives on the shipment (tracking.arrivesAt), not on the PO. Overdue = a shipment IN_TRANSIT or PARTIALLY_RECEIVED whose arrivesAt is before today; “not shipped” = no shipment in those states or RECEIVED. Fully received POs (received ≥ ordered) are skipped.

Things that cost me time

  1. Three scopes, not one: read_inventory_purchase_orders, read_inventory_transfers and read_inventory_shipments. Without the last one the query fails at shipments with “Access denied for shipments field.”
  2. “Oauth error failed_grant_with_invalid_scopes” when installing the app, although all scope names are valid. The cause was “Use legacy install flow” being on in the app version (Dev Dashboard). It rejected these newer scopes; with it off, the install went through.
  3. InventoryPurchaseOrderLineItem has no productVariant field. Use inventoryItem { variant { id } } if you need to match PO lines to orders.
  4. The admin link to a PO is https://admin.shopify.com/store/<shop>/purchase_orders/<numeric id from the gid>.

A replay of real runs on a test store (report email, tracking, partly received): Incoming Stock Tracker Pro live demo · TrenoxaHQ

Happy to share the workflow JSON or answer questions on the purchase order / transfer / shipment model.

Useful write-up — the three-scope trap is the one I see people hit most often with the 2026-10 PO API.

Two extra gotchas that bit me on a multi-location store:

  1. arrivesAt timezone. Shopify returns tracking.arrivesAt in UTC (sometimes date-only at midnight). If you compare that to new Date() in the shop’s local day without normalizing, you get false “overdue” on the morning of arrival, or you miss it entirely. I normalize both sides to the shop timezone’s date string before comparing.

  2. Multi-destination noise. query: "status:ORDERED" pulls every location. For a morning ops email I also filter on destination (name or location id) in the Code node, otherwise one late PO for a secondary warehouse drowns the list that actually needs action today.

On the cost-bucket Wait: if the loop ever spins without the page cursor moving, log extensions.cost.throttleStatus from the GraphQL response. A short Wait only helps when currentlyAvailable is low; if you’re already under the restore rate, paging 50 with a fixed ~1s Wait is enough and avoids looking hung.

Happy to dig into line-item → variant matching via inventoryItem { variant { id } } if you hit that next.

Thanks, these are useful additions.

Timezone: you caught a real bug in my version. I was taking the date with slice(0, 10) on arrivesAt, which gives the UTC date, while “today” came from $now in the workflow’s timezone. On a store well east or west of UTC, an arrival close to midnight could land on the wrong day. Fixed now: timestamps are converted to the workflow timezone before comparing, and date-only values stay as they are:

const day = v => !v ? '' : String(v).length <= 10 ? String(v)
  : DateTime.fromISO(String(v), { setZone: true }).setZone($now.zone).toISODate();

Multiple destinations: agreed. The email already shows a Destination column, but on a store with several warehouses that doesn’t stop the list getting long. A destinations setting that filters in the Code node is an easy addition.

Throttling: good point about logging throttleStatus. I’ll log it when the cursor doesn’t move instead of always waiting.

Matching line items to variants is probably next, so I may take you up on that.