Diagnostic Fulfillment

Shopify orders stuck Unfulfilled past your shipping SLA

A customer paid three days ago and their order still has not shipped. It is not on hold, nothing is wrong with it, it just slipped through. Shopify shows it as Unfulfilled like a hundred others and never raises its hand. The first person to notice is usually the customer, asking where their order is. Here is why paid orders quietly sit unfulfilled and a small job that flags the overdue ones for review before they become complaints.

Python and Node.js Admin GraphQL API Read only apart from a tag
Round black and white analog alarm clock
Photo by insung yoon on Unsplash
The short answer

Shopify does not chase overdue orders, so a paid order can sit Unfulfilled for days. Run a small Python or Node.js job that lists paid, unfulfilled, not-cancelled orders, keeps the ones older than your shipping SLA that are not on hold, and tags them for review with tagsAdd. The team then sees every overdue order in one filtered view. The only change it makes is the tag. Full code, tests, and a dry run guard are below.

The problem in plain words

When an order is paid, it is ready to ship, and it waits at the Unfulfilled status until someone fulfills it. On a normal day the team works through them and they move to Fulfilled. That is the happy path.

But Shopify does not track how long an order has waited or warn you when one is late. A paid order that gets skipped, or that an app failed to fulfill, just stays Unfulfilled next to all the fresh ones. There is no clock, no alert, no escalation. It sits there looking normal until the customer emails asking where their package is.

Paid order Unfulfilled Days pass no alert past SLA Still not shipped looks normal Customer complains
Nothing tracks how long an order has waited, so an overdue one looks the same as a fresh one until the customer chases it.

Why it happens

An order does not get stuck for one dramatic reason, it gets stuck for small, ordinary ones:

None of these announce themselves. Shopify treats an hour-old order and a five-day-old order the same way. The fix is to add the missing clock: measure how long each unfulfilled order has waited and surface the ones past your shipping SLA. See the citations at the end for the docs on fulfillment status.

The key insight

The Unfulfilled status tells you what, not how long. Age is the signal that matters. By checking each paid, unfulfilled order against your SLA, and skipping the ones on hold on purpose, you turn a flat list into a short, ranked list of orders that actually need attention today.

The fix, as a flow

We do not fulfill or edit anything. We add a job that lists the paid, unfulfilled orders, works out how old each one is, and tags the ones older than your SLA that are not on hold. The tag puts every overdue order in one saved view so the team can clear them before they turn into late shipments.

Scheduled job daily List paid unfulfilled read created date Measure the age skip if on hold Past SLA? yes no, skip Tag for review overdue queue
Only paid, unfulfilled orders older than the SLA and not on hold are tagged. Fresh orders and held ones are left alone.

Build it step by step

1

Get an Admin API access token

Create a custom app in your Shopify admin under Settings, Apps and sales channels, Develop apps. Give it the read_orders and write_orders scopes and install it to get an Admin API access token that starts with shpat_. Keep the token and the shop domain in environment variables, never in the file.

setup (shell)
pip install requests

export SHOPIFY_SHOP="yourstore.myshopify.com"
export SHOPIFY_ACCESS_TOKEN="shpat_..."
export SLA_DAYS="3"
export REVIEW_TAG="fulfillment-overdue"
export DRY_RUN="true"   # start safe, change to false to tag
setup (shell)
// Node 18+ has fetch built in, no dependencies needed

export SHOPIFY_SHOP="yourstore.myshopify.com"
export SHOPIFY_ACCESS_TOKEN="shpat_..."
export SLA_DAYS="3"
export REVIEW_TAG="fulfillment-overdue"
export DRY_RUN="true"   // start safe, change to false to tag
2

List paid, unfulfilled orders

Ask for orders that are paid, unfulfilled, and not cancelled, using the search filter so the query only returns candidates. For each, read the created date, the fulfillment status, the tags, and the fulfillment orders so we can tell if it is on hold. We page through with a cursor.

step2.py
ORDERS_QUERY = """
query($cursor: String) {
  orders(first: 25, after: $cursor,
         query: "financial_status:paid AND fulfillment_status:unfulfilled AND -status:cancelled") {
    pageInfo { hasNextPage endCursor }
    nodes {
      id name createdAt displayFulfillmentStatus tags
      fulfillmentOrders(first: 10) { nodes { status } }
    }
  }
}"""

def unfulfilled_orders():
    cursor = None
    while True:
        data = gql(ORDERS_QUERY, {"cursor": cursor})["orders"]
        for node in data["nodes"]:
            yield node
        if not data["pageInfo"]["hasNextPage"]:
            return
        cursor = data["pageInfo"]["endCursor"]
