Repair Fulfillment
Fulfillment stuck On hold
A hold was placed on a fulfillment order for a real reason: a fraud flag, a bad address, an item that was out of stock. Someone fixed the reason. Nobody ever released the hold. The fulfillment order still shows On hold, the order still will not ship, and it will sit there forever unless something calls the release. Here is why Shopify leaves it stuck and a small script that releases the specific holds that are actually safe to clear, and nothing else.
A fulfillment order stays On hold until every fulfillment hold on it is released, even after the reason behind the hold no longer applies. Run a small Python or Node.js script that lists fulfillment orders on hold with manualHoldsFulfillmentOrders, keeps only the holds your own app applied whose reason is safe to clear automatically, and only on orders that carry a confirmation tag a human adds once the problem is actually fixed, then releases exactly those hold ids with fulfillmentOrderReleaseHold. Full code, tests, and a dry run guard are below.
The problem in plain words
Shopify lets a merchant or an app put a fulfillment order on hold. Maybe the order looked like fraud, maybe the address needs a fix, maybe an item is out of stock. That is by design. It stops the warehouse from shipping something that should not go out yet.
The trouble starts after the reason goes away. The card is verified, the address is corrected, the item is back in stock, but the hold itself is a separate object that Shopify does not clear on its own. Someone has to call the release. If nothing does, the fulfillment order sits at status ON_HOLD forever, invisible in most day to day views, until a customer asks where their order is.
Why it happens
A hold is its own record attached to the fulfillment order, not a flag that Shopify recalculates. Fixing the underlying problem does not touch the hold. A few common ways stores end up with a pile of stuck holds:
- A fraud or risk app puts a hold on suspicious orders, a person clears the order by hand or by phone, and the app is never told to release its hold.
- An out of stock item triggers a hold, new stock arrives, but the restock process only updates inventory and never calls the release.
- An address validation step holds the order, support fixes the address in a note or a different system, and the fix never reaches the app that placed the hold.
- An app is uninstalled or a workflow changes, and the holds it created are now orphaned, with no automation left that knows to clear them.
This is a common source of confusion. Store owners see the order marked paid and ready, wonder why it will not ship, and only find the fulfillment hold after digging into the order's fulfillment orders. Shopify does let a person release a hold from the Admin, but hunting through orders one at a time does not scale once the backlog grows, and clicking release on the wrong hold can let something ship before it should. See the citations at the end for the exact docs.
Releasing a hold is a claim that the reason for it no longer applies. So the safe pattern is not "release every On hold order." It is "release only the specific holds a human has confirmed are resolved, and only reasons that are safe to clear on their own." We do that with a tag added once the fix is confirmed, a fixed list of reasons the job is allowed to touch, and by always passing the exact holdIds to the release mutation instead of leaving it blank.
The fix, as a flow
We do not touch orders that were never held, and we do not release holds we do not recognize. We add a job that lists fulfillment orders currently on hold, checks each hold this app applied against a short list of reasons that are fine to clear automatically, and only proceeds once the order carries a confirmation tag. It then releases exactly those hold ids, never the whole set, and calls the release mutation the same way the Admin button would.
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 and write scopes for fulfillment orders 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 HOLD_RESOLVED_TAG="hold-resolved"
export DRY_RUN="true" # start safe, change to false to write
// Node 18+ has fetch built in, no dependencies needed
export SHOPIFY_SHOP="yourstore.myshopify.com"
export SHOPIFY_ACCESS_TOKEN="shpat_..."
export HOLD_RESOLVED_TAG="hold-resolved"
export DRY_RUN="true" // start safe, change to false to write
Talk to the Admin GraphQL API
Every call goes to one GraphQL endpoint with your token in the X-Shopify-Access-Token header. A small helper sends a query and returns the data, and raises if Shopify reports an error. We use this same helper to read fulfillment orders and to run the release mutation.
import os, requests
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"
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"]
const SHOP = process.env.SHOPIFY_SHOP;
const TOKEN = process.env.SHOPIFY_ACCESS_TOKEN;
const API_VERSION = process.env.SHOPIFY_API_VERSION || "2025-01";
const ENDPOINT = `https://${SHOP}/admin/api/${API_VERSION}/graphql.json`;
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;
}
List the fulfillment orders that are on hold
Shopify has a query built exactly for this, manualHoldsFulfillmentOrders, which returns fulfillment orders that currently have a hold applied. We read back the fields the decision needs: the status, the parent order's name and tags, and each hold's id, reason, and whether this app applied it. We page through with a cursor so the job handles a large backlog.
HELD_ORDERS_QUERY = """
query($cursor: String) {
manualHoldsFulfillmentOrders(first: 25, after: $cursor) {
pageInfo { hasNextPage endCursor }
nodes {
id
status
order { id name tags }
fulfillmentHolds {
id
reason
reasonNotes
heldByRequestingApp
}
}
}
}"""
def held_fulfillment_orders():
cursor = None
while True:
data = gql(HELD_ORDERS_QUERY, {"cursor": cursor})["manualHoldsFulfillmentOrders"]
for node in data["nodes"]:
yield node
if not data["pageInfo"]["hasNextPage"]:
return
cursor = data["pageInfo"]["endCursor"]
const HELD_ORDERS_QUERY = `
query($cursor: String) {
manualHoldsFulfillmentOrders(first: 25, after: $cursor) {
pageInfo { hasNextPage endCursor }
nodes {
id
status
order { id name tags }
fulfillmentHolds {
id
reason
reasonNotes
heldByRequestingApp
}
}
}
}`;
async function* heldFulfillmentOrders() {
let cursor = null;
while (true) {
const data = (await gql(HELD_ORDERS_QUERY, { cursor })).manualHoldsFulfillmentOrders;
for (const node of data.nodes) yield node;
if (!data.pageInfo.hasNextPage) return;
cursor = data.pageInfo.endCursor;
}
}
Decide, with one pure function
Keep the decision in its own function that takes a fulfillment order and the required confirmation tag and returns the list of hold ids that are safe to release, never a blanket yes or no for the whole order. A pure function like this is easy to read and easy to test, which we do later. The rule is strict on purpose. The fulfillment order must actually be On hold, the parent order must carry the confirmation tag, and for each hold, this app must have applied it and its reason must be in a fixed safe list. High risk of fraud is left out of that list on purpose, since that call always needs a person.
RELEASABLE_REASONS = {
"INVENTORY_OUT_OF_STOCK",
"INCORRECT_ADDRESS",
"AWAITING_PAYMENT",
"AWAITING_RETURN_ITEMS",
"UNKNOWN_DELIVERY_DATE",
"ONLINE_STORE_POST_PURCHASE_CROSS_SELL",
"OTHER",
}
def holds_to_release(fulfillment_order, resolved_tag):
if fulfillment_order.get("status") != "ON_HOLD":
return []
order = fulfillment_order.get("order") or {}
if resolved_tag not in (order.get("tags") or []):
return []
ids = []
for hold in fulfillment_order.get("fulfillmentHolds") or []:
if not hold.get("heldByRequestingApp"):
continue
if hold.get("reason") not in RELEASABLE_REASONS:
continue
ids.append(hold["id"])
return ids
const RELEASABLE_REASONS = new Set([
"INVENTORY_OUT_OF_STOCK",
"INCORRECT_ADDRESS",
"AWAITING_PAYMENT",
"AWAITING_RETURN_ITEMS",
"UNKNOWN_DELIVERY_DATE",
"ONLINE_STORE_POST_PURCHASE_CROSS_SELL",
"OTHER",
]);
export function holdsToRelease(fulfillmentOrder, resolvedTag) {
if (fulfillmentOrder.status !== "ON_HOLD") return [];
const order = fulfillmentOrder.order || {};
if (!(order.tags || []).includes(resolvedTag)) return [];
const ids = [];
for (const hold of fulfillmentOrder.fulfillmentHolds || []) {
if (!hold.heldByRequestingApp) continue;
if (!RELEASABLE_REASONS.has(hold.reason)) continue;
ids.push(hold.id);
}
return ids;
}
Release only those hold ids
Call fulfillmentOrderReleaseHold with the fulfillment order id and the exact holdIds the decision function returned. Passing the ids matters. Leaving holdIds out releases every hold on the fulfillment order, including ones you never checked, which is exactly the mistake this script exists to avoid. Always read back userErrors, and stop on it rather than assume the release worked.
RELEASE_MUTATION = """
mutation($id: ID!, $holdIds: [ID!]) {
fulfillmentOrderReleaseHold(id: $id, holdIds: $holdIds) {
fulfillmentOrder { id status }
userErrors { field message }
}
}"""
def release_holds(fulfillment_order_id, hold_ids):
result = gql(RELEASE_MUTATION, {"id": fulfillment_order_id, "holdIds": hold_ids})[
"fulfillmentOrderReleaseHold"
]
if result["userErrors"]:
raise RuntimeError(result["userErrors"])
return result["fulfillmentOrder"]["status"]
const RELEASE_MUTATION = `
mutation($id: ID!, $holdIds: [ID!]) {
fulfillmentOrderReleaseHold(id: $id, holdIds: $holdIds) {
fulfillmentOrder { id status }
userErrors { field message }
}
}`;
async function releaseHolds(fulfillmentOrderId, holdIds) {
const result = (await gql(RELEASE_MUTATION, { id: fulfillmentOrderId, holdIds })).fulfillmentOrderReleaseHold;
if (result.userErrors.length) throw new Error(JSON.stringify(result.userErrors));
return result.fulfillmentOrder.status;
}
Wire it together with a dry run guard
The loop ties every piece together. Notice the dry run guard. On the first few runs, leave DRY_RUN on so the script only reports which fulfillment orders it would release holds on. Read the output, agree with it, then switch it off to let it write. Run it on a schedule that matches how often holds are cleared, for example every fifteen minutes.
Always start with DRY_RUN=true, and only tag an order once a human has confirmed the reason for the hold is actually resolved. Always pass explicit holdIds to the mutation. Releasing a hold tells Shopify the block is gone, so the tag is your proof, not a guess.
The full code
Here is the complete script 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 only releases holds this app applied, with a reason on the safe list, on orders that carry your confirmation tag.
View this code on GitHub Full runnable folder with tests in the shopify-fixes repo.
"""Release Shopify fulfillment holds that were never cleaned up.
A fulfillment order can be put on hold for a real reason (fraud review, an
address problem, waiting on stock) and that reason gets fixed, but nothing
ever calls fulfillmentOrderReleaseHold, so the order sits at status ON_HOLD
forever. This job pages through manualHoldsFulfillmentOrders, keeps only the
holds this app itself applied whose reason is one we are allowed to clear on
our own, and only on orders a human has tagged as resolved, then releases
those specific hold ids with fulfillmentOrderReleaseHold. It never releases a
hold it does not recognize, and it never releases every hold on an order
blindly. Run on a schedule. Safe to run again and again.
"""
import os
import logging
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("release_fulfillment_holds")
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"
RESOLVED_TAG = os.environ.get("HOLD_RESOLVED_TAG", "hold-resolved")
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
# Reasons this job is allowed to clear on its own, once a human has tagged the
# order resolved. High risk of fraud is left out on purpose: that call always
# needs a person, never a script.
RELEASABLE_REASONS = {
"INVENTORY_OUT_OF_STOCK",
"INCORRECT_ADDRESS",
"AWAITING_PAYMENT",
"AWAITING_RETURN_ITEMS",
"UNKNOWN_DELIVERY_DATE",
"ONLINE_STORE_POST_PURCHASE_CROSS_SELL",
"OTHER",
}
HELD_ORDERS_QUERY = """
query($cursor: String) {
manualHoldsFulfillmentOrders(first: 25, after: $cursor) {
pageInfo { hasNextPage endCursor }
nodes {
id
status
order { id name tags }
fulfillmentHolds {
id
reason
reasonNotes
heldByRequestingApp
}
}
}
}"""
RELEASE_MUTATION = """
mutation($id: ID!, $holdIds: [ID!]) {
fulfillmentOrderReleaseHold(id: $id, holdIds: $holdIds) {
fulfillmentOrder { id status }
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 holds_to_release(fulfillment_order, resolved_tag):
"""Pure decision: which hold ids on this fulfillment order are safe to release.
Only holds this app applied itself (heldByRequestingApp) count, only when
the reason is in RELEASABLE_REASONS, and only when the order carries the
confirmation tag a human adds once the underlying problem is actually
fixed. Everything else is left alone for a person to release by hand.
"""
if fulfillment_order.get("status") != "ON_HOLD":
return []
order = fulfillment_order.get("order") or {}
if resolved_tag not in (order.get("tags") or []):
return []
ids = []
for hold in fulfillment_order.get("fulfillmentHolds") or []:
if not hold.get("heldByRequestingApp"):
continue
if hold.get("reason") not in RELEASABLE_REASONS:
continue
ids.append(hold["id"])
return ids
def held_fulfillment_orders():
cursor = None
while True:
data = gql(HELD_ORDERS_QUERY, {"cursor": cursor})["manualHoldsFulfillmentOrders"]
for node in data["nodes"]:
yield node
if not data["pageInfo"]["hasNextPage"]:
return
cursor = data["pageInfo"]["endCursor"]
def release_holds(fulfillment_order_id, hold_ids):
result = gql(RELEASE_MUTATION, {"id": fulfillment_order_id, "holdIds": hold_ids})[
"fulfillmentOrderReleaseHold"
]
if result["userErrors"]:
raise RuntimeError(result["userErrors"])
return result["fulfillmentOrder"]["status"]
def run():
released = 0
for fo in held_fulfillment_orders():
hold_ids = holds_to_release(fo, RESOLVED_TAG)
if not hold_ids:
continue
order_name = (fo.get("order") or {}).get("name", fo["id"])
log.info(
"Fulfillment order %s has %d releasable hold(s). %s",
order_name, len(hold_ids), "would release" if DRY_RUN else "releasing",
)
if not DRY_RUN:
release_holds(fo["id"], hold_ids)
released += 1
log.info("Done. %d fulfillment order(s) %s.", released, "to release" if DRY_RUN else "released")
if __name__ == "__main__":
run()
/**
* Release Shopify fulfillment holds that were never cleaned up.
*
* A fulfillment order can be put on hold for a real reason (fraud review, an
* address problem, waiting on stock) and that reason gets fixed, but nothing
* ever calls fulfillmentOrderReleaseHold, so the order sits at status ON_HOLD
* forever. This job pages through manualHoldsFulfillmentOrders, keeps only
* the holds this app itself applied whose reason is one we are allowed to
* clear on our own, and only on orders a human has tagged as resolved, then
* releases those specific hold ids with fulfillmentOrderReleaseHold. Run on
* a schedule. Safe to run again and again.
*/
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 RESOLVED_TAG = process.env.HOLD_RESOLVED_TAG || "hold-resolved";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
// Reasons this job is allowed to clear on its own, once a human has tagged
// the order resolved. High risk of fraud is left out on purpose: that call
// always needs a person, never a script.
export const RELEASABLE_REASONS = new Set([
"INVENTORY_OUT_OF_STOCK",
"INCORRECT_ADDRESS",
"AWAITING_PAYMENT",
"AWAITING_RETURN_ITEMS",
"UNKNOWN_DELIVERY_DATE",
"ONLINE_STORE_POST_PURCHASE_CROSS_SELL",
"OTHER",
]);
export function holdsToRelease(fulfillmentOrder, resolvedTag) {
if (fulfillmentOrder.status !== "ON_HOLD") return [];
const order = fulfillmentOrder.order || {};
if (!(order.tags || []).includes(resolvedTag)) return [];
const ids = [];
for (const hold of fulfillmentOrder.fulfillmentHolds || []) {
if (!hold.heldByRequestingApp) continue;
if (!RELEASABLE_REASONS.has(hold.reason)) continue;
ids.push(hold.id);
}
return ids;
}
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 HELD_ORDERS_QUERY = `
query($cursor: String) {
manualHoldsFulfillmentOrders(first: 25, after: $cursor) {
pageInfo { hasNextPage endCursor }
nodes {
id
status
order { id name tags }
fulfillmentHolds {
id
reason
reasonNotes
heldByRequestingApp
}
}
}
}`;
const RELEASE_MUTATION = `
mutation($id: ID!, $holdIds: [ID!]) {
fulfillmentOrderReleaseHold(id: $id, holdIds: $holdIds) {
fulfillmentOrder { id status }
userErrors { field message }
}
}`;
async function* heldFulfillmentOrders() {
let cursor = null;
while (true) {
const data = (await gql(HELD_ORDERS_QUERY, { cursor })).manualHoldsFulfillmentOrders;
for (const node of data.nodes) yield node;
if (!data.pageInfo.hasNextPage) return;
cursor = data.pageInfo.endCursor;
}
}
async function releaseHolds(fulfillmentOrderId, holdIds) {
const result = (await gql(RELEASE_MUTATION, { id: fulfillmentOrderId, holdIds })).fulfillmentOrderReleaseHold;
if (result.userErrors.length) throw new Error(JSON.stringify(result.userErrors));
return result.fulfillmentOrder.status;
}
export async function run() {
let released = 0;
for await (const fo of heldFulfillmentOrders()) {
const holdIds = holdsToRelease(fo, RESOLVED_TAG);
if (!holdIds.length) continue;
const orderName = (fo.order || {}).name || fo.id;
console.log(
`Fulfillment order ${orderName} has ${holdIds.length} releasable hold(s). ${DRY_RUN ? "would release" : "releasing"}`
);
if (!DRY_RUN) await releaseHolds(fo.id, holdIds);
released++;
}
console.log(`Done. ${released} fulfillment order(s) ${DRY_RUN ? "to release" : "released"}.`);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The decision rule is the part most worth testing, because it decides which holds get cleared on a real store. Because we kept holds_to_release pure, the test needs no network and no Shopify account. It just feeds in plain objects and checks the answer.
from release_fulfillment_holds import holds_to_release, RELEASABLE_REASONS
def hold(**over):
base = {
"id": "gid://shopify/FulfillmentHold/1",
"reason": "INVENTORY_OUT_OF_STOCK",
"reasonNotes": "Waiting on new shipment",
"heldByRequestingApp": True,
}
base.update(over)
return base
def fulfillment_order(**over):
base = {
"id": "gid://shopify/FulfillmentOrder/1",
"status": "ON_HOLD",
"order": {"id": "gid://shopify/Order/1", "name": "#1001", "tags": ["hold-resolved"]},
"fulfillmentHolds": [hold()],
}
base.update(over)
return base
def test_releases_when_on_hold_tagged_and_reason_is_releasable():
fo = fulfillment_order()
assert holds_to_release(fo, "hold-resolved") == ["gid://shopify/FulfillmentHold/1"]
def test_skips_when_not_on_hold():
fo = fulfillment_order(status="OPEN")
assert holds_to_release(fo, "hold-resolved") == []
def test_skips_when_order_missing_confirmation_tag():
fo = fulfillment_order(order={"id": "gid://shopify/Order/1", "name": "#1001", "tags": []})
assert holds_to_release(fo, "hold-resolved") == []
def test_skips_holds_not_applied_by_this_app():
fo = fulfillment_order(fulfillmentHolds=[hold(heldByRequestingApp=False)])
assert holds_to_release(fo, "hold-resolved") == []
def test_skips_high_risk_of_fraud_even_if_tagged():
fo = fulfillment_order(fulfillmentHolds=[hold(reason="HIGH_RISK_OF_FRAUD")])
assert holds_to_release(fo, "hold-resolved") == []
assert "HIGH_RISK_OF_FRAUD" not in RELEASABLE_REASONS
import { test } from "node:test";
import assert from "node:assert/strict";
import { holdsToRelease, RELEASABLE_REASONS } from "./release-fulfillment-holds.js";
const hold = (over = {}) => ({
id: "gid://shopify/FulfillmentHold/1",
reason: "INVENTORY_OUT_OF_STOCK",
reasonNotes: "Waiting on new shipment",
heldByRequestingApp: true,
...over,
});
const fulfillmentOrder = (over = {}) => ({
id: "gid://shopify/FulfillmentOrder/1",
status: "ON_HOLD",
order: { id: "gid://shopify/Order/1", name: "#1001", tags: ["hold-resolved"] },
fulfillmentHolds: [hold()],
...over,
});
test("releases when on hold, tagged, and reason is releasable", () => {
const fo = fulfillmentOrder();
assert.deepEqual(holdsToRelease(fo, "hold-resolved"), ["gid://shopify/FulfillmentHold/1"]);
});
test("skips when not on hold", () => {
const fo = fulfillmentOrder({ status: "OPEN" });
assert.deepEqual(holdsToRelease(fo, "hold-resolved"), []);
});
test("skips high risk of fraud even if tagged", () => {
const fo = fulfillmentOrder({ fulfillmentHolds: [hold({ reason: "HIGH_RISK_OF_FRAUD" })] });
assert.deepEqual(holdsToRelease(fo, "hold-resolved"), []);
assert.equal(RELEASABLE_REASONS.has("HIGH_RISK_OF_FRAUD"), false);
});
Case studies
The risk app that never let go
A skincare store used a fraud screening app that put risky orders on hold automatically. Support cleared dozens of them by phone every week, confirming the buyer and the card, but the app itself was never told, so the hold stayed and the order never shipped until someone noticed the customer complaint.
Now a support agent adds hold-resolved the moment a call clears the order. A job checks every fifteen minutes, sees the tag, confirms the hold reason is on the safe list, and releases it. Fraud holds without the tag are never touched, and nothing ships before a person says so.
The backorder that got stock but stayed stuck
A hardware brand put fulfillment orders on hold for out of stock items, then restocked weekly. Inventory updated fine, but nothing reconnected the restock to the specific held fulfillment orders, so a growing pile of paid orders sat On hold with plenty of stock sitting right next to them.
The restock process now tags the affected orders once inventory is confirmed. The script picks up the tag, checks the hold reason is inventory related, and releases it. The backlog cleared in one run, and new restocks now flow straight through without a manual sweep.
After this runs on a schedule, a resolved hold is a quick tag away from a fulfillment order that can actually ship. Fraud and address problems still wait on a person's judgment, stock and address fixes clear themselves once confirmed, and nobody has to click through orders one by one looking for stuck holds. Keep the tag step with a human, since that is what keeps the script honest.
FAQ
Why is my Shopify fulfillment order stuck On hold?
A fulfillment order moves to On hold when a fulfillment hold is applied to it, for example for a fraud review, a bad address, or an out of stock item. Shopify will not let the order be fulfilled while any hold is active, and it stays that way until every hold on it is released, even after the underlying reason has been fixed.
Is it safe to release fulfillment holds with a script?
Yes, when the script only releases holds it recognizes as its own, limits itself to reasons that are safe to clear automatically, and only acts on orders a human has tagged as resolved. It also passes the specific hold ids to fulfillmentOrderReleaseHold rather than releasing every hold on the order, so it never clears a hold it does not understand.
Why does releasing all holds on an order cause problems?
If you call fulfillmentOrderReleaseHold without hold ids, Shopify releases every hold on that fulfillment order, including ones you did not check. That can let the order start fulfilling before an unrelated fraud review or address problem is actually resolved, so Shopify recommends always passing the exact hold ids you intend to release.
Related field notes
Citations
On the problem:
- Shopify Admin GraphQL: the FulfillmentOrder object, including status and fulfillmentHolds. shopify.dev/docs/api/admin-graphql/latest/objects/FulfillmentOrder
- Shopify Admin GraphQL: the FulfillmentHold object and its reason field. shopify.dev/docs/api/admin-graphql/latest/objects/FulfillmentHold
- Shopify Admin GraphQL: the fulfillmentOrderHold mutation that places a hold in the first place. shopify.dev/docs/api/admin-graphql/latest/mutations/fulfillmentOrderHold
On the solution:
- Shopify Admin GraphQL: the fulfillmentOrderReleaseHold mutation and its holdIds argument. shopify.dev/docs/api/admin-graphql/latest/mutations/fulfillmentOrderReleaseHold
- Shopify Admin GraphQL: the manualHoldsFulfillmentOrders query for listing fulfillment orders on hold. shopify.dev/docs/api/admin-graphql/latest/queries/manualHoldsFulfillmentOrders
- Shopify Admin GraphQL: the FulfillmentOrderStatus enum, including ON_HOLD. shopify.dev/docs/api/admin-graphql/latest/enums/FulfillmentOrderStatus
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 clear your stuck fulfillments?
If this saved you a pile of manual clicks or a customer complaint about a missing order, 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