Reconciler Customers and data
Paid checkouts stranded as abandoned
A customer finishes checkout, the payment is captured, and then nothing. No order shows up. Shopify quietly files the whole thing under abandoned checkouts, right next to the carts people genuinely walked away from, so a real, paid customer gets treated like a browser who left. Here is why the order write can fail after the money moves, and a small script that finds the stranded checkouts and hands them to a human to reconcile.
Shopify's abandoned checkout report only checks whether an order followed a checkout, not whether the payment finished. When the order write fails after payment is captured, such as a webhook timeout or an app crash mid write, the checkout stays "abandoned" while the customer's money is gone. Run a small Python or Node.js script that lists checkouts where completedAt is set, checks whether a matching order exists using a checkout_token search, and tags the ones with no order behind them so a human can reconcile the payment. Full code, tests, and a dry run guard are below.
The problem in plain words
Shopify's abandoned checkout list has one job: catch the carts nobody finished, so a marketing email can nudge the customer back. The signal it uses is simple. Contact information exists, and no order followed. That is a fine rule for genuine abandonment.
It falls apart the moment payment actually succeeds but the order never gets written. That happens more than store owners expect. A webhook that should trigger order creation times out. A custom checkout extension throws partway through. A duplicate submit races itself and the second attempt fails after the first already charged the card. In every case, the payment processor has the money, the customer believes they bought something, and Shopify's own report says the checkout was abandoned. Nobody is watching for this gap, because it looks exactly like every other abandoned cart in the list.
Why it happens
Shopify treats "checkout completed" and "order created" as two separate facts, and most of the time the second follows the first instantly. A few common ways they come apart:
- A custom storefront or checkout extension calls its own backend after payment to finish creating the order, and that call times out or the app crashes before it completes.
- A webhook driven flow, such as a third party fulfillment or ERP integration standing between payment and order creation, misses the delivery or fails silently.
- A shopper double-submits the pay button on a slow connection, the first request captures the payment, and the second request that was meant to finish the order errors out because the cart already cleared.
- A payment gateway confirms the charge to the processor before Shopify's own order creation step finishes, so a crash in that narrow window leaves the charge with nothing behind it.
This is a quiet failure by design. Nothing alerts you, because from Shopify's point of view an abandoned checkout is a completely normal, everyday thing. The only sign is a customer who emails asking where their order confirmation is, or a payout report that has more money in it than the order list explains. See the citations at the end for the exact docs behind each field this script uses.
Abandoned does not mean unpaid. It only means no order followed the checkout. So the fix is not to scan for abandoned checkouts and assume they are all fine to ignore. It is to separate the ones that finished payment, using completedAt, from the rest, then confirm with the order list itself, using checkout_token, that nothing was ever created for that payment. Only that narrow, doubly confirmed group gets flagged.
The fix, as a flow
We do not touch checkout or payment at all, and we never create an order ourselves, since guessing at what the order should contain is exactly the kind of mistake that makes things worse. We add a job that lists recently completed checkouts, checks the order list for each one, and tags the ones with no matching order so a human can look at the payment and either recreate the order by hand or refund it.
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 scope for the order search, and get access to the abandoned checkouts data with a user that has the manage abandoned checkouts permission. 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 RECONCILE_TAG="stranded-checkout"
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 RECONCILE_TAG="stranded-checkout"
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 completed checkouts, search orders, and add the reconciliation tag.
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 recently completed checkouts
Ask abandonedCheckouts for the fields the decision needs: the id, name, when it completed, its total in shopMoney, and the customer. We sort by updatedAt so the newest ones surface first, and we page through with a cursor so the job handles a busy store's backlog.
ABANDONED_CHECKOUTS_QUERY = """
query($cursor: String) {
abandonedCheckouts(first: 50, after: $cursor, sortKey: UPDATED_AT, reverse: true) {
pageInfo { hasNextPage endCursor }
nodes {
id
name
completedAt
updatedAt
totalPriceSet { shopMoney { amount currencyCode } }
customer { email }
}
}
}"""
def recently_completed_checkouts():
cursor = None
while True:
data = gql(ABANDONED_CHECKOUTS_QUERY, {"cursor": cursor})["abandonedCheckouts"]
for node in data["nodes"]:
yield node
if not data["pageInfo"]["hasNextPage"]:
return
cursor = data["pageInfo"]["endCursor"]
const ABANDONED_CHECKOUTS_QUERY = `
query($cursor: String) {
abandonedCheckouts(first: 50, after: $cursor, sortKey: UPDATED_AT, reverse: true) {
pageInfo { hasNextPage endCursor }
nodes {
id
name
completedAt
updatedAt
totalPriceSet { shopMoney { amount currencyCode } }
customer { email }
}
}
}`;
async function* recentlyCompletedCheckouts() {
let cursor = null;
while (true) {
const data = (await gql(ABANDONED_CHECKOUTS_QUERY, { cursor })).abandonedCheckouts;
for (const node of data.nodes) yield node;
if (!data.pageInfo.hasNextPage) return;
cursor = data.pageInfo.endCursor;
}
}
Check the order list, not just the checkout
Every order that comes from a checkout carries a checkoutToken that ties it back to the checkout it was created from, and the orders query can search on that same value with checkout_token. The abandoned checkout's own id ends in the numeric token that matches it, so we pull that out and ask Shopify directly whether an order already exists. If one does, the checkout was never really stranded, whatever the abandoned checkout report says.
ORDER_BY_CHECKOUT_TOKEN_QUERY = """
query($q: String!) {
orders(first: 1, query: $q) {
nodes { id name }
}
}"""
def checkout_token_from_gid(gid):
# gid://shopify/AbandonedCheckout/123 -> "123", the token orders were written with.
return gid.rsplit("/", 1)[-1]
def order_exists_for_checkout(checkout_gid):
token = checkout_token_from_gid(checkout_gid)
data = gql(ORDER_BY_CHECKOUT_TOKEN_QUERY, {"q": f"checkout_token:{token}"})["orders"]
return len(data["nodes"]) > 0
const ORDER_BY_CHECKOUT_TOKEN_QUERY = `
query($q: String!) {
orders(first: 1, query: $q) {
nodes { id name }
}
}`;
function checkoutTokenFromGid(gid) {
// gid://shopify/AbandonedCheckout/123 -> "123", the token orders were written with.
return gid.split("/").pop();
}
async function orderExistsForCheckout(checkoutGid) {
const token = checkoutTokenFromGid(checkoutGid);
const data = (await gql(ORDER_BY_CHECKOUT_TOKEN_QUERY, { q: `checkout_token:${token}` })).orders;
return data.nodes.length > 0;
}
Decide, with one pure function
Keep the decision in its own function that takes a checkout, whether a matching order was found, and a minimum amount worth chasing, then returns true or false. A pure function like this is easy to read and easy to test, which we do later. We work in cents throughout, since comparing raw decimal strings from the API invites rounding mistakes. The rule is strict on purpose. The checkout must have actually completed, no order can already exist for it, and the amount must clear a minimum you set, so a script does not get noisy over a one cent test order left behind by a theme check.
def to_cents(amount):
return round(float(amount) * 100)
def is_stranded(checkout, has_matching_order, min_cents=1):
if not checkout.get("completedAt"):
return False
if has_matching_order:
return False
amount = (checkout.get("totalPriceSet") or {}).get("shopMoney", {}).get("amount", "0")
return to_cents(amount) >= min_cents
export function toCents(amount) {
return Math.round(parseFloat(amount) * 100);
}
export function isStranded(checkout, hasMatchingOrder, minCents = 1) {
if (!checkout.completedAt) return false;
if (hasMatchingOrder) return false;
const amount = checkout.totalPriceSet?.shopMoney?.amount ?? "0";
return toCents(amount) >= minCents;
}
Tag stranded checkouts for a human to reconcile
When a checkout is stranded, we do not try to guess the order's contents and create one ourselves, since that risks charging or shipping something wrong. We add a tag to the checkout so a human can open the payment record, confirm what was charged, and either recreate the order by hand with the correct line items or refund the customer. Always read back userErrors. If Shopify refuses the tag, the script should stop on it rather than pretend it worked.
TAGS_ADD = """
mutation($id: ID!, $tags: [String!]!) {
tagsAdd(id: $id, tags: $tags) { node { id } userErrors { field message } }
}"""
def tag_for_reconciliation(checkout_id, reconcile_tag):
result = gql(TAGS_ADD, {"id": checkout_id, "tags": [reconcile_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 tagForReconciliation(checkoutId, reconcileTag) {
const result = (await gql(TAGS_ADD, { id: checkoutId, tags: [reconcileTag] })).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. Notice the dry run guard. On the first few runs, leave DRY_RUN on so the script only reports which checkouts it would tag. Read the output, agree with it, then switch it off to let it write tags. Run it on a schedule that matches how often your store takes checkouts, for example every hour, since a stranded payment deserves a quick reconciliation, not a weekly surprise.
Always start with DRY_RUN=true, and treat the tag as a pointer for a human, not a fix by itself. This script never creates an order and never refunds anyone. It only makes sure a stranded, paid checkout gets seen instead of sitting quietly in a list built for people who changed their minds.
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 reads checkouts and orders, and the one write it makes is a review tag.
View this code on GitHub Full runnable folder with tests in the shopify-fixes repo.
"""Find Shopify checkouts that were actually paid but never became an order.
A checkout can finish payment (Shopify records completedAt on the abandoned
checkout record) while the order write never lands, for example a webhook
that timed out, an app that crashed mid write, or a duplicate submit that
raced itself. Shopify's own abandoned checkout report still calls this
"abandoned" because no order followed, so real, paid checkouts hide in a
list meant for carts nobody finished. This job lists recently completed
checkouts, checks whether a matching order exists with checkout_token, and
tags the ones that are paid with nothing behind them for a human to
reconcile with reconcile_tag. Read only apart from the tag. 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("find_stranded_checkouts")
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"
LOOKBACK_DAYS = int(os.environ.get("LOOKBACK_DAYS", "3"))
MIN_STRANDED_CENTS = int(os.environ.get("MIN_STRANDED_CENTS", "1"))
RECONCILE_TAG = os.environ.get("RECONCILE_TAG", "stranded-checkout")
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
ABANDONED_CHECKOUTS_QUERY = """
query($cursor: String) {
abandonedCheckouts(first: 50, after: $cursor, sortKey: UPDATED_AT, reverse: true) {
pageInfo { hasNextPage endCursor }
nodes {
id
name
completedAt
updatedAt
totalPriceSet { shopMoney { amount currencyCode } }
customer { email }
}
}
}"""
ORDER_BY_CHECKOUT_TOKEN_QUERY = """
query($q: String!) {
orders(first: 1, query: $q) {
nodes { id name }
}
}"""
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 to_cents(amount):
return round(float(amount) * 100)
def checkout_token_from_gid(gid):
"""The abandoned checkout gid ends in the numeric checkout id, which is
also the token orders were written with. gid://shopify/AbandonedCheckout/123
becomes "123"."""
return gid.rsplit("/", 1)[-1]
def is_stranded(checkout, has_matching_order, min_cents=MIN_STRANDED_CENTS):
"""Pure decision: true when a checkout finished payment, is worth
reconciling, and no order exists for it.
checkout is a dict with at least "completedAt" and "totalPriceSet".
has_matching_order is a bool the caller already looked up.
"""
if not checkout.get("completedAt"):
return False
if has_matching_order:
return False
amount = (checkout.get("totalPriceSet") or {}).get("shopMoney", {}).get("amount", "0")
return to_cents(amount) >= min_cents
def order_exists_for_checkout(checkout_gid):
token = checkout_token_from_gid(checkout_gid)
data = gql(ORDER_BY_CHECKOUT_TOKEN_QUERY, {"q": f"checkout_token:{token}"})["orders"]
return len(data["nodes"]) > 0
def tag_for_reconciliation(checkout_id, reconcile_tag):
result = gql(TAGS_ADD, {"id": checkout_id, "tags": [reconcile_tag]})["tagsAdd"]
if result["userErrors"]:
raise RuntimeError(result["userErrors"])
def recently_completed_checkouts():
cursor = None
while True:
data = gql(ABANDONED_CHECKOUTS_QUERY, {"cursor": cursor})["abandonedCheckouts"]
for node in data["nodes"]:
yield node
if not data["pageInfo"]["hasNextPage"]:
return
cursor = data["pageInfo"]["endCursor"]
def run():
flagged = 0
for checkout in recently_completed_checkouts():
if not checkout.get("completedAt"):
continue
has_order = order_exists_for_checkout(checkout["id"])
if not is_stranded(checkout, has_order):
continue
log.warning(
"Checkout %s completed with no order. %s",
checkout.get("name") or checkout["id"],
"would tag" if DRY_RUN else "tagging",
)
if not DRY_RUN:
tag_for_reconciliation(checkout["id"], RECONCILE_TAG)
flagged += 1
log.info("Done. %d checkout(s) %s.", flagged, "to tag" if DRY_RUN else "tagged")
if __name__ == "__main__":
run()
/**
* Find Shopify checkouts that were actually paid but never became an order.
*
* A checkout can finish payment (Shopify records completedAt on the abandoned
* checkout record) while the order write never lands, for example a webhook
* that timed out, an app that crashed mid write, or a duplicate submit that
* raced itself. Shopify's own abandoned checkout report still calls this
* "abandoned" because no order followed, so real, paid checkouts hide in a
* list meant for carts nobody finished. This job lists recently completed
* checkouts, checks whether a matching order exists with checkout_token, and
* tags the ones that are paid with nothing behind them for a human to
* reconcile with reconcileTag. 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 LOOKBACK_DAYS = Number(process.env.LOOKBACK_DAYS || 3);
const MIN_STRANDED_CENTS = Number(process.env.MIN_STRANDED_CENTS || 1);
const RECONCILE_TAG = process.env.RECONCILE_TAG || "stranded-checkout";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
export function toCents(amount) {
return Math.round(parseFloat(amount) * 100);
}
export function checkoutTokenFromGid(gid) {
// gid://shopify/AbandonedCheckout/123 -> "123", the same token orders were written with.
return gid.split("/").pop();
}
export function isStranded(checkout, hasMatchingOrder, minCents = MIN_STRANDED_CENTS) {
if (!checkout.completedAt) return false;
if (hasMatchingOrder) return false;
const amount = checkout.totalPriceSet?.shopMoney?.amount ?? "0";
return toCents(amount) >= minCents;
}
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 ABANDONED_CHECKOUTS_QUERY = `
query($cursor: String) {
abandonedCheckouts(first: 50, after: $cursor, sortKey: UPDATED_AT, reverse: true) {
pageInfo { hasNextPage endCursor }
nodes {
id
name
completedAt
updatedAt
totalPriceSet { shopMoney { amount currencyCode } }
customer { email }
}
}
}`;
const ORDER_BY_CHECKOUT_TOKEN_QUERY = `
query($q: String!) {
orders(first: 1, query: $q) {
nodes { id name }
}
}`;
const TAGS_ADD = `
mutation($id: ID!, $tags: [String!]!) {
tagsAdd(id: $id, tags: $tags) { node { id } userErrors { field message } }
}`;
async function* recentlyCompletedCheckouts() {
let cursor = null;
while (true) {
const data = (await gql(ABANDONED_CHECKOUTS_QUERY, { cursor })).abandonedCheckouts;
for (const node of data.nodes) yield node;
if (!data.pageInfo.hasNextPage) return;
cursor = data.pageInfo.endCursor;
}
}
async function orderExistsForCheckout(checkoutGid) {
const token = checkoutTokenFromGid(checkoutGid);
const data = (await gql(ORDER_BY_CHECKOUT_TOKEN_QUERY, { q: `checkout_token:${token}` })).orders;
return data.nodes.length > 0;
}
async function tagForReconciliation(checkoutId, reconcileTag) {
const result = (await gql(TAGS_ADD, { id: checkoutId, tags: [reconcileTag] })).tagsAdd;
if (result.userErrors.length) throw new Error(JSON.stringify(result.userErrors));
}
export async function run() {
let flagged = 0;
for await (const checkout of recentlyCompletedCheckouts()) {
if (!checkout.completedAt) continue;
const hasOrder = await orderExistsForCheckout(checkout.id);
if (!isStranded(checkout, hasOrder)) continue;
console.warn(`Checkout ${checkout.name || checkout.id} completed with no order. ${DRY_RUN ? "would tag" : "tagging"}`);
if (!DRY_RUN) await tagForReconciliation(checkout.id, RECONCILE_TAG);
flagged++;
}
console.log(`Done. ${flagged} checkout(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 decision rule is the part most worth testing, because it decides which stranded checkouts get flagged for a human to chase down. Because we kept is_stranded pure, the test needs no network and no Shopify account. It just feeds in plain objects and checks the answer.
from find_stranded_checkouts import is_stranded, to_cents, checkout_token_from_gid
def checkout(**over):
base = {
"id": "gid://shopify/AbandonedCheckout/123",
"completedAt": "2026-07-09T10:00:00Z",
"totalPriceSet": {"shopMoney": {"amount": "49.99", "currencyCode": "USD"}},
}
base.update(over)
return base
def test_to_cents_rounds():
assert to_cents("49.99") == 4999
assert to_cents("10.00") == 1000
def test_checkout_token_from_gid_extracts_numeric_id():
assert checkout_token_from_gid("gid://shopify/AbandonedCheckout/123") == "123"
def test_stranded_when_completed_and_no_order():
assert is_stranded(checkout(), has_matching_order=False) is True
def test_not_stranded_when_order_exists():
assert is_stranded(checkout(), has_matching_order=True) is False
def test_not_stranded_when_never_completed():
assert is_stranded(checkout(completedAt=None), has_matching_order=False) is False
def test_not_stranded_when_below_minimum_cents():
tiny = checkout(totalPriceSet={"shopMoney": {"amount": "0.00", "currencyCode": "USD"}})
assert is_stranded(tiny, has_matching_order=False, min_cents=1) is False
def test_stranded_respects_custom_minimum():
small = checkout(totalPriceSet={"shopMoney": {"amount": "0.50", "currencyCode": "USD"}})
assert is_stranded(small, has_matching_order=False, min_cents=100) is False
assert is_stranded(small, has_matching_order=False, min_cents=10) is True
import { test } from "node:test";
import assert from "node:assert/strict";
import { isStranded, toCents, checkoutTokenFromGid } from "./find-stranded-checkouts.js";
const checkout = (over = {}) => ({
id: "gid://shopify/AbandonedCheckout/123",
completedAt: "2026-07-09T10:00:00Z",
totalPriceSet: { shopMoney: { amount: "49.99", currencyCode: "USD" } },
...over,
});
test("toCents rounds", () => {
assert.equal(toCents("49.99"), 4999);
assert.equal(toCents("10.00"), 1000);
});
test("checkoutTokenFromGid extracts numeric id", () => {
assert.equal(checkoutTokenFromGid("gid://shopify/AbandonedCheckout/123"), "123");
});
test("stranded when completed and no order", () => {
assert.equal(isStranded(checkout(), false), true);
});
test("not stranded when order exists", () => {
assert.equal(isStranded(checkout(), true), false);
});
test("not stranded when never completed", () => {
assert.equal(isStranded(checkout({ completedAt: null }), false), false);
});
test("not stranded when below minimum cents", () => {
const tiny = checkout({ totalPriceSet: { shopMoney: { amount: "0.00", currencyCode: "USD" } } });
assert.equal(isStranded(tiny, false, 1), false);
});
test("stranded respects custom minimum", () => {
const small = checkout({ totalPriceSet: { shopMoney: { amount: "0.50", currencyCode: "USD" } } });
assert.equal(isStranded(small, false, 100), false);
assert.equal(isStranded(small, false, 10), true);
});
Case studies
The store whose app crashed after the charge
A furniture brand ran a custom post-purchase upsell extension that finished creating the order after the main payment was captured. Once a week the extension's backend had a brief outage, and every checkout that completed during that window paid successfully but never got an order. Support only found out when customers wrote in asking where their confirmation email was.
Now the job runs every hour, matches completed checkouts against the order list with checkout_token, and tags anything stranded. The team catches the gap within the hour instead of waiting on a confused customer email, and every stranded payment gets either a manually created order or a fast refund.
The store that quietly kept customers' money
A store selling event tickets saw a pattern of customers on flaky mobile connections tapping the pay button twice. The first request captured the payment and started creating the order, the second request errored out because the cart was already used, and occasionally that error interrupted the first request's own order write mid flight.
The reconciliation tag turned a handful of quiet, unexplained charges into a short weekly list the finance person actually reviews. Every one of them either got a proper order or a refund within a day, instead of sitting as an unexplained line in the payout report.
After this runs on a schedule, a payment that finished without an order behind it gets found within the hour instead of surfacing as a support ticket or a payout that does not add up. The abandoned checkout list goes back to meaning what it says, people who left, while the rare stranded payment gets a human's attention fast. Keep the actual fix, recreating the order or refunding the customer, as a human decision, since only a person can tell which one is right.
FAQ
Why does Shopify call a checkout abandoned when the customer already paid?
Shopify marks a checkout abandoned whenever contact information was entered but no order followed it, and it does not look at whether payment actually finished. If the order write failed after the payment was captured, the checkout still shows as abandoned even though the money moved.
Is it safe to find stranded checkouts with a script?
Yes, when the script only reads the abandonedCheckouts list and the orders list, and the one write it makes is a reconciliation tag rather than creating an order or touching money. Run it in dry run first so you can see the exact list before anything is tagged.
How do you match an abandoned checkout to a missing order?
Every order that is created from a checkout carries a checkoutToken that matches the checkout it came from. Search orders with checkout_token and the completed checkout's identifier. If nothing comes back, the payment has nothing to show for it and the checkout is stranded.
Related field notes
Citations
On the problem:
- Shopify Admin GraphQL: the
abandonedCheckoutsquery and theAbandonedCheckoutobject, includingcompletedAt. shopify.dev/docs/api/admin-graphql/latest/queries/abandonedCheckouts - Shopify Help Center: how abandoned checkouts are detected and reported. help.shopify.com/en/manual/orders/abandoned-checkouts
- Shopify Community: orders that never got created after a successful payment. community.shopify.com graphql admin api
On the solution:
- Shopify Admin GraphQL: the
ordersquery and itscheckout_tokensearch syntax. shopify.dev/docs/api/admin-graphql/latest/queries/orders - Shopify Admin GraphQL: the Order object, including
checkoutToken. shopify.dev/docs/api/admin-graphql/latest/objects/Order - 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 stranded checkout?
If this helped you find money that was paid but never turned into an 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