step2.js
const ORDERS_QUERY = `
query($cursor: String) {
  orders(first: 25, after: $cursor,
         query: "financial_status:paid AND fulfillment_status:unfulfilled AND -status:cancelled") {
    pageInfo { hasNextPage endCursor }
    nodes {
      id name createdAt displayFulfillmentStatus tags
      fulfillmentOrders(first: 10) { nodes { status } }
    }
  }
}`;

async function* unfulfilledOrders() {
  let cursor = null;
  while (true) {
    const data = (await gql(ORDERS_QUERY, { cursor })).orders;
    for (const node of data.nodes) yield node;
    if (!data.pageInfo.hasNextPage) return;
    cursor = data.pageInfo.endCursor;
  }
}
3

Decide, with pure functions

The current time is passed in, which keeps the logic pure and testable. One function says whether any fulfillment order is on hold. The other says whether an order is overdue: unfulfilled, not already tagged, not on hold, and older than the SLA. Because the date is an input, we can test every edge without waiting for real days to pass.

decide.py
import datetime

def iso_to_epoch(iso):
    return datetime.datetime.fromisoformat(iso.replace("Z", "+00:00")).timestamp()

def is_on_hold(order):
    nodes = ((order.get("fulfillmentOrders") or {}).get("nodes")) or []
    return any(fo.get("status") == "ON_HOLD" for fo in nodes)

def is_stale_unfulfilled(order, now_epoch, sla_days, review_tag):
    if order.get("displayFulfillmentStatus") != "UNFULFILLED":
        return False
    if review_tag in (order.get("tags") or []):
        return False
    if is_on_hold(order):
        return False
    created = order.get("createdAt")
    if not created:
        return False
    age_days = (now_epoch - iso_to_epoch(created)) / 86400
    return age_days >= sla_days
decide.js
export function isoToEpoch(iso) {
  return Date.parse(iso) / 1000;
}

export function isOnHold(order) {
  const nodes = order.fulfillmentOrders?.nodes || [];
  return nodes.some((fo) => fo.status === "ON_HOLD");
}

export function isStaleUnfulfilled(order, nowEpoch, slaDays, reviewTag) {
  if (order.displayFulfillmentStatus !== "UNFULFILLED") return false;
  if ((order.tags || []).includes(reviewTag)) return false;
  if (isOnHold(order)) return false;
  if (!order.createdAt) return false;
  const ageDays = (nowEpoch - isoToEpoch(order.createdAt)) / 86400;
  return ageDays >= slaDays;
}
4

Tag the overdue orders

When an order is overdue, call tagsAdd with your review tag. That is the only change the job makes. It never fulfills, cancels, or edits the order, so a false positive is just an extra look. Read back userErrors and stop on them.

apply.py
TAGS_ADD = """
mutation($id: ID!, $tags: [String!]!) {
  tagsAdd(id: $id, tags: $tags) { node { id } userErrors { field message } }
}"""

def tag_for_review(order_id, review_tag):
    result = gql(TAGS_ADD, {"id": order_id, "tags": [review_tag]})["tagsAdd"]
    if result["userErrors"]:
        raise RuntimeError(result["userErrors"])
apply.js
const TAGS_ADD = `
mutation($id: ID!, $tags: [String!]!) {
  tagsAdd(id: $id, tags: $tags) { node { id } userErrors { field message } }
}`;

async function tagForReview(orderId, reviewTag) {
  const result = (await gql(TAGS_ADD, { id: orderId, tags: [reviewTag] })).tagsAdd;
  if (result.userErrors.length) throw new Error(JSON.stringify(result.userErrors));
}
5

Wire it together with a dry run guard

The loop ties every piece together, comparing each order's age to the SLA and skipping ones already tagged. On the first run, leave DRY_RUN on so it only reports which orders are overdue. Read the list, then switch it off. A daily run catches orders the morning after they cross the line.

Run it safe

Start with DRY_RUN=true. The only write is a tag, so even a false positive is harmless. Set your SLA_DAYS to match your real shipping promise, and set up a saved order view filtered by the tag so the team works a clear overdue queue.

The full code

Here is the complete job in one file for each language. It reads settings from the environment, logs what it does, respects the dry run flag, and is safe to run again and again because it skips orders on hold and ones already tagged.

View this code on GitHub Full runnable folder with tests in the shopify-fixes repo.

