Reconciler Refunds, payouts, and reconciliation

Refund exists but the money never moved

Support marked the order refunded. The customer is staring at their bank statement asking where the money is. Shopify shows a refund object right there on the order, so it looks done. But the refund object is only a record of an attempt, and the gateway transaction underneath it can fail, error out, or sit on Pending forever. Here is why a refund can exist and go nowhere, and a small script that finds the stuck ones so you can chase them down before the customer does it for you in a chargeback.

Python and Node.js Admin GraphQL API Safe by default (dry run)
A pile of money sitting on top of a white table
Photo by engin akyurt on Unsplash
The short answer

A refund on a Shopify order is made of one or more transactions, and the refund can exist and still not pay the customer if its transaction status is PENDING, FAILURE, or ERROR instead of SUCCESS. Run a small Python or Node.js script that pages through recent refunds, sums only the transactions with status SUCCESS in shopMoney minor units, compares that to the refund's own total, and tags any order where the two do not match so a human can look and retry the payout. Full code, tests, and a dry run guard are below.

The problem in plain words

When you refund an order in Shopify, the platform does two things. It creates a refund record that says this amount was refunded, and it sends the actual money back through one or more transactions tied to the original payment method.

Those two things are supposed to move together, but they are not the same event. The refund record gets created first. The transaction then goes to the gateway, and the gateway can take its time, or reject it outright. If the customer's card was closed, if the gateway is having a bad day, or if a refund is attempted twice too fast, the transaction can come back FAILURE or ERROR, or it can sit on PENDING well past when it should have settled. The refund object is still there on the order looking exactly like a normal, successful refund. Nobody notices until the customer says the money never showed up.

Refund issued record created Sent to gateway transaction created FAILURE, ERROR, or stuck PENDING Order shows Refunded looks complete Customer's money never arrives
The refund record and the money are two different things. The record can exist while the transaction underneath it never actually paid the customer.

Why it happens

Shopify's refund object is created up front so the order and the customer see a clear, immediate response. The transaction that actually moves the money is a separate step that can fail for reasons that have nothing to do with whether the refund was a good idea. A few common ways stores end up with a refund that went nowhere:

This is a quiet failure mode because Shopify's UI leans on the refund existing, not on the transaction succeeding. Support agents see "Refunded" and move on. The customer eventually opens a dispute, and now you are refunding twice or fighting a chargeback for money that was never actually sent the first time. See the citations at the end for the exact fields and docs this relies on.

The key insight

A refund is trustworthy only when every transaction it created has a status of SUCCESS. So the safe pattern is not "the order has a refund." It is "the successful transactions on that refund add up to what the refund says it returned." We do the comparison in whole cents, using shopMoney so currency conversion never enters the picture, and we only ever write a review tag. Nothing about payments is touched automatically.

The fix, as a flow

We do not retry payments and we do not touch the gateway. We add a job that lists recent orders with refunds, reads each refund's transactions, sums only the ones that actually succeeded, and compares that sum in cents to what the refund claims to have returned. When they do not match, the order gets a review tag so a human can go retry the payout or refund again through the correct channel. Everything that ties out is left alone.

Scheduled job runs on a timer List recent refunds orders with a refund Sum SUCCESS only shopMoney, in cents Matches the refund total? yes, skip no tagsAdd flagged for review
The script only ever adds a review tag. A human decides whether to retry the refund, retry through the gateway directly, or reach out to the customer first.

Build it step by step

1

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 (write is only used for the review tag) 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.

setup (shell)
pip install requests

export SHOPIFY_SHOP="yourstore.myshopify.com"
export SHOPIFY_ACCESS_TOKEN="shpat_..."
export LOOKBACK_DAYS="14"
export REVIEW_TAG="refund-stuck"
export DRY_RUN="true"   # start safe, change to false to write
setup (shell)
// Node 18+ has fetch built in, no dependencies needed

export SHOPIFY_SHOP="yourstore.myshopify.com"
export SHOPIFY_ACCESS_TOKEN="shpat_..."
export LOOKBACK_DAYS="14"
export REVIEW_TAG="refund-stuck"
export DRY_RUN="true"   // start safe, change to false to write
2

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 add the review tag.

