Skip to content

Diagnostic Credit Memos and Refunds

Online refund silently falls back to offline

A support agent opens an order, clicks Credit Memo, and expects Magento to hand the money back through the same card gateway the customer paid with. Instead the form only shows an offline refund option, or the agent does not notice the toggle and submits an offline credit memo anyway. Magento marks the order Refunded. The customer never sees the money. Here is why the gateway call gets skipped without any error, and a small script that finds every credit memo where that gap is hiding.

Python and Node.js Creditmemo and Transactions REST API Safe by default (report only)
Hands holding phones
Photo by David Dvoracek on Unsplash
The short answer

Magento's admin credit memo form only offers an online refund when the payment method's gateway adapter reports canRefund or canRefundPartialPerInvoice as true for that specific invoice's capture transaction. If the original capture cannot be found, if the gateway adapter does not support refunds for that transaction type, or if the online refund call errors out, Magento quietly falls back to showing only the offline form. If a human submits that form, Magento creates a perfectly normal looking credit memo, sets the order's payment status to refunded, and nothing about the record on screen says the gateway was never called. A script can list recent credit memos over the REST API, check whether a matching refund transaction exists against the original capture, and flag every credit memo on a gateway backed method that has no such transaction. Full code, tests, and a dry run guard are below.

The problem in plain words

When a payment method supports online refunds, the credit memo screen in the Magento admin is supposed to send the refund straight to the gateway, the same way the original charge went through it. Click Refund, Magento calls the payment adapter, the adapter talks to the processor, money moves back to the card, and a refund transaction is recorded against the original capture.

That whole path depends on Magento being able to confirm, at the moment the form renders, that an online refund is actually possible. It checks the payment method's own canRefund and canRefundPartialPerInvoice capabilities, and it needs to find the original capture transaction still attached to the invoice being refunded. When any of that comes back false, or the capture transaction cannot be located because of a partial capture, a split invoice, or a gateway that expired the transaction on its side, Magento does not throw an error. It simply narrows the form down to an offline refund only, or in some integrations leaves both options visible but silently ignores the online toggle if the backend check already failed. An agent who is used to seeing an online option can easily submit the form without noticing that only offline was ever available, or that the online box quietly did not do what it looked like it did.

Agent opens credit memo, expects online canRefund fails or capture lookup misses Gateway check runs before form renders Offline only form no error shown Order Refunded gateway never called No refund transaction is ever recorded against the original capture
The form fails silently, not the refund. Magento never tells the agent the gateway path was skipped, it just narrows the options and lets the offline submission through as if it were routine.

Why it happens

This is a well known category of Magento community complaint, precisely because nothing in the interface tells you the online path was attempted and failed. The credit memo looks the same either way. See the citations at the end for the exact threads and the payment gateway integration guide that documents these capability checks.

The key insight

An offline credit memo and an online credit memo look identical in Magento's own record once they are saved. The only reliable signal that money actually moved is a refund transaction recorded against the order's original capture transaction. So the safe thing a script can do is never try to guess from the credit memo alone. It has to independently check whether a matching refund transaction exists for that order, and flag the gap when a gateway backed payment method produced a credit memo with no such transaction.

The fix, as a flow

We do not touch any existing credit memo, and we do not try to trigger a new refund automatically. We add a job that lists recent credit memos, looks up the transactions on each order, and reports every credit memo on a gateway backed payment method where no refund transaction exists against the original capture.

Scheduled job runs on a timer List recent credit memos GET /creditmemos, by created_at Read order transactions method, capture, refund Refund txn missing? yes no, report ok Flag mismatch report row for review
The script only ever reports a mismatch. It never issues a new refund itself, since that has to go through the payment gateway with a human confirming the amount and destination.

Build it step by step

1

Get an admin bearer token

Authenticate the same way as any other Magento REST client. Either call POST /rest/V1/integration/admin/token with an admin username and password, or create an integration and use its token directly. Keep the store URL and token in environment variables, never in the file.

setup (shell)
pip install requests