flag_stale_unfulfilled.py
"""Flag Shopify orders that are paid but still unfulfilled past your shipping SLA.
Read only apart from a review tag. Run on a schedule.
"""
import os
import time
import logging
import datetime
import requests

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("flag_stale_unfulfilled")

SHOP = os.environ["SHOPIFY_SHOP"]
TOKEN = os.environ["SHOPIFY_ACCESS_TOKEN"]
API_VERSION = os.environ.get("SHOPIFY_API_VERSION", "2025-01")
ENDPOINT = f"https://{SHOP}/admin/api/{API_VERSION}/graphql.json"
SLA_DAYS = float(os.environ.get("SLA_DAYS", "3"))
REVIEW_TAG = os.environ.get("REVIEW_TAG", "fulfillment-overdue")
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"

ORDERS_QUERY = """
query($cursor: String) {
  orders(first: 25, after: $cursor,
         query: "financial_status:paid AND fulfillment_status:unfulfilled AND -status:cancelled") {
    pageInfo { hasNextPage endCursor }
    nodes {
      id name createdAt displayFulfillmentStatus tags
      fulfillmentOrders(first: 10) { nodes { status } }
    }
  }
}"""

TAGS_ADD = """
mutation($id: ID!, $tags: [String!]!) {
  tagsAdd(id: $id, tags: $tags) { node { id } userErrors { field message } }
}"""


def gql(query, variables=None):
    r = requests.post(
        ENDPOINT,
        json={"query": query, "variables": variables or {}},
        headers={"X-Shopify-Access-Token": TOKEN, "Content-Type": "application/json"},
        timeout=30,
    )
    r.raise_for_status()
    body = r.json()
    if body.get("errors"):
        raise RuntimeError(body["errors"])
    return body["data"]


def iso_to_epoch(iso):
    return datetime.datetime.fromisoformat(iso.replace("Z", "+00:00")).timestamp()


def is_on_hold(order):
    nodes = ((order.get("fulfillmentOrders") or {}).get("nodes")) or []
    return any(fo.get("status") == "ON_HOLD" for fo in nodes)


def is_stale_unfulfilled(order, now_epoch, sla_days, review_tag):
    if order.get("displayFulfillmentStatus") != "UNFULFILLED":
        return False
    if review_tag in (order.get("tags") or []):
        return False
    if is_on_hold(order):
        return False
    created = order.get("createdAt")
    if not created:
        return False
    age_days = (now_epoch - iso_to_epoch(created)) / 86400
    return age_days >= sla_days


def tag_for_review(order_id, review_tag):
    result = gql(TAGS_ADD, {"id": order_id, "tags": [review_tag]})["tagsAdd"]
    if result["userErrors"]:
        raise RuntimeError(result["userErrors"])


def unfulfilled_orders():
    cursor = None
    while True:
        data = gql(ORDERS_QUERY, {"cursor": cursor})["orders"]
        for node in data["nodes"]:
            yield node
        if not data["pageInfo"]["hasNextPage"]:
            return
        cursor = data["pageInfo"]["endCursor"]


def run():
    now_epoch = time.time()
    flagged = 0
    for order in unfulfilled_orders():
        if not is_stale_unfulfilled(order, now_epoch, SLA_DAYS, REVIEW_TAG):
            continue
        log.warning("Order %s unfulfilled past SLA. %s", order["name"],
                    "would tag" if DRY_RUN else "tagging")
        if not DRY_RUN:
            tag_for_review(order["id"], REVIEW_TAG)
        flagged += 1
    log.info("Done. %d overdue order(s) %s.", flagged, "to tag" if DRY_RUN else "tagged")


if __name__ == "__main__":
    run()
flag-stale-unfulfilled.js
/**
 * Flag Shopify orders that are paid but still unfulfilled past your shipping SLA.
 * Read only apart from a review tag. Run on a schedule.
 */
import { pathToFileURL } from "node:url";

const SHOP = process.env.SHOPIFY_SHOP || "example.myshopify.com";
const TOKEN = process.env.SHOPIFY_ACCESS_TOKEN || "shpat_dummy";
const API_VERSION = process.env.SHOPIFY_API_VERSION || "2025-01";
const ENDPOINT = `https://${SHOP}/admin/api/${API_VERSION}/graphql.json`;
const SLA_DAYS = Number(process.env.SLA_DAYS || 3);
const REVIEW_TAG = process.env.REVIEW_TAG || "fulfillment-overdue";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

export function isoToEpoch(iso) {
  return Date.parse(iso) / 1000;
}

export function isOnHold(order) {
  const nodes = order.fulfillmentOrders?.nodes || [];
  return nodes.some((fo) => fo.status === "ON_HOLD");
}