step2.py
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"]
step2.js
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;
}
3

List recent orders and their refunds

Ask for recent orders and, for each one, its refunds and the transactions that belong to each refund. Read back the transaction's kind, status, and its amount in shopMoney, plus the refund's own total in shopMoney. We page through with a cursor so the job handles a large history.

step3.py
ORDERS_QUERY = """
query($cursor: String, $q: String!) {
  orders(first: 25, after: $cursor, query: $q) {
    pageInfo { hasNextPage endCursor }
    nodes {
      id name tags
      refunds {
        id
        totalRefundedSet { shopMoney { amount currencyCode } }
        transactions(first: 10) {
          nodes { kind status amountSet { shopMoney { amount currencyCode } } }
        }
      }
    }
  }
}"""

def refunded_orders():
    q = f"created_at:>-{LOOKBACK_DAYS}d AND financial_status:refunded 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"]
step3.js
const ORDERS_QUERY = `
query($cursor: String, $q: String!) {
  orders(first: 25, after: $cursor, query: $q) {
    pageInfo { hasNextPage endCursor }
    nodes {
      id name tags
      refunds {
        id
        totalRefundedSet { shopMoney { amount currencyCode } }
        transactions(first: 10) {
          nodes { kind status amountSet { shopMoney { amount currencyCode } } }
        }
      }
    }
  }
}`;