export MAGENTO_URL="https://your-store.example.com"
export MAGENTO_ADMIN_TOKEN="eyJraWQ..."
export LOOKBACK_DAYS="7"
export GATEWAY_METHODS="stripe_payments,braintree,authorizenet_acceptjs,adyen_cc"
export DRY_RUN="true"   # report-only either way, this only affects log verbosity
setup (shell)
// Node 18+ has fetch built in, no dependencies needed

export MAGENTO_URL="https://your-store.example.com"
export MAGENTO_ADMIN_TOKEN="eyJraWQ..."
export LOOKBACK_DAYS="7"
export GATEWAY_METHODS="stripe_payments,braintree,authorizenet_acceptjs,adyen_cc"
export DRY_RUN="true"   // report-only either way, this only affects log verbosity
2

Talk to the Magento REST API

Every call sends Authorization: Bearer <token> to a /rest/V1 route. A small helper wraps the request and raises on a non 200 response, since Magento returns structured error bodies worth surfacing as is.

step2.py
import os, requests

MAGENTO_URL = os.environ["MAGENTO_URL"].rstrip("/")
TOKEN = os.environ["MAGENTO_ADMIN_TOKEN"]

def magento_get(path, params=None):
    r = requests.get(
        f"{MAGENTO_URL}/rest/V1{path}",
        params=params or {},
        headers={"Authorization": f"Bearer {TOKEN}"},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()
step2.js
const MAGENTO_URL = (process.env.MAGENTO_URL || "").replace(/\/$/, "");
const TOKEN = process.env.MAGENTO_ADMIN_TOKEN;

async function magentoGet(path, params = {}) {
  const url = new URL(`${MAGENTO_URL}/rest/V1${path}`);
  for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
  const res = await fetch(url, { headers: { Authorization: `Bearer ${TOKEN}` } });
  if (!res.ok) throw new Error(`Magento ${res.status}`);
  return res.json();
}
3

List recent credit memos and their order's transactions

Call GET /rest/V1/creditmemos with a searchCriteria filter on created_at using conditionType=gteq for your lookback window. For each credit memo's order_id, call GET /rest/V1/transactions filtered by order_id to read back every transaction, including its txn_type such as capture or refund and its parent_id. The decision function needs the credit memo's own payment method and the full transaction list for that order.

step3.py
def recent_creditmemos(since_iso, page_size=100, current_page=1):
    params = {
        "searchCriteria[filterGroups][0][filters][0][field]": "created_at",
        "searchCriteria[filterGroups][0][filters][0][conditionType]": "gteq",
        "searchCriteria[filterGroups][0][filters][0][value]": since_iso,
        "searchCriteria[pageSize]": page_size,
        "searchCriteria[currentPage]": current_page,
    }
    return magento_get("/creditmemos", params)["items"]


def order_transactions(order_id):
    params = {
        "searchCriteria[filterGroups][0][filters][0][field]": "order_id",
        "searchCriteria[filterGroups][0][filters][0][value]": order_id,
        "searchCriteria[pageSize]": 50,
    }
    return magento_get("/transactions", params)["items"]
step3.js
async function recentCreditmemos(sinceIso, pageSize = 100, currentPage = 1) {
  const params = {
    "searchCriteria[filterGroups][0][filters][0][field]": "created_at",
    "searchCriteria[filterGroups][0][filters][0][conditionType]": "gteq",
    "searchCriteria[filterGroups][0][filters][0][value]": sinceIso,
    "searchCriteria[pageSize]": pageSize,
    "searchCriteria[currentPage]": currentPage,
  };
  const data = await magentoGet("/creditmemos", params);
  return data.items;
}

async function orderTransactions(orderId) {
  const params = {
    "searchCriteria[filterGroups][0][filters][0][field]": "order_id",
    "searchCriteria[filterGroups][0][filters][0][value]": orderId,
    "searchCriteria[pageSize]": 50,
  };
  const data = await magentoGet("/transactions", params);
  return data.items;
}
4

Decide, with one pure function

Keep the decision in its own function that takes the credit memo's payment method, whether that method is one of your known gateway backed methods, and the order's transaction list, and returns whether the refund silently fell back to offline. A pure function like this is easy to read and easy to test, which we do later. It is a fallback only when the payment method is gateway backed and no transaction of type refund exists on that order. An offline method such as checkmo or banktransfer is never flagged, since offline is the correct and only path there.

decide.py
def evaluate_refund_fallback(creditmemo, transactions, gateway_methods):
    method = creditmemo.get("paymentMethod")
    if method not in gateway_methods:
        return {"isGatewayMethod": False, "hasRefundTxn": None, "fellBackOffline": False}

    has_refund_txn = any(t.get("txnType") == "refund" for t in transactions)
    return {
        "isGatewayMethod": True,
        "hasRefundTxn": has_refund_txn,
        "fellBackOffline": not has_refund_txn,
    }
decide.js
export function evaluateRefundFallback(creditmemo, transactions, gatewayMethods) {
  const method = creditmemo.paymentMethod;
  if (!gatewayMethods.includes(method)) {
    return { isGatewayMethod: false, hasRefundTxn: null, fellBackOffline: false };
  }

  const hasRefundTxn = transactions.some((t) => t.txnType === "refund");
  return {
    isGatewayMethod: true,
    hasRefundTxn,
    fellBackOffline: !hasRefundTxn,
  };
}
5

Report by default, never fake a repair

The output is a structured report row per flagged credit memo: entity_id, increment_id, order_id, the payment method, and the grand total the customer is still owed, for a merchant or developer to review and issue the real refund by hand through the gateway's own dashboard. There is no code path in this script that calls a payment gateway or creates a new credit memo, because converting an offline record into a real gateway refund after the fact is not something any Magento endpoint supports.

6

Wire it together with a dry run guard

The loop ties every piece together. DRY_RUN only changes log verbosity here, since this script never writes: it reports every fallback found either way. The actual fix, refunding the customer for real, has to happen in the payment gateway's own dashboard or through its API, confirmed by a human against the amount already recorded in the Magento credit memo, never as an automatic follow-up to this detector.

Run it safe

This script never calls a payment gateway and never creates a credit memo. Every fallback it reports is a lead for a human to check the gateway's own transaction history and, if the money truly never moved, issue the refund there directly, then note it against the existing Magento record.

The full code

Here is the complete script in one file for each language. It reads settings from the environment, pages through recent credit memos, checks each order's transactions with the pure function, and prints a structured report. It never touches a gateway or a credit memo, so it is safe to run again and again.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Dry run by default. One of 59 Magento fixes, free and open source.
flag_offline_refund_fallback.py
"""Flag Magento 2 credit memos where an online refund silently fell back to
offline.

The admin credit memo form only offers an online refund when the payment
method's gateway adapter reports canRefund or canRefundPartialPerInvoice as
true for that invoice's capture transaction. If the capture cannot be found,
or the gateway call fails, Magento quietly narrows the form to offline only,
with no visible error. If a human submits that form, Magento creates a
normal looking credit memo and marks the order refunded, but the payment
gateway was never called and the customer's money never moved. There is no
supported endpoint that converts an existing offline credit memo into a real
gateway refund, so this only reports the mismatch. Run on a schedule. Safe
to run again and again.
"""
import os
import logging
import datetime
import requests

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("flag_offline_refund_fallback")

MAGENTO_URL = os.environ["MAGENTO_URL"].rstrip("/")
TOKEN = os.environ["MAGENTO_ADMIN_TOKEN"]
LOOKBACK_DAYS = float(os.environ.get("LOOKBACK_DAYS", "7"))
GATEWAY_METHODS = [
    m.strip()
    for m in os.environ.get(
        "GATEWAY_METHODS", "stripe_payments,braintree,authorizenet_acceptjs,adyen_cc"
    ).split(",")
    if m.strip()
]
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"


def magento_get(path, params=None):
    r = requests.get(
        f"{MAGENTO_URL}/rest/V1{path}",
        params=params or {},
        headers={"Authorization": f"Bearer {TOKEN}"},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()


def since_iso(lookback_days):
    since = datetime.datetime.utcnow() - datetime.timedelta(days=lookback_days)
    return since.strftime("%Y-%m-%d %H:%M:%S")


def recent_creditmemos(since, page_size=100, current_page=1):
    params = {
        "searchCriteria[filterGroups][0][filters][0][field]": "created_at",
        "searchCriteria[filterGroups][0][filters][0][conditionType]": "gteq",
        "searchCriteria[filterGroups][0][filters][0][value]": since,
        "searchCriteria[pageSize]": page_size,
        "searchCriteria[currentPage]": current_page,
    }
    return magento_get("/creditmemos", params)["items"]


def order_transactions(order_id):
    params = {
        "searchCriteria[filterGroups][0][filters][0][field]": "order_id",
        "searchCriteria[filterGroups][0][filters][0][value]": order_id,
        "searchCriteria[pageSize]": 50,
    }
    return magento_get("/transactions", params)["items"]


def normalize_creditmemo(raw):
    return {
        "entityId": raw.get("entity_id"),
        "incrementId": raw.get("increment_id"),
        "orderId": raw.get("order_id"),
        "paymentMethod": (raw.get("extension_attributes") or {}).get("payment_method")
        or raw.get("payment_method"),
        "grandTotal": float(raw.get("grand_total") or 0),
    }


def normalize_transaction(raw):
    return {"txnType": raw.get("txn_type"), "parentId": raw.get("parent_id")}


def evaluate_refund_fallback(creditmemo, transactions, gateway_methods):
    method = creditmemo.get("paymentMethod")
    if method not in gateway_methods:
        return {"isGatewayMethod": False, "hasRefundTxn": None, "fellBackOffline": False}

    has_refund_txn = any(t.get("txnType") == "refund" for t in transactions)
    return {
        "isGatewayMethod": True,
        "hasRefundTxn": has_refund_txn,
        "fellBackOffline": not has_refund_txn,
    }


def run():
    since = since_iso(LOOKBACK_DAYS)
    flagged = []
    page = 1
    while True:
        raw_items = recent_creditmemos(since, current_page=page)
        if not raw_items:
            break
        for raw in raw_items:
            creditmemo = normalize_creditmemo(raw)
            if not creditmemo["orderId"]:
                continue
            raw_txns = order_transactions(creditmemo["orderId"])
            transactions = [normalize_transaction(t) for t in raw_txns]
            result = evaluate_refund_fallback(creditmemo, transactions, GATEWAY_METHODS)
            if result["fellBackOffline"]:
                flagged.append({**creditmemo, **result})
        if len(raw_items) < 100:
            break
        page += 1

    for row in flagged:
        log.warning(
            "Creditmemo %s (order %s, method %s) has no refund transaction. Customer may still be owed %.2f.",
            row["incrementId"], row["orderId"], row["paymentMethod"], row["grandTotal"],
        )

    if flagged:
        log.error("%d credit memo(s) look like a silent offline fallback. This script never issues a refund itself.", len(flagged))
    else:
        log.info("Done. No offline refund fallback found.")


if __name__ == "__main__":
    run()
flag-offline-refund-fallback.js
/**
 * Flag Magento 2 credit memos where an online refund silently fell back to
 * offline.
 *
 * The admin credit memo form only offers an online refund when the payment
 * method's gateway adapter reports canRefund or canRefundPartialPerInvoice
 * as true for that invoice's capture transaction. If the capture cannot be
 * found, or the gateway call fails, Magento quietly narrows the form to
 * offline only, with no visible error. If a human submits that form,
 * Magento creates a normal looking credit memo and marks the order
 * refunded, but the payment gateway was never called and the customer's
 * money never moved. There is no supported endpoint that converts an
 * existing offline credit memo into a real gateway refund, so this only
 * reports the mismatch. Run on a schedule. Safe to run again and again.
 *
 * Guide: https://www.allanninal.dev/magento/online-refund-falls-back-offline/
 */
import { pathToFileURL } from "node:url";

const MAGENTO_URL = (process.env.MAGENTO_URL || "https://demo.example.com").replace(/\/$/, "");
const TOKEN = process.env.MAGENTO_ADMIN_TOKEN || "token_dummy";
const LOOKBACK_DAYS = Number(process.env.LOOKBACK_DAYS || 7);
const GATEWAY_METHODS = (
  process.env.GATEWAY_METHODS || "stripe_payments,braintree,authorizenet_acceptjs,adyen_cc"
)
  .split(",")
  .map((m) => m.trim())
  .filter(Boolean);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

export function evaluateRefundFallback(creditmemo, transactions, gatewayMethods) {
  const method = creditmemo.paymentMethod;
  if (!gatewayMethods.includes(method)) {
    return { isGatewayMethod: false, hasRefundTxn: null, fellBackOffline: false };
  }

  const hasRefundTxn = transactions.some((t) => t.txnType === "refund");
  return {
    isGatewayMethod: true,
    hasRefundTxn,
    fellBackOffline: !hasRefundTxn,
  };
}

async function magentoGet(path, params = {}) {
  const url = new URL(`${MAGENTO_URL}/rest/V1${path}`);
  for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
  const res = await fetch(url, { headers: { Authorization: `Bearer ${TOKEN}` } });
  if (!res.ok) throw new Error(`Magento ${res.status}`);
  return res.json();
}

function sinceIso(lookbackDays) {
  const since = new Date(Date.now() - lookbackDays * 86400 * 1000);
  return since.toISOString().slice(0, 19).replace("T", " ");
}

async function recentCreditmemos(since, pageSize = 100, currentPage = 1) {
  const params = {
    "searchCriteria[filterGroups][0][filters][0][field]": "created_at",
    "searchCriteria[filterGroups][0][filters][0][conditionType]": "gteq",
    "searchCriteria[filterGroups][0][filters][0][value]": since,
    "searchCriteria[pageSize]": pageSize,
    "searchCriteria[currentPage]": currentPage,
  };
  const data = await magentoGet("/creditmemos", params);
  return data.items;
}

async function orderTransactions(orderId) {
  const params = {
    "searchCriteria[filterGroups][0][filters][0][field]": "order_id",
    "searchCriteria[filterGroups][0][filters][0][value]": orderId,
    "searchCriteria[pageSize]": 50,
  };
  const data = await magentoGet("/transactions", params);
  return data.items;
}

function normalizeCreditmemo(raw) {
  return {
    entityId: raw.entity_id,
    incrementId: raw.increment_id,
    orderId: raw.order_id,
    paymentMethod: raw.extension_attributes?.payment_method || raw.payment_method,
    grandTotal: Number(raw.grand_total || 0),
  };
}

function normalizeTransaction(raw) {
  return { txnType: raw.txn_type, parentId: raw.parent_id };
}

export async function run() {
  const since = sinceIso(LOOKBACK_DAYS);
  const flagged = [];
  let page = 1;

  while (true) {
    const rawItems = await recentCreditmemos(since, 100, page);
    if (!rawItems.length) break;

    for (const raw of rawItems) {
      const creditmemo = normalizeCreditmemo(raw);
      if (!creditmemo.orderId) continue;
      const rawTxns = await orderTransactions(creditmemo.orderId);
      const transactions = rawTxns.map(normalizeTransaction);
      const result = evaluateRefundFallback(creditmemo, transactions, GATEWAY_METHODS);
      if (result.fellBackOffline) flagged.push({ ...creditmemo, ...result });
    }

    if (rawItems.length < 100) break;
    page++;
  }

  for (const row of flagged) {
    console.warn(
      `Creditmemo ${row.incrementId} (order ${row.orderId}, method ${row.paymentMethod}) has no refund transaction. ` +
      `Customer may still be owed ${row.grandTotal.toFixed(2)}.`
    );
  }

  if (flagged.length) {
    console.error(`${flagged.length} credit memo(s) look like a silent offline fallback. This script never issues a refund itself.`);
  } else {
    console.log("Done. No offline refund fallback found.");
  }
}

if (import.meta.url === pathToFileURL(process.argv[1]).href) {
  run().catch((err) => { console.error(err); process.exit(1); });
}

Add a test

The fallback rule is the part most worth testing, because it decides whether a credit memo gets flagged as a customer who may still be owed money. Because we kept evaluate_refund_fallback pure, the test needs no network, no Magento store, and no admin token. It just feeds in plain fixture data and checks the answer.

test_offline_fallback.py
from flag_offline_refund_fallback import evaluate_refund_fallback

GATEWAY_METHODS = ["stripe_payments", "braintree", "authorizenet_acceptjs", "adyen_cc"]


def creditmemo(**over):
    base = {
        "entityId": 501,
        "incrementId": "300000501",
        "orderId": 900,
        "paymentMethod": "stripe_payments",
        "grandTotal": 49.99,
    }
    base.update(over)
    return base


def txn(txn_type, parent_id=None):
    return {"txnType": txn_type, "parentId": parent_id}


def test_flags_gateway_method_with_no_refund_transaction():
    transactions = [txn("order"), txn("capture")]
    result = evaluate_refund_fallback(creditmemo(), transactions, GATEWAY_METHODS)
    assert result["isGatewayMethod"] is True
    assert result["hasRefundTxn"] is False
    assert result["fellBackOffline"] is True


def test_not_flagged_when_refund_transaction_exists():
    transactions = [txn("order"), txn("capture"), txn("refund")]
    result = evaluate_refund_fallback(creditmemo(), transactions, GATEWAY_METHODS)
    assert result["fellBackOffline"] is False


def test_not_flagged_for_offline_payment_method():
    cm = creditmemo(paymentMethod="checkmo")
    result = evaluate_refund_fallback(cm, [], GATEWAY_METHODS)
    assert result["isGatewayMethod"] is False
    assert result["fellBackOffline"] is False


def test_not_flagged_for_unlisted_custom_method():
    cm = creditmemo(paymentMethod="some_custom_offline_method")
    result = evaluate_refund_fallback(cm, [], GATEWAY_METHODS)
    assert result["fellBackOffline"] is False


def test_flags_when_transactions_list_is_empty():
    result = evaluate_refund_fallback(creditmemo(), [], GATEWAY_METHODS)
    assert result["fellBackOffline"] is True


def test_not_flagged_when_only_authorize_and_refund_exist():
    transactions = [txn("authorization"), txn("refund")]
    result = evaluate_refund_fallback(creditmemo(), transactions, GATEWAY_METHODS)
    assert result["fellBackOffline"] is False
offline-fallback.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { evaluateRefundFallback } from "./flag-offline-refund-fallback.js";

const GATEWAY_METHODS = ["stripe_payments", "braintree", "authorizenet_acceptjs", "adyen_cc"];

const creditmemo = (over = {}) => ({
  entityId: 501,
  incrementId: "300000501",
  orderId: 900,
  paymentMethod: "stripe_payments",
  grandTotal: 49.99,
  ...over,
});

const txn = (txnType, parentId = null) => ({ txnType, parentId });

test("flags gateway method with no refund transaction", () => {
  const transactions = [txn("order"), txn("capture")];
  const result = evaluateRefundFallback(creditmemo(), transactions, GATEWAY_METHODS);
  assert.equal(result.isGatewayMethod, true);
  assert.equal(result.hasRefundTxn, false);
  assert.equal(result.fellBackOffline, true);
});

test("not flagged when refund transaction exists", () => {
  const transactions = [txn("order"), txn("capture"), txn("refund")];
  const result = evaluateRefundFallback(creditmemo(), transactions, GATEWAY_METHODS);
  assert.equal(result.fellBackOffline, false);
});

test("not flagged for offline payment method", () => {
  const cm = creditmemo({ paymentMethod: "checkmo" });
  const result = evaluateRefundFallback(cm, [], GATEWAY_METHODS);
  assert.equal(result.isGatewayMethod, false);
  assert.equal(result.fellBackOffline, false);
});

test("not flagged for unlisted custom method", () => {
  const cm = creditmemo({ paymentMethod: "some_custom_offline_method" });
  const result = evaluateRefundFallback(cm, [], GATEWAY_METHODS);
  assert.equal(result.fellBackOffline, false);
});

test("flags when transactions list is empty", () => {
  const result = evaluateRefundFallback(creditmemo(), [], GATEWAY_METHODS);
  assert.equal(result.fellBackOffline, true);
});

test("not flagged when only authorize and refund exist", () => {
  const transactions = [txn("authorization"), txn("refund")];
  const result = evaluateRefundFallback(creditmemo(), transactions, GATEWAY_METHODS);
  assert.equal(result.fellBackOffline, false);
});

Case studies

Partial capture

The order that shipped in two boxes

A hardware store split a large order into two shipments and two partial invoices, each captured separately through Braintree. When one item came back damaged, the agent opened the credit memo screen expecting the usual online refund button. Because the specific invoice's capture had already been partially refunded once before, the gateway adapter's canRefundPartialPerInvoice check came back false, and the form only showed offline. The agent, in a hurry, submitted it anyway.

The detection script caught it the same night, since the order's transaction list had a capture but no matching refund. The team confirmed with the customer, refunded the correct amount directly through the Braintree dashboard, and added a note to the existing Magento credit memo referencing the gateway transaction id.

Expired authorization

The Adyen order refunded three weeks late

A furniture retailer using Adyen let a return sit in a queue for three weeks before anyone processed the credit memo. By then, the original transaction had aged past what the gateway integration expected for an online refund lookup, so the form quietly fell back to offline once again with no error banner.

Running the script against a month of credit memos surfaced a cluster of these late refunds all on the same payment method, which pointed the team at the real pattern: refunds processed more than two weeks after the sale kept losing their online path. They now flag the queue itself so returns get processed inside that window, and keep the detection script running to catch anything that still slips through.

What good looks like

After this runs on a schedule, a refund that silently fell back to offline is caught within one detection cycle instead of surfacing weeks later as a customer complaint or a chargeback. The report carries the credit memo's increment id, its order, the payment method, and the amount still outstanding, so whoever responds can go straight to the gateway's dashboard and issue the real refund. Keep the actual gateway call with a human who can confirm the amount and the customer, since that is what keeps the script from ever moving money on a guess.

FAQ

Why did my Magento 2 credit memo post as offline instead of refunding through the gateway?

The admin credit memo form only offers an online refund button when the payment method's gateway adapter reports that it can refund, and when the original invoice's transaction is still capturable in Magento's records. If the gateway call fails, times out, or the payment method does not implement online refunds for that transaction type, Magento's UI quietly shows only the offline option. If someone submits that offline form believing it means the same thing, Magento creates a normal credit memo and marks the order refunded, but no request was ever sent to the payment gateway and the customer's money never moved.

Does the Magento REST API tell me whether a credit memo used an online or offline refund?

Yes, indirectly. A creditmemo resource does not have a single is_online flag, but GET /V1/transactions/{id} for the parent order's capture transaction, together with the creditmemo's own online_refund flag in its extension attributes on many payment integrations, shows whether a matching refund transaction exists. When a credit memo has no corresponding refund transaction against the original capture, and the order's payment method is one that supports online refunds, that is the signature of a refund that fell back to offline.

Can I fix a credit memo that was posted offline by mistake through the REST API?

Not directly. There is no supported endpoint that converts an existing offline credit memo into an online gateway refund after the fact, since the credit memo is already a closed financial record and the money still has not moved through the gateway. The safe pattern is to detect the mismatch, then manually issue the real refund through the payment gateway's own dashboard or a supported API call, and reconcile it against the existing credit memo rather than creating a duplicate one in Magento.

Related field notes

Citations

On the problem:

  1. a. example.com

On the solution:

  1. a. example.com

Stuck on a tricky one?

If you have a problem in Magento 2 or Adobe Commerce orders, payments, catalog data, or inventory 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 refund that never moved?

If this saved you an angry customer email 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 Magento field notes