export function isStaleUnfulfilled(order, nowEpoch, slaDays, reviewTag) {
  if (order.displayFulfillmentStatus !== "UNFULFILLED") return false;
  if ((order.tags || []).includes(reviewTag)) return false;
  if (isOnHold(order)) return false;
  if (!order.createdAt) return false;
  const ageDays = (nowEpoch - isoToEpoch(order.createdAt)) / 86400;
  return ageDays >= slaDays;
}

async function gql(query, variables = {}) {
  const res = await fetch(ENDPOINT, {
    method: "POST",
    headers: { "X-Shopify-Access-Token": TOKEN, "Content-Type": "application/json" },
    body: JSON.stringify({ query, variables }),
  });
  if (!res.ok) throw new Error(`Shopify ${res.status}`);
  const body = await res.json();
  if (body.errors) throw new Error(JSON.stringify(body.errors));
  return body.data;
}

const ORDERS_QUERY = `
query($cursor: String) {
  orders(first: 25, after: $cursor,
         query: "financial_status:paid AND fulfillment_status:unfulfilled AND -status:cancelled") {
    pageInfo { hasNextPage endCursor }
    nodes {
      id name createdAt displayFulfillmentStatus tags
      fulfillmentOrders(first: 10) { nodes { status } }
    }
  }
}`;

const TAGS_ADD = `
mutation($id: ID!, $tags: [String!]!) {
  tagsAdd(id: $id, tags: $tags) { node { id } userErrors { field message } }
}`;

async function* unfulfilledOrders() {
  let cursor = null;
  while (true) {
    const data = (await gql(ORDERS_QUERY, { cursor })).orders;
    for (const node of data.nodes) yield node;
    if (!data.pageInfo.hasNextPage) return;
    cursor = data.pageInfo.endCursor;
  }
}

async function tagForReview(orderId, reviewTag) {
  const result = (await gql(TAGS_ADD, { id: orderId, tags: [reviewTag] })).tagsAdd;
  if (result.userErrors.length) throw new Error(JSON.stringify(result.userErrors));
}

export async function run() {
  const nowEpoch = Date.now() / 1000;
  let flagged = 0;
  for await (const order of unfulfilledOrders()) {
    if (!isStaleUnfulfilled(order, nowEpoch, SLA_DAYS, REVIEW_TAG)) continue;
    console.warn(`Order ${order.name} unfulfilled past SLA. ${DRY_RUN ? "would tag" : "tagging"}`);
    if (!DRY_RUN) await tagForReview(order.id, REVIEW_TAG);
    flagged++;
  }
  console.log(`Done. ${flagged} overdue order(s) ${DRY_RUN ? "to tag" : "tagged"}.`);
}

if (import.meta.url === pathToFileURL(process.argv[1]).href) {
  run().catch((err) => { console.error(err); process.exit(1); });
}

Add a test

The overdue rule is the whole point, so it is the thing to test. Because the current time is passed in, the tests need no network, no store, and no waiting. They just feed in a fixed now and plain orders and check the result.

test_stale_unfulfilled.py
from flag_stale_unfulfilled import is_stale_unfulfilled, is_on_hold, iso_to_epoch

NOW = iso_to_epoch("2026-07-10T00:00:00Z")


def order(**over):
    base = {
        "displayFulfillmentStatus": "UNFULFILLED",
        "createdAt": "2026-07-01T00:00:00Z",  # 9 days old
        "tags": [],
        "fulfillmentOrders": {"nodes": [{"status": "OPEN"}]},
    }
    base.update(over)
    return base


def test_stale_when_old_and_unfulfilled():
    assert is_stale_unfulfilled(order(), NOW, 3, "fulfillment-overdue") is True


def test_not_stale_when_recent():
    assert is_stale_unfulfilled(order(createdAt="2026-07-09T00:00:00Z"), NOW, 3, "fulfillment-overdue") is False


def test_not_stale_when_already_fulfilled():
    assert is_stale_unfulfilled(order(displayFulfillmentStatus="FULFILLED"), NOW, 3, "fulfillment-overdue") is False


def test_not_stale_when_on_hold():
    o = order(fulfillmentOrders={"nodes": [{"status": "ON_HOLD"}]})
    assert is_stale_unfulfilled(o, NOW, 3, "fulfillment-overdue") is False


def test_exactly_at_sla_is_stale():
    assert is_stale_unfulfilled(order(createdAt="2026-07-07T00:00:00Z"), NOW, 3, "fulfillment-overdue") is True
