Reconciler Inventory
Shopify Available drifted from the real on-hand count
A cycle count says forty units are on the shelf. Shopify says Available is thirty one. Nobody changed anything on purpose, a POS miscount here, a damaged unit written off there, a warehouse webhook that never arrived, and now the number on the store and the number on the shelf quietly disagree. Here is why that gap opens up and a small script that closes it with a compare-and-set write, so it never overwrites a change that just happened.
Available drifts because things change stock outside the normal order and refund flow, a miscount, a damaged unit, a missed sync. Run a small Python or Node.js script that reads your trusted real count (from a cycle count file, a warehouse feed, or a POS export), compares it to what Shopify reports as available for that item and location, and only writes when the gap is bigger than a small tolerance. The write uses inventorySetQuantities with compareQuantity set to the value Shopify had a moment ago, so if a sale or return lands in between, the write fails safely instead of clobbering it. Full code, tests, and a dry run guard are below.
The problem in plain words
Shopify keeps its own running total for every inventory item at every location. It starts from a number you gave it once, then adjusts it forward and backward as orders are placed, cancelled, returned, and fulfilled. As long as every real world change to stock goes through Shopify, Available stays true.
The trouble is that not every change to stock goes through Shopify. A staff member counts the shelf and finds three units missing. A box arrives from the warehouse damaged and someone throws it out without logging it anywhere. A third party fulfillment system posts a webhook that Shopify never receives because of a timeout. Each of these moves real stock without moving the Shopify number, so a gap opens between what the store says is Available and what a person can actually pick up off the shelf. Left alone, that gap only grows, because every future adjustment is still measured from the wrong starting point.
Why it happens
Shopify only moves Available when an event tells it to, an order, a cancellation, a return, or a manual adjustment made through the Admin or the API. A few common ways stores end up with drift anyway:
- A point of sale count finds fewer or more units than Shopify expects, and the difference is written down on paper but never entered as an inventory adjustment.
- A unit is damaged, lost, or stolen in the stockroom, and it disappears from the shelf without anyone touching Shopify.
- A third party warehouse or fulfillment system pushes stock levels over a webhook or a scheduled sync, and one of those calls fails silently or times out.
- Two systems both try to write the same inventory item at the same time, a sale posts through checkout while a bulk import is halfway through updating the same SKU, and one write overwrites the other.
This is a common source of confusion because the drift is quiet. Nothing errors, nothing alerts, the store just slowly starts overselling items that look available but are not, or under-selling items that show as out of stock when there is a full case sitting in the back. Shopify does let you edit quantities by hand in the Admin, but doing that for hundreds of SKUs across multiple locations does not scale, and a manual edit made from a stale screen can just as easily make the drift worse. See the citations at the end for the exact docs.
The dangerous part of fixing drift by hand is that you are writing a number based on what you saw a minute ago, and inventory can move in that minute. The safe pattern is not "set Available to the count." It is "set Available to the count, but only if it still matches what I last read." That is exactly what a compare-and-set write gives you: Shopify checks your remembered value against its own before it accepts the change, so a sale that lands in between makes the write fail instead of silently clobbering it.
The fix, as a flow
We do not touch checkout or the storefront. We add a job that reads a trusted real count for each inventory item and location, reads what Shopify currently reports as Available for the same pair, and works out the difference. When the difference is bigger than a small tolerance, it writes the corrected value with the last known Shopify quantity attached as the compare value, so the write only lands if nothing else changed that item in the meantime.
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_locations, read_inventory, and write_inventory 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 SHOPIFY_LOCATION_ID="gid://shopify/Location/124656943"
export DRIFT_TOLERANCE="1"
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 SHOPIFY_LOCATION_ID="gid://shopify/Location/124656943"
export DRIFT_TOLERANCE="1"
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 inventory levels and to run the reconciling write.
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 what Shopify currently thinks is Available
Ask the location for its inventory levels, and read back the fields the decision needs: the inventory item id, the SKU, and the current available quantity. We page through with a cursor so the job handles a large catalog.
LEVELS_QUERY = """
query($cursor: String, $locationId: ID!) {
location(id: $locationId) {
inventoryLevels(first: 50, after: $cursor) {
pageInfo { hasNextPage endCursor }
nodes {
item { id sku }
quantities(names: ["available"]) { name quantity }
}
}
}
}"""
def shopify_levels(location_id):
cursor = None
while True:
data = gql(LEVELS_QUERY, {"cursor": cursor, "locationId": location_id})["location"]["inventoryLevels"]
for node in data["nodes"]:
yield node
if not data["pageInfo"]["hasNextPage"]:
return
cursor = data["pageInfo"]["endCursor"]
const LEVELS_QUERY = `
query($cursor: String, $locationId: ID!) {
location(id: $locationId) {
inventoryLevels(first: 50, after: $cursor) {
pageInfo { hasNextPage endCursor }
nodes {
item { id sku }
quantities(names: ["available"]) { name quantity }
}
}
}
}`;
async function* shopifyLevels(locationId) {
let cursor = null;
while (true) {
const data = (await gql(LEVELS_QUERY, { cursor, locationId })).location.inventoryLevels;
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 the real count, the Shopify count, and a tolerance, and returns either nothing or a plan describing the write. A pure function like this is easy to read and easy to test, which we do later. The rule is strict on purpose. If the two counts already agree within the tolerance, we do nothing. If they do not, the plan carries both the new quantity to set and the old quantity to compare against, so the caller can always attach a compare-and-set guard.
def plan_reconciliation(real_count, shopify_available, tolerance):
"""Return a write plan, or None if the two counts already agree.
real_count, shopify_available, tolerance are all whole units (not cents).
"""
if real_count < 0 or shopify_available < 0 or tolerance < 0:
raise ValueError("counts and tolerance must not be negative")
drift = real_count - shopify_available
if abs(drift) <= tolerance:
return None
return {
"quantity": real_count,
"compare_quantity": shopify_available,
"drift": drift,
}
export function planReconciliation(realCount, shopifyAvailable, tolerance) {
if (realCount < 0 || shopifyAvailable < 0 || tolerance < 0) {
throw new Error("counts and tolerance must not be negative");
}
const drift = realCount - shopifyAvailable;
if (Math.abs(drift) <= tolerance) return null;
return { quantity: realCount, compareQuantity: shopifyAvailable, drift };
}
Write the correction with a compare-and-set guard
When there is a plan, call inventorySetQuantities with name: "available", the new quantity, and compareQuantity set to the Shopify value the plan was built from. If something else changed that item since we read it, Shopify rejects the write instead of silently overwriting the newer number. Always read back userErrors and stop on anything unexpected rather than assume it worked.
SET_QUANTITIES = """
mutation($input: InventorySetQuantitiesInput!) {
inventorySetQuantities(input: $input) {
inventoryAdjustmentGroup { changes { name delta quantityAfterChange } }
userErrors { field message code }
}
}"""
def apply_correction(item_id, location_id, plan, reason="correction"):
input_ = {
"name": "available",
"reason": reason,
"ignoreCompareQuantity": False,
"quantities": [{
"inventoryItemId": item_id,
"locationId": location_id,
"quantity": plan["quantity"],
"compareQuantity": plan["compare_quantity"],
}],
}
result = gql(SET_QUANTITIES, {"input": input_})["inventorySetQuantities"]
if result["userErrors"]:
raise RuntimeError(result["userErrors"])
return result["inventoryAdjustmentGroup"]
const SET_QUANTITIES = `
mutation($input: InventorySetQuantitiesInput!) {
inventorySetQuantities(input: $input) {
inventoryAdjustmentGroup { changes { name delta quantityAfterChange } }
userErrors { field message code }
}
}`;
async function applyCorrection(itemId, locationId, plan, reason = "correction") {
const input = {
name: "available",
reason,
ignoreCompareQuantity: false,
quantities: [{
inventoryItemId: itemId,
locationId,
quantity: plan.quantity,
compareQuantity: plan.compareQuantity,
}],
};
const result = (await gql(SET_QUANTITIES, { input })).inventorySetQuantities;
if (result.userErrors.length) throw new Error(JSON.stringify(result.userErrors));
return result.inventoryAdjustmentGroup;
}
Wire it together with a dry run guard
The loop ties every piece together. It reads your trusted real counts, matches each one to its Shopify inventory item by SKU, and only calls the write when plan_reconciliation says there is a real gap. Notice the dry run guard. On the first few runs, leave DRY_RUN on so the script only reports which items it would correct and by how much. Read the output, agree with it, then switch it off to let it write. Run it after every cycle count or whenever your warehouse feed lands.
Always start with DRY_RUN=true, and only feed the script a real count you trust, a physical cycle count or a warehouse system of record. Never feed it a guess. The compare-and-set write protects you from a race with a live sale, but it cannot protect you from correcting Available to a number that was wrong to begin with.
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 a write is only attempted when the drift is past the tolerance, and the compare-and-set guard stops it from overwriting a quantity that already moved.
View this code on GitHub Full runnable folder with tests in the shopify-fixes repo.
"""Reconcile Shopify Available inventory with a trusted real on-hand count.
Reads a trusted count per SKU (a cycle count file or warehouse feed), compares
it to what Shopify currently reports as available at one location, and writes
the correction only when the drift is bigger than a tolerance. The write uses
inventorySetQuantities with compareQuantity, a compare-and-set guard, so a
concurrent sale or return cannot be silently overwritten.
Run after every cycle count. Safe to run again and again.
"""
import os
import csv
import logging
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("reconcile_available")
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"
LOCATION_ID = os.environ["SHOPIFY_LOCATION_ID"]
DRIFT_TOLERANCE = int(os.environ.get("DRIFT_TOLERANCE", "1"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
COUNTS_FILE = os.environ.get("REAL_COUNTS_FILE", "real_counts.csv")
LEVELS_QUERY = """
query($cursor: String, $locationId: ID!) {
location(id: $locationId) {
inventoryLevels(first: 50, after: $cursor) {
pageInfo { hasNextPage endCursor }
nodes {
item { id sku }
quantities(names: ["available"]) { name quantity }
}
}
}
}"""
SET_QUANTITIES = """
mutation($input: InventorySetQuantitiesInput!) {
inventorySetQuantities(input: $input) {
inventoryAdjustmentGroup { changes { name delta quantityAfterChange } }
userErrors { field message code }
}
}"""
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 plan_reconciliation(real_count, shopify_available, tolerance):
"""Return a write plan, or None if the two counts already agree.
real_count, shopify_available, tolerance are all whole units (not cents).
"""
if real_count < 0 or shopify_available < 0 or tolerance < 0:
raise ValueError("counts and tolerance must not be negative")
drift = real_count - shopify_available
if abs(drift) <= tolerance:
return None
return {
"quantity": real_count,
"compare_quantity": shopify_available,
"drift": drift,
}
def shopify_levels(location_id):
cursor = None
while True:
data = gql(LEVELS_QUERY, {"cursor": cursor, "locationId": location_id})["location"]["inventoryLevels"]
for node in data["nodes"]:
yield node
if not data["pageInfo"]["hasNextPage"]:
return
cursor = data["pageInfo"]["endCursor"]
def load_real_counts(path):
"""CSV with columns: sku, real_count"""
counts = {}
with open(path, newline="") as f:
for row in csv.DictReader(f):
counts[row["sku"]] = int(row["real_count"])
return counts
def apply_correction(item_id, location_id, plan, reason="correction"):
input_ = {
"name": "available",
"reason": reason,
"ignoreCompareQuantity": False,
"quantities": [{
"inventoryItemId": item_id,
"locationId": location_id,
"quantity": plan["quantity"],
"compareQuantity": plan["compare_quantity"],
}],
}
result = gql(SET_QUANTITIES, {"input": input_})["inventorySetQuantities"]
if result["userErrors"]:
raise RuntimeError(result["userErrors"])
return result["inventoryAdjustmentGroup"]
def run():
real_counts = load_real_counts(COUNTS_FILE)
fixed = 0
for level in shopify_levels(LOCATION_ID):
sku = level["item"]["sku"]
if sku not in real_counts:
continue
shopify_available = next(
(q["quantity"] for q in level["quantities"] if q["name"] == "available"), 0
)
plan = plan_reconciliation(real_counts[sku], shopify_available, DRIFT_TOLERANCE)
if plan is None:
continue
log.info(
"SKU %s drift %+d (real %d, available %d). %s",
sku, plan["drift"], real_counts[sku], shopify_available,
"would correct" if DRY_RUN else "correcting",
)
if not DRY_RUN:
apply_correction(level["item"]["id"], LOCATION_ID, plan)
fixed += 1
log.info("Done. %d item(s) %s.", fixed, "to correct" if DRY_RUN else "corrected")
if __name__ == "__main__":
run()
/**
* Reconcile Shopify Available inventory with a trusted real on-hand count.
*
* Reads a trusted count per SKU (a cycle count file or warehouse feed), compares
* it to what Shopify currently reports as available at one location, and writes
* the correction only when the drift is bigger than a tolerance. The write uses
* inventorySetQuantities with compareQuantity, a compare-and-set guard, so a
* concurrent sale or return cannot be silently overwritten.
* Run after every cycle count. Safe to run again and again.
*/
import { pathToFileURL } from "node:url";
import { readFileSync } from "node:fs";
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 LOCATION_ID = process.env.SHOPIFY_LOCATION_ID || "gid://shopify/Location/0";
const DRIFT_TOLERANCE = Number(process.env.DRIFT_TOLERANCE || 1);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const COUNTS_FILE = process.env.REAL_COUNTS_FILE || "real_counts.csv";
export function planReconciliation(realCount, shopifyAvailable, tolerance) {
if (realCount < 0 || shopifyAvailable < 0 || tolerance < 0) {
throw new Error("counts and tolerance must not be negative");
}
const drift = realCount - shopifyAvailable;
if (Math.abs(drift) <= tolerance) return null;
return { quantity: realCount, compareQuantity: shopifyAvailable, drift };
}
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 LEVELS_QUERY = `
query($cursor: String, $locationId: ID!) {
location(id: $locationId) {
inventoryLevels(first: 50, after: $cursor) {
pageInfo { hasNextPage endCursor }
nodes {
item { id sku }
quantities(names: ["available"]) { name quantity }
}
}
}
}`;
const SET_QUANTITIES = `
mutation($input: InventorySetQuantitiesInput!) {
inventorySetQuantities(input: $input) {
inventoryAdjustmentGroup { changes { name delta quantityAfterChange } }
userErrors { field message code }
}
}`;
async function* shopifyLevels(locationId) {
let cursor = null;
while (true) {
const data = (await gql(LEVELS_QUERY, { cursor, locationId })).location.inventoryLevels;
for (const node of data.nodes) yield node;
if (!data.pageInfo.hasNextPage) return;
cursor = data.pageInfo.endCursor;
}
}
function loadRealCounts(path) {
const text = readFileSync(path, "utf8").trim();
const [header, ...rows] = text.split("\n");
const cols = header.split(",").map((c) => c.trim());
const skuIdx = cols.indexOf("sku");
const countIdx = cols.indexOf("real_count");
const counts = {};
for (const row of rows) {
const cells = row.split(",");
counts[cells[skuIdx].trim()] = parseInt(cells[countIdx].trim(), 10);
}
return counts;
}
async function applyCorrection(itemId, locationId, plan, reason = "correction") {
const input = {
name: "available",
reason,
ignoreCompareQuantity: false,
quantities: [{
inventoryItemId: itemId,
locationId,
quantity: plan.quantity,
compareQuantity: plan.compareQuantity,
}],
};
const result = (await gql(SET_QUANTITIES, { input })).inventorySetQuantities;
if (result.userErrors.length) throw new Error(JSON.stringify(result.userErrors));
return result.inventoryAdjustmentGroup;
}
export async function run() {
const realCounts = loadRealCounts(COUNTS_FILE);
let fixed = 0;
for await (const level of shopifyLevels(LOCATION_ID)) {
const sku = level.item.sku;
if (!(sku in realCounts)) continue;
const shopifyAvailable = (level.quantities.find((q) => q.name === "available") || {}).quantity || 0;
const plan = planReconciliation(realCounts[sku], shopifyAvailable, DRIFT_TOLERANCE);
if (plan === null) continue;
console.log(
`SKU ${sku} drift ${plan.drift >= 0 ? "+" : ""}${plan.drift} (real ${realCounts[sku]}, available ${shopifyAvailable}). ${DRY_RUN ? "would correct" : "correcting"}`
);
if (!DRY_RUN) await applyCorrection(level.item.id, LOCATION_ID, plan);
fixed++;
}
console.log(`Done. ${fixed} item(s) ${DRY_RUN ? "to correct" : "corrected"}.`);
}
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 whether a real inventory number gets overwritten. Because we kept plan_reconciliation pure, the test needs no network and no Shopify account. It just feeds in plain numbers and checks the plan.
import pytest
from reconcile_available import plan_reconciliation
def test_no_plan_when_within_tolerance():
assert plan_reconciliation(40, 39, tolerance=1) is None
def test_plan_when_real_is_higher():
plan = plan_reconciliation(40, 31, tolerance=1)
assert plan == {"quantity": 40, "compare_quantity": 31, "drift": 9}
def test_plan_when_real_is_lower():
plan = plan_reconciliation(20, 25, tolerance=1)
assert plan == {"quantity": 20, "compare_quantity": 25, "drift": -5}
def test_exact_tolerance_boundary_is_not_a_drift():
assert plan_reconciliation(10, 8, tolerance=2) is None
def test_rejects_negative_inputs():
with pytest.raises(ValueError):
plan_reconciliation(-1, 5, tolerance=1)
import { test } from "node:test";
import assert from "node:assert/strict";
import { planReconciliation } from "./reconcile-available.js";
test("no plan when within tolerance", () => {
assert.equal(planReconciliation(40, 39, 1), null);
});
test("plan when real is higher", () => {
assert.deepEqual(planReconciliation(40, 31, 1), { quantity: 40, compareQuantity: 31, drift: 9 });
});
test("plan when real is lower", () => {
assert.deepEqual(planReconciliation(20, 25, 1), { quantity: 20, compareQuantity: 25, drift: -5 });
});
test("exact tolerance boundary is not a drift", () => {
assert.equal(planReconciliation(10, 8, 2), null);
});
test("rejects negative inputs", () => {
assert.throws(() => planReconciliation(-1, 5, 1));
});
Case studies
The boutique with a monthly drift habit
A clothing boutique ran a physical count at the start of every month and always found the same pattern, Available on Shopify was a little higher than what was actually folded on the table, a shirt here, a scarf there, shrinkage that never got logged. Staff fixed it by clicking into each product and typing a new number, which took an afternoon and sometimes typo'd a quantity backwards.
Now the count sheet is exported to a CSV, and the script compares it to Shopify the same evening. Only SKUs with a real gap get touched, and the compare-and-set guard means a late sale during the count does not get overwritten by a stale number.
The brand whose warehouse feed dropped one delivery
A skincare brand synced stock from a third party logistics provider every night. One night the sync job silently failed for a single shipment, and forty units of a bestseller sat in the warehouse marked as zero Available on the store for three days before anyone noticed the missed sales.
The team now runs the reconciliation script against the warehouse's own report every morning. It caught the very next silent failure within hours instead of days, corrected the drifted SKU, and left everything else that already matched completely alone.
After this runs on a schedule against a trusted count, Available on the store tracks what is really on the shelf, not what Shopify last happened to compute. Drift gets caught within one cycle instead of compounding for weeks, and the compare-and-set guard means the fix never fights a sale that is happening in real time. Keep the real count coming from a source you trust, since the script is only as honest as the number you feed it.
FAQ
Why does Shopify Available stop matching the real shelf count?
Available drifts when something changes stock outside the normal order and return flow, such as a POS miscount, a damaged unit written off without an adjustment, a failed webhook from a warehouse system, or a manual edit that was never reconciled. Shopify keeps counting from that point forward, so the gap between Available and the real on-hand count never closes on its own.
What does compare-and-set mean for a Shopify inventory write?
Compare-and-set means the write only applies if the quantity Shopify currently has still matches the value your script last read. The inventorySetQuantities mutation supports this with the compareQuantity field. If another process changed the count in between, the compare check fails and Shopify rejects the write instead of silently overwriting a number that has already moved.
Is it safe to auto correct Available with a script?
Yes, when the script only recomputes what Available should be from a trusted count, only writes when the drift is bigger than a small tolerance, always sends the last known quantity as compareQuantity so a concurrent change blocks the write, and runs in dry run first. That combination stops the script from fighting a sale that is happening at the same moment.
Related field notes
Citations
On the problem:
- Shopify Help Center: understanding and adjusting inventory quantities. help.shopify.com/en/manual/products/inventory/getting-started-with-inventory/inventory-quantities
- Shopify Help Center: counting inventory and adjusting for shrinkage or damage. help.shopify.com/en/manual/products/inventory/managing-inventory/counting-inventory
- Shopify Community: Available quantity out of sync with physical stock. community.shopify.com shopify apis and sdks
On the solution:
- Shopify Admin GraphQL: the
inventorySetQuantitiesmutation and its compare-and-set behavior. shopify.dev/docs/api/admin-graphql/latest/mutations/inventorySetQuantities - Shopify Admin GraphQL: the
InventoryLevelobject and itsquantitiesfield. shopify.dev/docs/api/admin-graphql/latest/objects/InventoryLevel - Shopify Admin GraphQL: managing inventory quantities across locations. shopify.dev/docs/apps/build/orders-fulfillment/inventory-management-apps/quantities-states
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 up your inventory drift?
If this saved you a pile of manual clicks or stopped an oversell, 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