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.
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.
Why it happens
- Magento's credit memo controller asks the payment method's gateway adapter whether it
canRefundorcanRefundPartialPerInvoicefor the invoice being refunded, before it decides which refund options to render. Both capabilities are implemented per payment method, and some integrations answer conservatively or return false for edge cases like a second partial refund on the same invoice. - An online refund needs the original capture transaction still on record. A partial capture, a manually voided authorization, or a transaction the gateway expired on its own side can leave Magento unable to find a capture to refund against, so it drops to offline without surfacing why.
- Some third party payment modules implement offline refund support fully but leave
canRefundPartialPerInvoicereturning false by default, or throw a caught exception during the online refund attempt that the admin UI swallows into a fallback rather than a visible error. - Store staff turnover means the person processing the refund may not know that an offline-only form for this particular order is a signal something is wrong, and simply treats it as the normal way this store issues refunds.
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.
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.
Build it step by step
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.
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
// 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
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.
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()
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();
}
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.
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"]
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;
}
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.
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,
}
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,
};
}
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.
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.
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.
"""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 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.
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
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
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.
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.
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
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.
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