stale.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { isStaleUnfulfilled, isOnHold, isoToEpoch } from "./flag-stale-unfulfilled.js";

const NOW = isoToEpoch("2026-07-10T00:00:00Z");

const order = (over = {}) => ({
  displayFulfillmentStatus: "UNFULFILLED",
  createdAt: "2026-07-01T00:00:00Z",
  tags: [],
  fulfillmentOrders: { nodes: [{ status: "OPEN" }] },
  ...over,
});

test("stale when old and unfulfilled", () => {
  assert.equal(isStaleUnfulfilled(order(), NOW, 3, "fulfillment-overdue"), true);
});

test("not stale when recent", () => {
  assert.equal(isStaleUnfulfilled(order({ createdAt: "2026-07-09T00:00:00Z" }), NOW, 3, "fulfillment-overdue"), false);
});

test("not stale when already fulfilled", () => {
  assert.equal(isStaleUnfulfilled(order({ displayFulfillmentStatus: "FULFILLED" }), NOW, 3, "fulfillment-overdue"), false);
});

test("not stale when on hold", () => {
  assert.equal(isStaleUnfulfilled(order({ fulfillmentOrders: { nodes: [{ status: "ON_HOLD" }] } }), NOW, 3, "fulfillment-overdue"), false);
});

test("exactly at SLA is stale", () => {
  assert.equal(isStaleUnfulfilled(order({ createdAt: "2026-07-07T00:00:00Z" }), NOW, 3, "fulfillment-overdue"), true);
});

Case studies

Buried in a rush

The orders that a busy week swallowed

During a promotion a store took far more orders than usual. A handful never got worked in the rush and stayed Unfulfilled, hidden among hundreds of fresh ones. A week later those customers were the ones filing complaints and chargebacks.

The daily job now tags any paid order past the three day SLA. The team works the overdue queue first each morning, and buried orders surface within a day instead of a week.

Silent app failure

The fulfillment app that dropped a few

A store used an app to auto-fulfill orders. Most of the time it worked, but now and then it failed on an order and moved on without a sound. Those orders sat Unfulfilled while everyone assumed the app had them.

The team ran the job in dry run, saw the exact orders the app had missed, then let it tag them. The overdue tag became their safety net for the app's silent failures.

What good looks like

After this runs on a schedule, an order that slips through is caught the next day, not by the customer. Your team works a short overdue queue instead of scanning every open order, late shipments and the complaints they cause drop, and the orders you deliberately put on hold stay out of the way. The missing clock is finally there.

FAQ

Why do Shopify orders stay Unfulfilled?

An order stays Unfulfilled until someone or something fulfills it. Shopify does not chase overdue ones, so a paid order can sit for days if it slips past the team, an app failed to fulfill it, or it was set aside and forgotten. Nothing raises a flag unless you build one.

How do I find overdue unfulfilled orders in Shopify?

List orders that are paid, unfulfilled, and not cancelled, then keep the ones created longer ago than your shipping SLA that are not on hold. Those are the orders at risk of a late shipment, and they are the ones worth surfacing for review.

Is it safe to run this on a live store?

Yes. The only change it makes is adding a review tag to overdue orders, so it never fulfills, cancels, or edits anything. It also skips orders on hold and ones already tagged. Start in dry run mode to see the list first.

Related field notes

Citations

On the problem:

  1. Shopify Help Center: fulfilling orders and fulfillment statuses. help.shopify.com/en/manual/fulfillment/managing-orders/fulfilling-orders
  2. Shopify Help Center: holding and releasing fulfillment. help.shopify.com fulfillment holds
  3. Shopify Community: orders stuck on Unfulfilled and how stores catch them. community.shopify.com shopify discussions

On the solution:

  1. Shopify Admin GraphQL: the orders query and its search syntax. shopify.dev/docs/api/admin-graphql/latest/queries/orders
  2. Shopify Admin GraphQL: the Order fulfillment fields and FulfillmentOrder status. shopify.dev/docs/api/admin-graphql/latest/objects/FulfillmentOrder
  3. Shopify Admin GraphQL: the tagsAdd mutation. shopify.dev/docs/api/admin-graphql/latest/mutations/tagsAdd

Stuck on a tricky one?

If you have a problem in Shopify orders, payments, subscriptions, inventory, or fulfillment that you would rather hand off, this is the kind of work I do. Message me and we can work through it together.

Contact me on LinkedIn

Did this catch a late order?

If this surfaced an order before the customer had to chase it, you can buy me a coffee. It is the best way to keep these field notes free and growing.

Buy me a coffee on Ko-fi

Back to all Shopify field notes