Reconciler Refunds, payouts, and reconciliation
Chargeback pulled funds, order still Paid
A customer disputes a charge with their bank. The card network pulls the money out of your next payout almost immediately. But open Shopify admin and the order still says Paid, sitting there like nothing happened. Support answers questions about it, finance reports it as revenue, and nobody notices the funds are gone until the payout report comes up short. Here is why Shopify leaves the order looking fine and a small script that writes the dispute state onto the order so it does not.
A chargeback is filed with the customer's bank, not with Shopify checkout, so Shopify does not automatically change an order's displayFinancialStatus when a dispute opens. Run a small Python or Node.js script that lists recently paid orders, reads their disputes field for any entry where initiatedAs is CHARGEBACK and status is NEEDS_RESPONSE or UNDER_REVIEW, and tags the order so the dispute state is visible where support and finance actually look. Full code, tests, and a dry run guard are below.
The problem in plain words
When a card payment goes through Shopify checkout, Shopify tracks the whole life of that money: the authorization, the capture, any refunds. The order's financial status reflects what Shopify itself did.
A chargeback does not start inside Shopify. The cardholder contacts their bank, the bank contacts the card network, and the network pulls the disputed amount back from the merchant, usually the very next payout. Shopify finds out about this through Shopify Payments and creates a dispute record, but it does not rewrite the order's displayFinancialStatus to match. The order still reads Paid, or Partially refunded if you already issued one, right through the whole dispute process. The gap between "order looks settled" and "money is actually gone" can last weeks, until the dispute is finally won, lost, or accepted.
Why it happens
Shopify sets financial status from events it processes directly. A dispute is an event the card network and the bank drive, and Shopify only surfaces it as a separate record. A few things make this worse in practice:
- The dispute lives on
ShopifyPaymentsDisputeand on the order'sdisputesfield, but neither one changesdisplayFinancialStatus, so nothing about the order itself looks different. - Support agents and finance staff usually look at the order first, see Paid, and move on, never checking a separate disputes list unless someone tells them to.
- A dispute can sit in
NEEDS_RESPONSEorUNDER_REVIEWfor weeks before it resolves toWON,LOST, orACCEPTED, and during that whole window the order gives no visual signal. - Payout reports show the deduction, but by the time someone reconciles the payout against the orders, the missing link between a specific order and a specific dispute is easy to miss.
This is a common source of confusion. A merchant sees a shortfall in their payout, spends time hunting for a refund or a fee that explains it, and only later learns the customer support team had been treating a disputed order as a normal, completed sale the entire time. See the citations at the end for the exact docs.
We are not trying to fix the money here. The card network already moved it, and only submitting evidence through Shopify or your payment provider can win it back. What we can fix is visibility: write the dispute state onto the order itself, as a tag, so anyone looking at that order sees the chargeback instead of a plain Paid label. That keeps the script read-only apart from one tag, and keeps the real decision, what evidence to submit, with a human.
The fix, as a flow
We do not touch payments or attempt to respond to disputes. We add a job that lists orders that still look paid or partially refunded, reads each order's disputes, and tags the ones with an open chargeback that are not tagged yet. Everything else, including resolved disputes and orders with no dispute at all, is left alone.
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 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 CHARGEBACK_TAG="chargeback-open"
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 CHARGEBACK_TAG="chargeback-open"
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 orders and to write the 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 the orders that still look settled
Ask for recent orders whose financial status reads paid or partially refunded, and read back the fields the decision needs: the name, the tags, the current financial status, the shop money received, and every dispute attached to the order. We page through with a cursor so the job handles a full lookback window.
ORDERS_QUERY = """
query($cursor: String, $q: String!) {
orders(first: 25, after: $cursor, query: $q) {
pageInfo { hasNextPage endCursor }
nodes {
id
name
tags
displayFinancialStatus
totalReceivedSet { shopMoney { amount currencyCode } }
disputes { id initiatedAs status }
}
}
}"""
def recently_paid_orders():
q = f"created_at:>-{LOOKBACK_DAYS}d AND (financial_status:paid OR financial_status:partially_refunded)"
cursor = None
while True:
data = gql(ORDERS_QUERY, {"cursor": cursor, "q": q})["orders"]
for node in data["nodes"]:
yield node
if not data["pageInfo"]["hasNextPage"]:
return
cursor = data["pageInfo"]["endCursor"]
const ORDERS_QUERY = `
query($cursor: String, $q: String!) {
orders(first: 25, after: $cursor, query: $q) {
pageInfo { hasNextPage endCursor }
nodes {
id
name
tags
displayFinancialStatus
totalReceivedSet { shopMoney { amount currencyCode } }
disputes { id initiatedAs status }
}
}
}`;
async function* recentlyPaidOrders() {
const q = `created_at:>-${LOOKBACK_DAYS}d AND (financial_status:paid OR financial_status:partially_refunded)`;
let cursor = null;
while (true) {
const data = (await gql(ORDERS_QUERY, { cursor, q })).orders;
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 an order and the required tag and returns true or false. A pure function like this is easy to read and easy to test, which we do later. It checks that the order still displays as paid or partially refunded, that at least one dispute on the order is a chargeback with an open status, and that the order is not already tagged. Money amounts are converted to cents so nothing here depends on floating point comparisons.
OPEN_DISPUTE_STATUSES = {"NEEDS_RESPONSE", "UNDER_REVIEW"}
STILL_LOOKS_PAID = {"PAID", "PARTIALLY_REFUNDED"}
def to_cents(amount):
return round(float(amount) * 100)
def has_open_chargeback(order):
for dispute in order.get("disputes") or []:
if dispute.get("initiatedAs") != "CHARGEBACK":
continue
if dispute.get("status") in OPEN_DISPUTE_STATUSES:
return True
return False
def needs_flag(order, required_tag):
if order.get("displayFinancialStatus") not in STILL_LOOKS_PAID:
return False
if not has_open_chargeback(order):
return False
return required_tag not in (order.get("tags") or [])
const OPEN_DISPUTE_STATUSES = new Set(["NEEDS_RESPONSE", "UNDER_REVIEW"]);
const STILL_LOOKS_PAID = new Set(["PAID", "PARTIALLY_REFUNDED"]);
export function toCents(amount) {
return Math.round(parseFloat(amount) * 100);
}
export function hasOpenChargeback(order) {
for (const dispute of order.disputes || []) {
if (dispute.initiatedAs !== "CHARGEBACK") continue;
if (OPEN_DISPUTE_STATUSES.has(dispute.status)) return true;
}
return false;
}
export function needsFlag(order, requiredTag) {
if (!STILL_LOOKS_PAID.has(order.displayFinancialStatus)) return false;
if (!hasOpenChargeback(order)) return false;
return !(order.tags || []).includes(requiredTag);
}
Write the dispute state onto the order
When an order needs flagging, call the tagsAdd mutation with the order id and the chargeback tag. This never changes financial status and never touches money, it only adds a tag so the true state shows up on the order itself, in views, exports, and automations that already key off tags. Always read back userErrors.
TAGS_ADD = """
mutation($id: ID!, $tags: [String!]!) {
tagsAdd(id: $id, tags: $tags) { node { id } userErrors { field message } }
}"""
def flag_order(order_id, tag):
result = gql(TAGS_ADD, {"id": order_id, "tags": [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 flagOrder(orderId, tag) {
const result = (await gql(TAGS_ADD, { id: orderId, tags: [tag] })).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 logs which orders it would tag, including the disputed amount in cents for context. Read the output, agree with it, then switch it off to let it write. Run it on a schedule that matches how quickly you want dispute visibility, for example every few hours.
Always start with DRY_RUN=true. This script only ever writes a tag, it never submits dispute evidence and never issues a refund, so a mistake here cannot move money. Deciding what evidence to submit for a dispute should stay a human call.
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 tags orders that still look settled but carry an unresolved chargeback.
View this code on GitHub Full runnable folder with tests in the shopify-fixes repo.
"""Write the dispute state onto Shopify orders that still show Paid.
A chargeback pulls the money through the card network right away, but Shopify
does not flip displayFinancialStatus when a dispute opens. The order keeps
reading Paid while the funds are already gone, so it slips past reconciliation
and reporting. This job lists recently paid orders, reads their disputes, and
tags the ones with an open chargeback so the order carries the true state.
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("flag_open_chargebacks")
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", "30"))
CHARGEBACK_TAG = os.environ.get("CHARGEBACK_TAG", "chargeback-open")
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
OPEN_DISPUTE_STATUSES = {"NEEDS_RESPONSE", "UNDER_REVIEW"}
STILL_LOOKS_PAID = {"PAID", "PARTIALLY_REFUNDED"}
ORDERS_QUERY = """
query($cursor: String, $q: String!) {
orders(first: 25, after: $cursor, query: $q) {
pageInfo { hasNextPage endCursor }
nodes {
id
name
tags
displayFinancialStatus
totalReceivedSet { shopMoney { amount currencyCode } }
disputes { id initiatedAs 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 to_cents(amount):
return round(float(amount) * 100)
def has_open_chargeback(order):
"""True when at least one dispute on the order is an unresolved chargeback."""
for dispute in order.get("disputes") or []:
if dispute.get("initiatedAs") != "CHARGEBACK":
continue
if dispute.get("status") in OPEN_DISPUTE_STATUSES:
return True
return False
def needs_flag(order, required_tag):
"""Pure decision: should this order be tagged as carrying an open chargeback?
True only when the order still displays as paid or partially refunded,
it has at least one open chargeback dispute, and it is not already tagged.
"""
if order.get("displayFinancialStatus") not in STILL_LOOKS_PAID:
return False
if not has_open_chargeback(order):
return False
return required_tag not in (order.get("tags") or [])
def disputed_amount_cents(order):
"""Sum of the shopMoney amount received on the order, in minor units.
Exposed for reporting only; the decision above never depends on the
exact amount, only on whether an open chargeback exists.
"""
received = (order.get("totalReceivedSet") or {}).get("shopMoney", {}).get("amount", "0")
return to_cents(received)
def flag_order(order_id, tag):
result = gql(TAGS_ADD, {"id": order_id, "tags": [tag]})["tagsAdd"]
if result["userErrors"]:
raise RuntimeError(result["userErrors"])
def recently_paid_orders():
q = f"created_at:>-{LOOKBACK_DAYS}d AND (financial_status:paid OR financial_status:partially_refunded)"
cursor = None
while True:
data = gql(ORDERS_QUERY, {"cursor": cursor, "q": q})["orders"]
for node in data["nodes"]:
yield node
if not data["pageInfo"]["hasNextPage"]:
return
cursor = data["pageInfo"]["endCursor"]
def run():
flagged = 0
for order in recently_paid_orders():
if not needs_flag(order, CHARGEBACK_TAG):
continue
log.warning(
"Order %s shows %s but has an open chargeback for %s cents. %s",
order["name"], order["displayFinancialStatus"], disputed_amount_cents(order),
"would tag" if DRY_RUN else "tagging",
)
if not DRY_RUN:
flag_order(order["id"], CHARGEBACK_TAG)
flagged += 1
log.info("Done. %d order(s) %s.", flagged, "to flag" if DRY_RUN else "flagged")
if __name__ == "__main__":
run()
/**
* Write the dispute state onto Shopify orders that still show Paid.
*
* A chargeback pulls the money through the card network right away, but Shopify
* does not flip displayFinancialStatus when a dispute opens. The order keeps
* reading Paid while the funds are already gone, so it slips past reconciliation
* and reporting. This job lists recently paid orders, reads their disputes, and
* tags the ones with an open chargeback so the order carries the true state.
* Read-only apart from the 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 LOOKBACK_DAYS = Number(process.env.LOOKBACK_DAYS || 30);
const CHARGEBACK_TAG = process.env.CHARGEBACK_TAG || "chargeback-open";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const OPEN_DISPUTE_STATUSES = new Set(["NEEDS_RESPONSE", "UNDER_REVIEW"]);
const STILL_LOOKS_PAID = new Set(["PAID", "PARTIALLY_REFUNDED"]);
export function toCents(amount) {
return Math.round(parseFloat(amount) * 100);
}
export function hasOpenChargeback(order) {
for (const dispute of order.disputes || []) {
if (dispute.initiatedAs !== "CHARGEBACK") continue;
if (OPEN_DISPUTE_STATUSES.has(dispute.status)) return true;
}
return false;
}
/**
* Pure decision: should this order be tagged as carrying an open chargeback?
* True only when the order still displays as paid or partially refunded,
* it has at least one open chargeback dispute, and it is not already tagged.
*/
export function needsFlag(order, requiredTag) {
if (!STILL_LOOKS_PAID.has(order.displayFinancialStatus)) return false;
if (!hasOpenChargeback(order)) return false;
return !(order.tags || []).includes(requiredTag);
}
/**
* Sum of the shopMoney amount received on the order, in minor units.
* Exposed for reporting only; the decision above never depends on the
* exact amount, only on whether an open chargeback exists.
*/
export function disputedAmountCents(order) {
const received = order.totalReceivedSet?.shopMoney?.amount ?? "0";
return toCents(received);
}
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, $q: String!) {
orders(first: 25, after: $cursor, query: $q) {
pageInfo { hasNextPage endCursor }
nodes {
id
name
tags
displayFinancialStatus
totalReceivedSet { shopMoney { amount currencyCode } }
disputes { id initiatedAs status }
}
}
}`;
const TAGS_ADD = `
mutation($id: ID!, $tags: [String!]!) {
tagsAdd(id: $id, tags: $tags) { node { id } userErrors { field message } }
}`;
async function* recentlyPaidOrders() {
const q = `created_at:>-${LOOKBACK_DAYS}d AND (financial_status:paid OR financial_status:partially_refunded)`;
let cursor = null;
while (true) {
const data = (await gql(ORDERS_QUERY, { cursor, q })).orders;
for (const node of data.nodes) yield node;
if (!data.pageInfo.hasNextPage) return;
cursor = data.pageInfo.endCursor;
}
}
async function flagOrder(orderId, tag) {
const result = (await gql(TAGS_ADD, { id: orderId, tags: [tag] })).tagsAdd;
if (result.userErrors.length) throw new Error(JSON.stringify(result.userErrors));
}
export async function run() {
let flagged = 0;
for await (const order of recentlyPaidOrders()) {
if (!needsFlag(order, CHARGEBACK_TAG)) continue;
console.warn(
`Order ${order.name} shows ${order.displayFinancialStatus} but has an open chargeback for ${disputedAmountCents(order)} cents. ${DRY_RUN ? "would tag" : "tagging"}`
);
if (!DRY_RUN) await flagOrder(order.id, CHARGEBACK_TAG);
flagged++;
}
console.log(`Done. ${flagged} order(s) ${DRY_RUN ? "to flag" : "flagged"}.`);
}
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 an order gets flagged as carrying an open chargeback. Because we kept needs_flag pure, the test needs no network and no Shopify account. It just feeds in plain objects and checks the answer.
from flag_open_chargebacks import needs_flag, has_open_chargeback, disputed_amount_cents, to_cents
def dispute(status="NEEDS_RESPONSE", initiated_as="CHARGEBACK"):
return {"id": "gid://shopify/ShopifyPaymentsDispute/1", "initiatedAs": initiated_as, "status": status}
def order(financial_status="PAID", disputes=None, tags=None, received="50.00"):
return {
"displayFinancialStatus": financial_status,
"disputes": disputes if disputes is not None else [dispute()],
"tags": tags or [],
"totalReceivedSet": {"shopMoney": {"amount": received, "currencyCode": "USD"}},
}
def test_needs_flag_true_when_paid_and_disputed_and_untagged():
assert needs_flag(order(), "chargeback-open") is True
def test_needs_flag_false_when_no_disputes():
assert needs_flag(order(disputes=[]), "chargeback-open") is False
def test_needs_flag_false_when_dispute_resolved():
assert needs_flag(order(disputes=[dispute(status="WON")]), "chargeback-open") is False
def test_needs_flag_false_when_already_tagged():
assert needs_flag(order(tags=["chargeback-open"]), "chargeback-open") is False
def test_disputed_amount_cents_reads_shop_money():
assert disputed_amount_cents(order(received="123.45")) == 12345
import { test } from "node:test";
import assert from "node:assert/strict";
import { needsFlag, hasOpenChargeback, disputedAmountCents, toCents } from "./flag-open-chargebacks.js";
const dispute = ({ status = "NEEDS_RESPONSE", initiatedAs = "CHARGEBACK" } = {}) => ({
id: "gid://shopify/ShopifyPaymentsDispute/1", initiatedAs, status,
});
const order = ({ financialStatus = "PAID", disputes = [dispute()], tags = [], received = "50.00" } = {}) => ({
displayFinancialStatus: financialStatus,
disputes,
tags,
totalReceivedSet: { shopMoney: { amount: received, currencyCode: "USD" } },
});
test("needsFlag true when paid, disputed, and untagged", () => {
assert.equal(needsFlag(order(), "chargeback-open"), true);
});
test("needsFlag false when no disputes", () => {
assert.equal(needsFlag(order({ disputes: [] }), "chargeback-open"), false);
});
test("needsFlag false when dispute resolved", () => {
assert.equal(needsFlag(order({ disputes: [dispute({ status: "WON" })] }), "chargeback-open"), false);
});
test("needsFlag false when already tagged", () => {
assert.equal(needsFlag(order({ tags: ["chargeback-open"] }), "chargeback-open"), false);
});
test("disputedAmountCents reads shop money", () => {
assert.equal(disputedAmountCents(order({ received: "123.45" })), 12345);
});
Case studies
A support ticket about an order that had already lost its money
A skincare brand's support team answered a shipping question on an order that looked completely normal, Paid, no flags, nothing unusual. The customer had actually filed a chargeback two weeks earlier, the funds were long gone from the payout, and support had no way to know from the order screen.
After adding this job, disputed orders now carry a chargeback-open tag the moment the dispute shows up in the Admin API. Support sees the tag immediately and stops treating the order as routine, and it gets routed to the person who handles evidence instead.
The payout that came up short with no obvious cause
A subscription box store noticed a payout that was several hundred dollars under what their order totals predicted. Finance spent an afternoon checking refunds and processing fees before finding three unrelated chargebacks buried in the Shopify Payments dashboard, none of them visible on the orders themselves.
Running the script in dry run first showed the exact three orders and the disputed amounts in cents. Turning it on for real means every future chargeback lands a tag on the order the same day, so the next payout shortfall takes minutes to explain instead of an afternoon.
After this runs on a schedule, an open chargeback shows up on the order itself within hours, not just in a separate disputes dashboard. Support stops answering questions about orders that already lost their money, finance can explain a payout shortfall in minutes, and nothing about handling the actual dispute changes, since submitting evidence stays a deliberate human decision.
FAQ
Why does a Shopify order still say Paid after a chargeback?
A chargeback is filed with the customer's bank, not with Shopify checkout, so Shopify does not automatically change displayFinancialStatus when a dispute opens. The order keeps showing Paid or Partially refunded until the dispute is finalized, even though the disputed amount has already left your payout.
How do I find orders with an open chargeback in the Admin GraphQL API?
Read the disputes field on the Order object. Each entry is an OrderDisputeSummary with initiatedAs and status. A chargeback is open when initiatedAs is CHARGEBACK and status is NEEDS_RESPONSE or UNDER_REVIEW. Orders with only an INQUIRY, or a dispute that is already WON, ACCEPTED, or LOST, are not open chargebacks.
Is it safe to automate handling of chargebacks?
Yes, as long as the automation only reads dispute data and writes a review tag, never moves money, and runs with a dry run flag first. Responding to a dispute and submitting evidence should stay a human decision, since it needs the actual proof of delivery or service, not a script.
Related field notes
Citations
On the problem:
- Shopify Help Center: chargebacks and how disputes work in Shopify Payments. help.shopify.com/en/manual/payments/shopify-payments/chargebacks
- Shopify Help Center: order payment status and what each financial status means. help.shopify.com/en/manual/orders/manage-orders/payment-status
- Shopify Community: orders that still show Paid despite an active dispute. community.shopify.com shopify payments
On the solution:
- Shopify Admin GraphQL: the ShopifyPaymentsDispute object and its order field. shopify.dev/docs/api/admin-graphql/latest/objects/ShopifyPaymentsDispute
- Shopify Admin GraphQL: the Order object, including disputes and displayFinancialStatus. shopify.dev/docs/api/admin-graphql/latest/objects/Order
- Shopify Admin GraphQL: the tagsAdd mutation. shopify.dev/docs/api/admin-graphql/latest/mutations/tagsAdd
Stuck on a tricky one?
If you have a problem in Shopify orders, payments, subscriptions, inventory, or fulfillment that you would rather hand off, this is the kind of work I do. Message me and we can work through it together.
Did this catch a hidden chargeback?
If this saved your team from treating a disputed order as a normal sale, 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