Repair Orders and payments
Shopify double charges from duplicate transactions
One order, two charges. The customer paid once but their card shows the amount twice, and now you have an angry email and a chargeback risk. It usually comes from a retry after a timeout, a double click on a manual capture, or an app that captured the same order more than once. Here is why it happens and a small job that finds the duplicate charges and refunds the extra in a safe way.
A retry or a misbehaving app can leave two successful charge transactions of the same amount on one order, so the customer pays twice. Run a small Python or Node.js job that reads each recent order's transactions, groups the successful SALE and CAPTURE charges by amount, treats every same-amount charge after the first as a duplicate, and refunds it with refundCreate. Full code, tests, and a dry run guard are below.
The problem in plain words
Every payment on an order is stored as a transaction. A normal order has one successful charge. But sometimes the charge step runs twice and you end up with two successful charges of the same amount on the same order. Both moved real money, so the customer paid double.
Shopify does not always block this, because from its point of view two charge requests came in and both succeeded. The order total still shows once, so the double charge hides in the transaction list where nobody looks until the customer complains.
Why it happens
A duplicate charge almost always comes from the same charge being sent twice. A few common ways it happens:
- A network timeout on the first charge, then a retry that also went through, so two charges succeed for one order.
- A staff member double clicked the capture button on a manual capture order.
- A third party app or a custom script captured or charged the order more than once, often after a webhook was delivered twice.
- A checkout or point of sale integration submitted the payment twice under load.
The safe way to spot a real double charge is to compare charges on the same order. Two successful charges of the same amount on one order is the signal. Two different amounts might be a legitimate split payment, so we leave those alone. See the citations at the end for the exact docs and threads.
The order total is not the place to look, because it only shows once. The truth is in the transaction list. If the same successful charge amount appears more than once on one order, every charge after the first is money the customer should get back.
The fix, as a flow
We do not touch the live checkout. We add a job that reads each recent order's transactions, groups the successful charges by amount, and refunds any charge that repeats an amount already seen on that order. The first charge of each amount stays, the extras are refunded.
Build it step by step
Get an Admin API access token
Create a custom app in your Shopify admin under Settings, Apps and sales channels, Develop apps. Give it the read_orders and write_orders scopes and install it to get an Admin API access token that starts with shpat_. Keep the token and the shop domain in environment variables, never in the file.
pip install requests
export SHOPIFY_SHOP="yourstore.myshopify.com"
export SHOPIFY_ACCESS_TOKEN="shpat_..."
export LOOKBACK_DAYS="7"
export DRY_RUN="true" # start safe, change to false to refund
// Node 18+ has fetch built in, no dependencies needed
export SHOPIFY_SHOP="yourstore.myshopify.com"
export SHOPIFY_ACCESS_TOKEN="shpat_..."
export LOOKBACK_DAYS="7"
export DRY_RUN="true" // start safe, change to false to refund
List recent orders with their transactions
Ask for orders created in your lookback window and read back each order's transactions, including the kind, the status, and the amount. Grouping happens per order, so we only need the transactions on that one order. We page through with a cursor so the job handles a backlog.
ORDERS_QUERY = """
query($cursor: String, $q: String!) {
orders(first: 25, after: $cursor, query: $q) {
pageInfo { hasNextPage endCursor }
nodes {
id name
transactions(first: 20) {
id kind status
amountSet { shopMoney { amount currencyCode } }
}
}
}
}"""
def recent_orders(lookback_days):
q = f"created_at:>-{lookback_days}d"
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
transactions(first: 20) {
id kind status
amountSet { shopMoney { amount currencyCode } }
}
}
}
}`;
async function* recentOrders(lookbackDays) {
const q = `created_at:>-${lookbackDays}d`;
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;
}
}
Find the duplicates, with one pure function
Keep the detection in its own function that takes a list of transactions and returns the extras to refund. It only counts successful SALE and CAPTURE transactions, so a failed attempt or a refund never looks like a duplicate. The first charge of each amount is kept, and every later charge of that same amount is returned as a duplicate.
CHARGE_KINDS = {"SALE", "CAPTURE"}
def duplicate_sale_transactions(transactions):
seen = set()
extras = []
for t in transactions or []:
if t.get("kind") in CHARGE_KINDS and t.get("status") == "SUCCESS":
amount = t["amountSet"]["shopMoney"]["amount"]
if amount in seen:
extras.append(t)
else:
seen.add(amount)
return extras
const CHARGE_KINDS = new Set(["SALE", "CAPTURE"]);
export function duplicateSaleTransactions(transactions) {
const seen = new Set();
const extras = [];
for (const t of transactions || []) {
if (CHARGE_KINDS.has(t.kind) && t.status === "SUCCESS") {
const amount = t.amountSet.shopMoney.amount;
if (seen.has(amount)) extras.push(t);
else seen.add(amount);
}
}
return extras;
}
Refund the extra charge with refundCreate
For each duplicate, call the refundCreate mutation with the order id and a refund transaction that points at the duplicate charge as its parent. Refunding against the specific transaction returns exactly that charge. Always read back userErrors and stop on them rather than assume the refund worked.
REFUND_MUTATION = """
mutation($input: RefundInput!) {
refundCreate(input: $input) {
refund { id }
userErrors { field message }
}
}"""
def refund(order_id, txn):
money = txn["amountSet"]["shopMoney"]
result = gql(REFUND_MUTATION, {"input": {
"orderId": order_id,
"transactions": [{
"parentId": txn["id"],
"amount": money["amount"],
"gateway": "shopify_payments",
"kind": "REFUND",
}],
}})["refundCreate"]
if result["userErrors"]:
raise RuntimeError(result["userErrors"])
return result["refund"]["id"]
const REFUND_MUTATION = `
mutation($input: RefundInput!) {
refundCreate(input: $input) {
refund { id }
userErrors { field message }
}
}`;
async function refund(orderId, txn) {
const money = txn.amountSet.shopMoney;
const result = (await gql(REFUND_MUTATION, {
input: {
orderId,
transactions: [{ parentId: txn.id, amount: money.amount, gateway: "shopify_payments", kind: "REFUND" }],
},
})).refundCreate;
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. On the first few runs, leave DRY_RUN on so the job only reports which duplicate charges it would refund. Read the output, agree with it, then switch it off. A refund moves real money, so review the first list carefully before you let it write.
Always start with DRY_RUN=true. This job refunds money, so review the list first. Two different amounts on one order can be a real split payment, which is why the rule only ever refunds a repeat of the exact same amount.
The full code
Here is the complete job in one file for each language. It reads settings from the environment, logs what it does, respects the dry run flag, and is safe to run again and again because it only refunds a duplicate of an amount already charged on the same order.
View this code on GitHub Full runnable folder with tests in the shopify-fixes repo.
"""Find Shopify orders that were charged twice and refund the extra charge.
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_duplicate_charges")
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", "7"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
CHARGE_KINDS = {"SALE", "CAPTURE"}
ORDERS_QUERY = """
query($cursor: String, $q: String!) {
orders(first: 25, after: $cursor, query: $q) {
pageInfo { hasNextPage endCursor }
nodes {
id name
transactions(first: 20) {
id kind status
amountSet { shopMoney { amount currencyCode } }
}
}
}
}"""
REFUND_MUTATION = """
mutation($input: RefundInput!) {
refundCreate(input: $input) {
refund { 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 duplicate_sale_transactions(transactions):
seen = set()
extras = []
for t in transactions or []:
if t.get("kind") in CHARGE_KINDS and t.get("status") == "SUCCESS":
amount = t["amountSet"]["shopMoney"]["amount"]
if amount in seen:
extras.append(t)
else:
seen.add(amount)
return extras
def refund(order_id, txn):
money = txn["amountSet"]["shopMoney"]
result = gql(REFUND_MUTATION, {"input": {
"orderId": order_id,
"transactions": [{
"parentId": txn["id"],
"amount": money["amount"],
"gateway": "shopify_payments",
"kind": "REFUND",
}],
}})["refundCreate"]
if result["userErrors"]:
raise RuntimeError(result["userErrors"])
return result["refund"]["id"]
def recent_orders():
q = f"created_at:>-{LOOKBACK_DAYS}d"
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():
refunded = 0
for order in recent_orders():
for txn in duplicate_sale_transactions(order["transactions"]):
money = txn["amountSet"]["shopMoney"]
log.warning("Order %s has a duplicate charge of %s %s. %s",
order["name"], money["amount"], money["currencyCode"],
"would refund" if DRY_RUN else "refunding")
if not DRY_RUN:
refund(order["id"], txn)
refunded += 1
log.info("Done. %d duplicate charge(s) %s.", refunded, "to refund" if DRY_RUN else "refunded")
if __name__ == "__main__":
run()
/**
* Find Shopify orders that were charged twice and refund the extra charge.
* 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 LOOKBACK_DAYS = Number(process.env.LOOKBACK_DAYS || 7);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const CHARGE_KINDS = new Set(["SALE", "CAPTURE"]);
export function duplicateSaleTransactions(transactions) {
const seen = new Set();
const extras = [];
for (const t of transactions || []) {
if (CHARGE_KINDS.has(t.kind) && t.status === "SUCCESS") {
const amount = t.amountSet.shopMoney.amount;
if (seen.has(amount)) extras.push(t);
else seen.add(amount);
}
}
return extras;
}
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
transactions(first: 20) {
id kind status
amountSet { shopMoney { amount currencyCode } }
}
}
}
}`;
const REFUND_MUTATION = `
mutation($input: RefundInput!) {
refundCreate(input: $input) {
refund { id }
userErrors { field message }
}
}`;
async function* recentOrders() {
const q = `created_at:>-${LOOKBACK_DAYS}d`;
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 refund(orderId, txn) {
const money = txn.amountSet.shopMoney;
const result = (await gql(REFUND_MUTATION, {
input: {
orderId,
transactions: [{ parentId: txn.id, amount: money.amount, gateway: "shopify_payments", kind: "REFUND" }],
},
})).refundCreate;
if (result.userErrors.length) throw new Error(JSON.stringify(result.userErrors));
}
export async function run() {
let refunded = 0;
for await (const order of recentOrders()) {
for (const txn of duplicateSaleTransactions(order.transactions)) {
const money = txn.amountSet.shopMoney;
console.warn(`Order ${order.name} has a duplicate charge of ${money.amount} ${money.currencyCode}. ${DRY_RUN ? "would refund" : "refunding"}`);
if (!DRY_RUN) await refund(order.id, txn);
refunded++;
}
}
console.log(`Done. ${refunded} duplicate charge(s) ${DRY_RUN ? "to refund" : "refunded"}.`);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The detection rule is the part most worth testing, because it decides whether a real charge gets refunded. Because we kept duplicate_sale_transactions pure, the test needs no network and no Shopify account. It just feeds in plain transactions and checks which ones come back as duplicates.
from find_duplicate_charges import duplicate_sale_transactions
def txn(amount, kind="SALE", status="SUCCESS", tid="t"):
return {"id": tid, "kind": kind, "status": status,
"amountSet": {"shopMoney": {"amount": amount, "currencyCode": "USD"}}}
def test_no_duplicates_when_single_charge():
assert duplicate_sale_transactions([txn("50.00")]) == []
def test_finds_one_duplicate_of_same_amount():
extras = duplicate_sale_transactions([txn("50.00", tid="a"), txn("50.00", tid="b")])
assert [t["id"] for t in extras] == ["b"]
def test_different_amounts_are_not_duplicates():
assert duplicate_sale_transactions([txn("50.00"), txn("20.00")]) == []
def test_ignores_failed_and_refund_transactions():
txns = [txn("50.00", tid="a"),
txn("50.00", status="FAILURE", tid="b"),
txn("50.00", kind="REFUND", tid="c")]
assert duplicate_sale_transactions(txns) == []
import { test } from "node:test";
import assert from "node:assert/strict";
import { duplicateSaleTransactions } from "./find-duplicate-charges.js";
const txn = (amount, { kind = "SALE", status = "SUCCESS", id = "t" } = {}) => ({
id, kind, status, amountSet: { shopMoney: { amount, currencyCode: "USD" } },
});
test("no duplicates when single charge", () => {
assert.deepEqual(duplicateSaleTransactions([txn("50.00")]), []);
});
test("finds one duplicate of the same amount", () => {
const extras = duplicateSaleTransactions([txn("50.00", { id: "a" }), txn("50.00", { id: "b" })]);
assert.deepEqual(extras.map((t) => t.id), ["b"]);
});
test("different amounts are not duplicates", () => {
assert.deepEqual(duplicateSaleTransactions([txn("50.00"), txn("20.00")]), []);
});
test("ignores failed and refund transactions", () => {
const txns = [txn("50.00", { id: "a" }), txn("50.00", { status: "FAILURE", id: "b" }), txn("50.00", { kind: "REFUND", id: "c" })];
assert.deepEqual(duplicateSaleTransactions(txns), []);
});
Case studies
The checkout that charged twice under load
During a product launch a store's checkout slowed down, a few charge calls timed out, and the retry also went through. About a dozen customers were charged twice and started asking for their money back on the same day.
The job on an hourly schedule found every double charge from that window and refunded the extras. Instead of hunting through transaction lists, the team had a clean list and the refunds went out before most customers even noticed.
The app that captured orders twice
A third party app captured orders when a webhook arrived, but the webhook was sometimes delivered twice, so a set of orders got two identical captures. The order totals looked normal, so nobody caught it until a chargeback came in.
The team ran the job in dry run first, saw exactly which orders held duplicate captures, then refunded them. They kept the job running as a safety net while the app vendor fixed the double capture.
After this runs on a schedule, a double charge is caught within the hour instead of turning into a chargeback weeks later. The customer gets their money back fast, your dispute rate stays low, and the transaction list stops hiding surprises. Keep it running even after you fix the root cause, because retries happen.
FAQ
Why was my Shopify customer charged twice for one order?
A retry after a timeout, a double click on a manual capture, or an app that captured the order more than once can create two successful charge transactions of the same amount on one order. Both take money, so the customer is charged twice. Finding the duplicate transaction and refunding it fixes it.
How do I detect a duplicate charge on a Shopify order?
Read the order transactions and group the successful SALE and CAPTURE transactions by amount. If the same amount appears more than once, every charge after the first is a duplicate. This ignores failed attempts and refunds, so it only flags real double charges.
Is it safe to refund duplicate charges with a script?
Yes, when the script only refunds the extra successful charge of an amount that appears more than once on the same order, and runs in dry run first so you can review the list. It never refunds the first charge or a legitimate second order.
Related field notes
Citations
On the problem:
- Shopify Community: customer charged twice for a single order. community.shopify.com payments and shipping
- Shopify Help Center: order transactions and the payment timeline. help.shopify.com/en/manual/orders/manage-orders
- Shopify Help Center: refunding an order or part of an order. help.shopify.com/en/manual/orders/refund-cancel-order
On the solution:
- Shopify Admin GraphQL: the
refundCreatemutation. shopify.dev/docs/api/admin-graphql/latest/mutations/refundCreate - Shopify Admin GraphQL: the OrderTransaction object, its kind, status, and amountSet. shopify.dev/docs/api/admin-graphql/latest/objects/OrderTransaction
- Shopify Admin GraphQL: the
ordersquery and its search syntax. shopify.dev/docs/api/admin-graphql/latest/queries/orders
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 double charge?
If this saved a customer a wrong charge or you a chargeback, 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