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.
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.
Why it happens
An order does not get stuck for one dramatic reason, it gets stuck for small, ordinary ones:
- A busy day left a few orders unworked, and they got buried under the next day's batch.
- A fulfillment app or a third party warehouse failed to fulfill an order and nothing retried it.
- An order was set aside for a missing item or a question, then forgotten once the blocker cleared.
- A partial fulfillment left the rest waiting, and no one circled back to finish it.
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 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.
Build it step by step
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.
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
// 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
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.
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"]
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;
}
}
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.
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
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;
}
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.
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"])
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));
}
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.
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 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 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.
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
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
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.
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.
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:
- Shopify Help Center: fulfilling orders and fulfillment statuses. help.shopify.com/en/manual/fulfillment/managing-orders/fulfilling-orders
- Shopify Help Center: holding and releasing fulfillment. help.shopify.com fulfillment holds
- Shopify Community: orders stuck on Unfulfilled and how stores catch them. community.shopify.com shopify discussions
On the solution:
- Shopify Admin GraphQL: the
ordersquery and its search syntax. shopify.dev/docs/api/admin-graphql/latest/queries/orders - Shopify Admin GraphQL: the Order fulfillment fields and FulfillmentOrder status. shopify.dev/docs/api/admin-graphql/latest/objects/FulfillmentOrder
- Shopify Admin GraphQL: the
tagsAddmutation. 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.
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