async function* refundedOrders() {
  const q = `created_at:>-${LOOKBACK_DAYS}d AND financial_status:refunded 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;
  }
}
4

Decide, with pure functions in cents

Keep the money math and the decision in their own functions that take plain data and return plain values. Working in whole cents means the comparison never suffers from floating point rounding. The rule is strict on purpose: a refund only counts as moved when its transactions with status SUCCESS add up, within a cent, to what the refund itself claims. Anything else, a refund with no successful transaction, a partial success, or a refund still on PENDING, is a stuck refund.

decide.py
def to_cents(amount):
    return round(float(amount) * 100)


def moved_cents(refund):
    """Sum of only the SUCCESS transactions on a refund, in minor units."""
    total = 0
    for t in (refund.get("transactions") or {}).get("nodes", []):
        if t.get("status") == "SUCCESS":
            total += to_cents(t["amountSet"]["shopMoney"]["amount"])
    return total


def is_stuck_refund(refund):
    claimed = to_cents(refund.get("totalRefundedSet", {}).get("shopMoney", {}).get("amount", "0"))
    return abs(moved_cents(refund) - claimed) > 1
decide.js
export function toCents(amount) {
  return Math.round(parseFloat(amount) * 100);
}

export function movedCents(refund) {
  let total = 0;
  for (const t of refund.transactions?.nodes || []) {
    if (t.status === "SUCCESS") total += toCents(t.amountSet.shopMoney.amount);
  }
  return total;
}

export function isStuckRefund(refund) {
  const claimed = toCents(refund.totalRefundedSet?.shopMoney?.amount ?? "0");
  return Math.abs(movedCents(refund) - claimed) > 1;
}
5

Tag the order, never touch the money

When an order has at least one stuck refund, add a review tag with tagsAdd. This is the only write the script performs. It does not retry the gateway, and it does not create a second refund, because both of those decisions need a human to check the customer's actual bank or card statement first.

apply.py
TAGS_ADD = """
mutation($id: ID!, $tags: [String!]!) {
  tagsAdd(id: $id, tags: $tags) { node { id } userErrors { field message } }
}"""

def tag_for_review(order_id, review_tag):
    result = gql(TAGS_ADD, {"id": order_id, "tags": [review_tag]})["tagsAdd"]
    if result["userErrors"]:
        raise RuntimeError(result["userErrors"])
apply.js
const TAGS_ADD = `
mutation($id: ID!, $tags: [String!]!) {
  tagsAdd(id: $id, tags: $tags) { node { id } userErrors { field message } }
}`;

async function tagForReview(orderId, reviewTag) {
  const result = (await gql(TAGS_ADD, { id: orderId, tags: [reviewTag] })).tagsAdd;
  if (result.userErrors.length) throw new Error(JSON.stringify(result.userErrors));
}
6

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 orders it would tag. Read the output, agree with it, then switch it off to let it write the tag. Run it on a schedule that matches your refund volume, for example once a day.

Run it safe

Always start with DRY_RUN=true, and treat the tag as a to-do for a human, not a fix. Retrying a refund or the payout is a judgment call that depends on what the gateway says happened, so this script only ever finds the problem, it never guesses at a solution.

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 the only write it makes is a review tag on orders whose refund money never actually moved.

View this code on GitHub Full runnable folder with tests in the shopify-fixes repo.

find_stuck_refunds.py
"""Flag Shopify orders whose refund exists but the money never actually moved.

A refund record can sit on an order while the gateway transaction underneath it
failed, errored, or never left PENDING. This sums each refund's SUCCESS-only
transactions in minor units, compares that to the refund's own totalRefundedSet,
and tags the order for review with tagsAdd when they do not match. 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_stuck_refunds")

SHOP = os.environ.get("SHOPIFY_SHOP", "example.myshopify.com")
TOKEN = os.environ.get("SHOPIFY_ACCESS_TOKEN", "shpat_dummy")
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", "14"))
REVIEW_TAG = os.environ.get("REVIEW_TAG", "refund-stuck")
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"

ORDERS_QUERY = """
query($cursor: String, $q: String!) {
  orders(first: 25, after: $cursor, query: $q) {
    pageInfo { hasNextPage endCursor }
    nodes {
      id name tags
      refunds {
        id
        totalRefundedSet { shopMoney { amount currencyCode } }
        transactions(first: 10) {
          nodes { kind status amountSet { shopMoney { amount currencyCode } } }
        }
      }
    }
  }
}"""

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 moved_cents(refund):
    """Sum of only the SUCCESS transactions on a refund, in minor units."""
    total = 0
    for t in (refund.get("transactions") or {}).get("nodes", []):
        if t.get("status") == "SUCCESS":
            total += to_cents(t["amountSet"]["shopMoney"]["amount"])
    return total


def is_stuck_refund(refund):
    claimed = to_cents(refund.get("totalRefundedSet", {}).get("shopMoney", {}).get("amount", "0"))
    return abs(moved_cents(refund) - claimed) > 1


def has_stuck_refund(order):
    return any(is_stuck_refund(r) for r in order.get("refunds") or [])


def tag_for_review(order_id, review_tag):
    result = gql(TAGS_ADD, {"id": order_id, "tags": [review_tag]})["tagsAdd"]
    if result["userErrors"]:
        raise RuntimeError(result["userErrors"])


def refunded_orders():
    q = f"created_at:>-{LOOKBACK_DAYS}d AND financial_status:refunded 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 refunded_orders():
        if not has_stuck_refund(order):
            continue
        if REVIEW_TAG in (order.get("tags") or []):
            continue
        log.warning("Order %s has a refund that never moved the money. %s",
                    order["name"], "would tag" if DRY_RUN else "tagging")
        if not DRY_RUN:
            tag_for_review(order["id"], REVIEW_TAG)
        flagged += 1
    log.info("Done. %d order(s) %s.", flagged, "to tag" if DRY_RUN else "tagged")


if __name__ == "__main__":
    run()
find-stuck-refunds.js
/**
 * Flag Shopify orders whose refund exists but the money never actually moved.
 *
 * A refund record can sit on an order while the gateway transaction underneath it
 * failed, errored, or never left PENDING. This sums each refund's SUCCESS-only
 * transactions in minor units, compares that to the refund's own totalRefundedSet,
 * and tags the order for review with tagsAdd when they do not match. 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 || 14);
const REVIEW_TAG = process.env.REVIEW_TAG || "refund-stuck";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

export function toCents(amount) {
  return Math.round(parseFloat(amount) * 100);
}

export function movedCents(refund) {
  let total = 0;
  for (const t of refund.transactions?.nodes || []) {
    if (t.status === "SUCCESS") total += toCents(t.amountSet.shopMoney.amount);
  }
  return total;
}

export function isStuckRefund(refund) {
  const claimed = toCents(refund.totalRefundedSet?.shopMoney?.amount ?? "0");
  return Math.abs(movedCents(refund) - claimed) > 1;
}

export function hasStuckRefund(order) {
  return (order.refunds || []).some(isStuckRefund);
}

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
      refunds {
        id
        totalRefundedSet { shopMoney { amount currencyCode } }
        transactions(first: 10) {
          nodes { kind status amountSet { shopMoney { amount currencyCode } } }
        }
      }
    }
  }
}`;

const TAGS_ADD = `
mutation($id: ID!, $tags: [String!]!) {
  tagsAdd(id: $id, tags: $tags) { node { id } userErrors { field message } }
}`;

async function* refundedOrders() {
  const q = `created_at:>-${LOOKBACK_DAYS}d AND financial_status:refunded 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 tagForReview(orderId, reviewTag) {
  const result = (await gql(TAGS_ADD, { id: orderId, tags: [reviewTag] })).tagsAdd;
  if (result.userErrors.length) throw new Error(JSON.stringify(result.userErrors));
}

export async function run() {
  let flagged = 0;
  for await (const order of refundedOrders()) {
    if (!hasStuckRefund(order)) continue;
    if ((order.tags || []).includes(REVIEW_TAG)) continue;
    console.warn(`Order ${order.name} has a refund that never moved the money. ${DRY_RUN ? "would tag" : "tagging"}`);
    if (!DRY_RUN) await tagForReview(order.id, REVIEW_TAG);
    flagged++;
  }
  console.log(`Done. ${flagged} order(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 money math and the decision rule are the parts most worth testing, because they decide which orders get flagged as broken. Because we kept moved_cents and is_stuck_refund pure, the tests need no network and no Shopify account. They just feed in plain objects and check the answer.

test_refund_stuck.py
from find_stuck_refunds import moved_cents, is_stuck_refund, to_cents, has_stuck_refund


def txn(amount, status="SUCCESS", kind="REFUND"):
    return {"kind": kind, "status": status,
            "amountSet": {"shopMoney": {"amount": amount, "currencyCode": "USD"}}}


def refund(total, txns):
    return {
        "totalRefundedSet": {"shopMoney": {"amount": total, "currencyCode": "USD"}},
        "transactions": {"nodes": txns},
    }


def test_to_cents_rounds():
    assert to_cents("20.00") == 2000
    assert to_cents("9.99") == 999


def test_moved_cents_counts_only_success():
    txns = [txn("20.00", status="SUCCESS"), txn("20.00", status="FAILURE")]
    assert moved_cents({"transactions": {"nodes": txns}}) == 2000


def test_not_stuck_when_success_matches_claim():
    assert is_stuck_refund(refund("20.00", [txn("20.00")])) is False


def test_stuck_when_transaction_failed():
    assert is_stuck_refund(refund("20.00", [txn("20.00", status="FAILURE")])) is True


def test_stuck_when_transaction_pending():
    assert is_stuck_refund(refund("20.00", [txn("20.00", status="PENDING")])) is True


def test_stuck_when_no_transactions_at_all():
    assert is_stuck_refund(refund("20.00", [])) is True


def test_has_stuck_refund_true_when_any_refund_is_stuck():
    order = {"refunds": [refund("20.00", [txn("20.00")]), refund("5.00", [txn("5.00", status="ERROR")])]}
    assert has_stuck_refund(order) is True


def test_has_stuck_refund_false_when_all_refunds_ok():
    order = {"refunds": [refund("20.00", [txn("20.00")])]}
    assert has_stuck_refund(order) is False
refund-stuck.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { movedCents, isStuckRefund, toCents, hasStuckRefund } from "./find-stuck-refunds.js";

const txn = (amount, { status = "SUCCESS", kind = "REFUND" } = {}) => ({
  kind, status, amountSet: { shopMoney: { amount, currencyCode: "USD" } },
});

const refund = (total, transactions) => ({
  totalRefundedSet: { shopMoney: { amount: total, currencyCode: "USD" } },
  transactions: { nodes: transactions },
});

test("toCents rounds", () => {
  assert.equal(toCents("20.00"), 2000);
  assert.equal(toCents("9.99"), 999);
});

test("movedCents counts only SUCCESS", () => {
  const txns = [txn("20.00", { status: "SUCCESS" }), txn("20.00", { status: "FAILURE" })];
  assert.equal(movedCents({ transactions: { nodes: txns } }), 2000);
});

test("not stuck when success matches claim", () => {
  assert.equal(isStuckRefund(refund("20.00", [txn("20.00")])), false);
});

test("stuck when transaction failed", () => {
  assert.equal(isStuckRefund(refund("20.00", [txn("20.00", { status: "FAILURE" })])), true);
});

test("stuck when transaction pending", () => {
  assert.equal(isStuckRefund(refund("20.00", [txn("20.00", { status: "PENDING" })])), true);
});

test("stuck when no transactions at all", () => {
  assert.equal(isStuckRefund(refund("20.00", [])), true);
});

test("hasStuckRefund true when any refund is stuck", () => {
  const order = { refunds: [refund("20.00", [txn("20.00")]), refund("5.00", [txn("5.00", { status: "ERROR" })])] };
  assert.equal(hasStuckRefund(order), true);
});

test("hasStuckRefund false when all refunds ok", () => {
  const order = { refunds: [refund("20.00", [txn("20.00")])] };
  assert.equal(hasStuckRefund(order), false);
});

Case studies

Closed card

The return that never landed

A clothing store processed a return and issued the refund the same afternoon. The order timeline showed it as refunded and support closed the ticket. Three weeks later the customer called their bank, and the store found out the card on file had been closed the week before, so the gateway transaction had quietly failed while the refund record sat there looking normal.

Now a daily job checks every refund's transactions against its claimed total. The closed-card failure gets tagged the same day it happens, and the team retries the payout to a new card before the customer has to chase it.

Double refund attempt

The app and the agent both clicked refund

A support app auto-refunded a canceled order at the same moment an agent processed the same refund by hand in the Admin. One attempt succeeded, the other errored out from a duplicate request, but both left an entry on the order, and it was hard to tell from the order page alone which one actually paid.

The script sums only the transactions that say SUCCESS, so the duplicate attempt that failed does not count toward the total. It flagged the order, the team could see one transaction succeeded and one did not, and closed it out without refunding the customer twice.

What good looks like

After this runs on a schedule, a refund that never moved the money gets caught inside a day, not three weeks later from a chargeback. Support can tell a customer the truth instead of insisting a refund is on its way when the gateway already said no. Keep the retry decision with a human, since that is what keeps the script honest and keeps you from moving money on autopilot.

FAQ

Why does a Shopify order show a refund that the customer never received?

Shopify creates a refund record as soon as you start the refund, but the money only moves once the gateway transaction underneath it settles. If that transaction comes back with a status of FAILURE or ERROR, or never leaves PENDING, the refund object stays on the order while no money actually left your account, so the customer sees nothing arrive.

Is it safe to run a script that touches refund transactions?

Yes, when the script only reads refunds and their transactions and tags the order for a human to review, and runs in dry run first. It never retries a payment or moves money on its own, so the worst it can do is add a tag.

What transaction status means a refund actually reached the customer?

A refund only moved money when its transaction status is SUCCESS. PENDING means the gateway has not settled it yet, and FAILURE or ERROR mean the attempt did not go through, so the order looks refunded while the customer's money is still with you.

Related field notes

Citations

On the problem:

  1. Shopify Help Center: refund an order and how refund status is tracked. help.shopify.com/en/manual/orders/refund-return-exchange
  2. Shopify Help Center: understanding payouts and how refunds affect them. help.shopify.com/en/manual/payments/shopify-payments/payout-schedule
  3. Shopify Community: refund shows on the order but the transaction failed. community.shopify.com graphql admin api

On the solution:

  1. Shopify Admin GraphQL: the Refund object, including totalRefundedSet and its transactions. shopify.dev/docs/api/admin-graphql/latest/objects/Refund
  2. Shopify Admin GraphQL: the OrderTransaction object, including status and kind. shopify.dev/docs/api/admin-graphql/latest/objects/OrderTransaction
  3. 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.

Contact me on LinkedIn

Did this catch a stuck refund?

If this saved you an awkward call with a customer or a chargeback you did not see coming, 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

Back to all Shopify field notes