Repair Payments / Refunds
Refund rejected on orders paid with split payment methods
A customer paid part gift card, part credit card, or store credit plus PayPal. Now a refund on that order comes back with "the requested refund had invalid split payment," and the money never moves. BigCommerce settled that order as separate transactions against separate providers, each capped at what it actually captured, and the refund endpoint will not guess how to split a lump sum across them. Here is why the rejection happens and a small script that always asks for the exact split first.
An order paid with more than one tender settles as multiple discrete transactions, each against a different payment provider, each capped at what that provider actually captured. The V3 refund endpoint, POST /v3/orders/{order_id}/payment_actions/refunds, requires your refund request's payments[].provider_id and payments[].amount to exactly match an entry in the refund_methods array from a prior refund quote, POST /v3/orders/{order_id}/payment_actions/refund_quotes. It will not automatically split a lump sum across tenders. Always call POST /v3/orders/{order_id}/payment_actions/refund_quotes first, copy each provider's provider_id and refundable amount out of the response, and only then post to POST /v3/orders/{order_id}/payment_actions/refunds. Full code, tests, and a dry run guard are below.
The problem in plain words
When a shopper pays with a single card, the order has one transaction against one provider, and refunding it is a one-line request. Split tender orders are different. Part gift card and part credit card, or store credit plus PayPal, means BigCommerce settled the checkout as two or more separate captures, each against its own payment provider, and each capped at whatever that specific provider actually captured.
The refund side of the API mirrors that reality on purpose. POST /v3/orders/{order_id}/payment_actions/refunds does not accept "refund $80 from this order." It accepts a payments array, a list of exact {provider_id, amount} pairs, and every pair has to match an entry the gateway already approved through a prior refund quote. If a script skips the quote step, sends the wrong or stale provider_id, asks a provider to refund more than it captured, or tries to push the whole order total through a single provider on a multi-tender order, the gateway rejects the request outright with "the requested refund had invalid split payment."
Why it happens
A handful of common mistakes all produce the exact same rejection:
- Refunding against a
provider_idthat is stale or simply wrong for that order, so the gateway has no matching capture to reverse. - Asking one provider to refund more than it actually captured, for example the full order total when that provider only took a partial tender.
- Sending a single-provider refund payload against an order that settled across multiple tenders, instead of one
{provider_id, amount}entry per provider. - Skipping
POST /v3/orders/{order_id}/payment_actions/refund_quotesentirely and hand-building thepaymentsarray from the order total instead of from the gateway's own quotedrefund_methods.
This is a recurring question on BigCommerce's own support community, where merchants hit "the requested refund had invalid split payment" or a related "payment provider encountered an error" response while trying to refund a multi-tender order through the API. See the citations at the end for the exact threads and docs.
Never guess the split. The refund quote is the only source of truth for what the gateway will currently accept. POST /v3/orders/{order_id}/payment_actions/refund_quotes returns a refund_methods array, each entry carrying a provider_id and the maximum that provider can still refund. Build the payments array for POST /v3/orders/{order_id}/payment_actions/refunds by copying provider_id and amount straight out of that array, never by dividing the order total yourself. On a single-tender order that array has one entry. On a split-tender order it has one entry per provider, and each entry's amount is capped at what that provider actually captured.
The fix, as a flow
We do not touch checkout or the payment gateways themselves. We add a small guard in front of every refund: request the quote, build the split from the quote's own numbers, and only then post the refund, one call per order since BigCommerce does not support concurrent refunds on the same order.
Build it step by step
Get a store hash and an API access token
Create an API account in your BigCommerce control panel under Settings, API, or use the store's existing app credentials. Grant it Orders (modify) scope so it can read transactions, request refund quotes, and post refunds. You need the store hash from your control panel URL and the access token, sent on every call as the X-Auth-Token header. Keep both in environment variables, never in the file.
pip install requests
export BIGCOMMERCE_STORE_HASH="abc123"
export BIGCOMMERCE_ACCESS_TOKEN="..."
export DRY_RUN="true" # start safe, change to false to write
// Node 18+ has fetch built in, no dependencies needed
export BIGCOMMERCE_STORE_HASH="abc123"
export BIGCOMMERCE_ACCESS_TOKEN="..."
export DRY_RUN="true" // start safe, change to false to write
Talk to the V3 Order Refunds API
Every call goes to https://api.bigcommerce.com/stores/{store_hash}/v3/ with the token in the X-Auth-Token header. A small helper handles GET and POST and raises on a non-2xx response. We reuse it to request the refund quote and, later, to post the refund itself.
import os, requests
STORE_HASH = os.environ["BIGCOMMERCE_STORE_HASH"]
ACCESS_TOKEN = os.environ["BIGCOMMERCE_ACCESS_TOKEN"]
API_BASE = f"https://api.bigcommerce.com/stores/{STORE_HASH}/v3"
HEADERS = {
"X-Auth-Token": ACCESS_TOKEN,
"Content-Type": "application/json",
"Accept": "application/json",
}
def bc_get(path, params=None):
r = requests.get(f"{API_BASE}{path}", headers=HEADERS, params=params or {}, timeout=30)
r.raise_for_status()
return r.json() if r.text else {}
def bc_post(path, body):
r = requests.post(f"{API_BASE}{path}", headers=HEADERS, json=body, timeout=30)
r.raise_for_status()
return r.json() if r.text else {}
const STORE_HASH = process.env.BIGCOMMERCE_STORE_HASH;
const ACCESS_TOKEN = process.env.BIGCOMMERCE_ACCESS_TOKEN;
const API_BASE = `https://api.bigcommerce.com/stores/${STORE_HASH}/v3`;
const HEADERS = {
"X-Auth-Token": ACCESS_TOKEN,
"Content-Type": "application/json",
Accept: "application/json",
};
async function bcGet(path, params = {}) {
const url = new URL(`${API_BASE}${path}`);
for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value);
const res = await fetch(url, { headers: HEADERS });
if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
const text = await res.text();
return text ? JSON.parse(text) : {};
}
async function bcPost(path, body) {
const res = await fetch(`${API_BASE}${path}`, { method: "POST", headers: HEADERS, body: JSON.stringify(body) });
if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
const text = await res.text();
return text ? JSON.parse(text) : {};
}
Request the refund quote before anything else
Call POST /v3/orders/{order_id}/payment_actions/refund_quotes with the items or amount you intend to refund. The response's refund_methods array is the only place the exact provider_id and refundable amount for each tender on this order live. Never build a refund payload from the order total alone.
def request_refund_quote(order_id, payload):
return bc_post(f"/orders/{order_id}/payment_actions/refund_quotes", payload)
# payload example:
# {"reason": "Customer request", "items": [{"item_id": 123, "quantity": 1}]}
# response contains: {"data": {"refund_methods": [{"provider_id": "...", "amount": "..."}, ...]}}
async function requestRefundQuote(orderId, payload) {
return bcPost(`/orders/${orderId}/payment_actions/refund_quotes`, payload);
}
// payload example:
// { reason: "Customer request", items: [{ item_id: 123, quantity: 1 }] }
// response contains: { data: { refund_methods: [{ provider_id: "...", amount: "..." }, ...] } }
Build the split with one pure function
Keep the decision in its own function that takes the refund quote's refund_methods and the requested total, and returns the exact payments array to send. It never exceeds any single method's quoted maximum, it orders entries by provider_id for determinism, and it raises a ValueError the moment the requested total cannot be covered, whether that is an over-refund attempt or a zero or negative amount.
from decimal import Decimal
def build_split_refund_payload(refund_quote: dict, requested_total: str) -> list:
methods = sorted(
refund_quote.get("refund_methods") or [],
key=lambda m: m["provider_id"],
)
if not methods:
raise ValueError("refund_quote has no refund_methods to split across")
total = Decimal(requested_total)
if total <= 0:
raise ValueError("requested_total must be greater than zero")
available_total = sum(Decimal(m["amount"]) for m in methods)
if total > available_total:
raise ValueError(
f"requested_total {total} exceeds available refund amount {available_total}"
)
remaining = total
payload = []
for method in methods:
if remaining <= 0:
break
max_amount = Decimal(method["amount"])
take = min(max_amount, remaining)
payload.append({"provider_id": method["provider_id"], "amount": str(take)})
remaining -= take
return payload
function buildSplitRefundPayload(refundQuote, requestedTotal) {
const methods = [...(refundQuote.refund_methods || [])].sort((a, b) =>
a.provider_id < b.provider_id ? -1 : a.provider_id > b.provider_id ? 1 : 0
);
if (methods.length === 0) {
throw new Error("refund_quote has no refund_methods to split across");
}
const total = Number(requestedTotal);
if (!Number.isFinite(total) || total <= 0) {
throw new Error("requested_total must be greater than zero");
}
const availableTotal = methods.reduce((sum, m) => sum + Number(m.amount), 0);
if (total > availableTotal + 1e-9) {
throw new Error(`requested_total ${total} exceeds available refund amount ${availableTotal}`);
}
let remaining = total;
const payload = [];
for (const method of methods) {
if (remaining <= 0) break;
const maxAmount = Number(method.amount);
const take = Math.min(maxAmount, remaining);
payload.push({ provider_id: method.provider_id, amount: take.toFixed(2) });
remaining = Math.round((remaining - take) * 100) / 100;
}
return payload;
}
Post the refund with the quote's own numbers
Call POST /v3/orders/{order_id}/payment_actions/refunds with the payments array you just built, one call per order, because BigCommerce does not support concurrent refunds on the same order. Every provider_id and amount pair in the request came straight out of the quote, so the gateway has no room to call it an invalid split.
def post_refund(order_id, payments):
return bc_post(
f"/orders/{order_id}/payment_actions/refunds",
{"payments": payments},
)
async function postRefund(orderId, payments) {
return bcPost(`/orders/${orderId}/payment_actions/refunds`, { payments });
}
Wire it together with a dry run guard
The full run ties every piece together. Notice the dry run guard. On the first few runs, leave DRY_RUN on so the script only logs the computed per-provider split, order id, requested total, and each {provider_id, amount} pair, diffed against any prior failed attempt. Read the output, agree with it, then switch it off. Only when DRY_RUN=false and the split sums exactly to the intended total does it perform the live POST.
Always start with DRY_RUN=true, and never post a refund payload you did not just build from a fresh refund quote. One order, one refund call at a time. BigCommerce does not support concurrent refunds on the same order, so retry sequentially, not in parallel.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, always quotes the refund before posting one, respects the dry run flag, and refuses to guess a split it cannot fully justify from the gateway's own numbers.
View this code on GitHub Full runnable folder with tests in the bigcommerce-fixes repo.
"""Refund a BigCommerce order paid with more than one tender without tripping
"the requested refund had invalid split payment."
An order paid with more than one tender, part gift card and part credit card, or
store credit plus PayPal, settles as separate transactions against separate
payment providers, each capped at what that provider actually captured. The V3
refund endpoint, POST /v3/orders/{order_id}/payment_actions/refunds, requires the
payments[].provider_id and payments[].amount in the request to exactly match an
entry the gateway already approved in a prior refund quote from
POST /v3/orders/{order_id}/payment_actions/refund_quotes. It will not
automatically split a lump sum refund across tenders. This script always
requests the quote first, builds the payments array from the quote's own
refund_methods, and only then posts the refund. Safe to run again and again.
Guide: https://www.allanninal.dev/bigcommerce/refund-invalid-split-payment/
"""
import os
import logging
from decimal import Decimal
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("refund_split_payment")
STORE_HASH = os.environ["BIGCOMMERCE_STORE_HASH"]
ACCESS_TOKEN = os.environ["BIGCOMMERCE_ACCESS_TOKEN"]
API_BASE = f"https://api.bigcommerce.com/stores/{STORE_HASH}/v3"
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
HEADERS = {
"X-Auth-Token": ACCESS_TOKEN,
"Content-Type": "application/json",
"Accept": "application/json",
}
def bc_get(path, params=None):
r = requests.get(f"{API_BASE}{path}", headers=HEADERS, params=params or {}, timeout=30)
r.raise_for_status()
return r.json() if r.text else {}
def bc_post(path, body):
r = requests.post(f"{API_BASE}{path}", headers=HEADERS, json=body, timeout=30)
r.raise_for_status()
return r.json() if r.text else {}
def build_split_refund_payload(refund_quote: dict, requested_total: str) -> list:
"""Pure decision. No network, no side effects.
Takes refund_quote["refund_methods"] (a list of {"provider_id", "amount"}
entries as returned by POST .../refund_quotes) and the Decimal-string
requested_total. Returns a list of {"provider_id", "amount"} entries such
that no entry exceeds that method's quoted max, entries are ordered by
provider_id for determinism, and the entries' amounts sum exactly to
requested_total. Raises ValueError if requested_total exceeds the sum of
all refund_methods amounts (an over-refund attempt), or if requested_total
is zero/negative, or if refund_methods is empty. Multi vs single tender is
simply len(refund_methods) > 1.
"""
methods = sorted(
refund_quote.get("refund_methods") or [],
key=lambda m: m["provider_id"],
)
if not methods:
raise ValueError("refund_quote has no refund_methods to split across")
total = Decimal(requested_total)
if total <= 0:
raise ValueError("requested_total must be greater than zero")
available_total = sum(Decimal(m["amount"]) for m in methods)
if total > available_total:
raise ValueError(
f"requested_total {total} exceeds available refund amount {available_total}"
)
remaining = total
payload = []
for method in methods:
if remaining <= 0:
break
max_amount = Decimal(method["amount"])
take = min(max_amount, remaining)
payload.append({"provider_id": method["provider_id"], "amount": str(take)})
remaining -= take
return payload
def request_refund_quote(order_id, quote_payload):
return bc_post(f"/orders/{order_id}/payment_actions/refund_quotes", quote_payload)
def post_refund(order_id, payments):
return bc_post(f"/orders/{order_id}/payment_actions/refunds", {"payments": payments})
def refund_order(order_id, quote_payload, requested_total):
"""Quote, split, and (if not a dry run) post the refund for one order."""
quote_response = request_refund_quote(order_id, quote_payload)
refund_quote = quote_response.get("data", quote_response)
payments = build_split_refund_payload(refund_quote, requested_total)
log.info(
"order_id=%s requested_total=%s split=%s (%s)",
order_id, requested_total, payments, "dry run" if DRY_RUN else "posting",
)
if DRY_RUN:
return {"order_id": order_id, "payments": payments, "posted": False}
result = post_refund(order_id, payments)
return {"order_id": order_id, "payments": payments, "posted": True, "result": result}
def run(order_id, quote_payload, requested_total):
outcome = refund_order(order_id, quote_payload, requested_total)
log.info("Done. order_id=%s posted=%s", outcome["order_id"], outcome["posted"])
return outcome
if __name__ == "__main__":
example_order_id = os.environ.get("ORDER_ID", "0")
example_total = os.environ.get("REQUESTED_TOTAL", "0.00")
run(example_order_id, {"reason": "Customer request"}, example_total)
/**
* Refund a BigCommerce order paid with more than one tender without tripping
* "the requested refund had invalid split payment."
*
* An order paid with more than one tender, part gift card and part credit card,
* or store credit plus PayPal, settles as separate transactions against separate
* payment providers, each capped at what that provider actually captured. The V3
* refund endpoint, POST /v3/orders/{order_id}/payment_actions/refunds, requires
* the payments[].provider_id and payments[].amount in the request to exactly
* match an entry the gateway already approved in a prior refund quote from
* POST /v3/orders/{order_id}/payment_actions/refund_quotes. It will not
* automatically split a lump sum refund across tenders. This script always
* requests the quote first, builds the payments array from the quote's own
* refund_methods, and only then posts the refund. Safe to run again and again.
*
* Guide: https://www.allanninal.dev/bigcommerce/refund-invalid-split-payment/
*/
import { pathToFileURL } from "node:url";
const STORE_HASH = process.env.BIGCOMMERCE_STORE_HASH || "example_hash";
const ACCESS_TOKEN = process.env.BIGCOMMERCE_ACCESS_TOKEN || "bc_dummy";
const API_BASE = `https://api.bigcommerce.com/stores/${STORE_HASH}/v3`;
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const HEADERS = {
"X-Auth-Token": ACCESS_TOKEN,
"Content-Type": "application/json",
Accept: "application/json",
};
/**
* Pure decision. No network, no side effects.
*
* Takes refundQuote.refund_methods (a list of {provider_id, amount} entries as
* returned by POST .../refund_quotes) and the decimal-string requestedTotal.
* Returns a list of {provider_id, amount} entries such that no entry exceeds
* that method's quoted max, entries are ordered by provider_id for
* determinism, and the entries' amounts sum exactly to requestedTotal. Throws
* if requestedTotal exceeds the sum of all refund_methods amounts (an
* over-refund attempt), or if requestedTotal is zero/negative, or if
* refund_methods is empty. Multi vs single tender is simply
* refund_methods.length > 1.
*/
export function buildSplitRefundPayload(refundQuote, requestedTotal) {
const methods = [...(refundQuote.refund_methods || [])].sort((a, b) =>
a.provider_id < b.provider_id ? -1 : a.provider_id > b.provider_id ? 1 : 0
);
if (methods.length === 0) {
throw new Error("refund_quote has no refund_methods to split across");
}
const total = Number(requestedTotal);
if (!Number.isFinite(total) || total <= 0) {
throw new Error("requested_total must be greater than zero");
}
const availableTotal = methods.reduce((sum, m) => sum + Number(m.amount), 0);
if (total > availableTotal + 1e-9) {
throw new Error(`requested_total ${total} exceeds available refund amount ${availableTotal}`);
}
let remaining = total;
const payload = [];
for (const method of methods) {
if (remaining <= 0) break;
const maxAmount = Number(method.amount);
const take = Math.min(maxAmount, remaining);
payload.push({ provider_id: method.provider_id, amount: take.toFixed(2) });
remaining = Math.round((remaining - take) * 100) / 100;
}
return payload;
}
async function bcPost(path, body) {
const res = await fetch(`${API_BASE}${path}`, {
method: "POST",
headers: HEADERS,
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
const text = await res.text();
return text ? JSON.parse(text) : {};
}
async function requestRefundQuote(orderId, quotePayload) {
return bcPost(`/orders/${orderId}/payment_actions/refund_quotes`, quotePayload);
}
async function postRefund(orderId, payments) {
return bcPost(`/orders/${orderId}/payment_actions/refunds`, { payments });
}
export async function refundOrder(orderId, quotePayload, requestedTotal) {
const quoteResponse = await requestRefundQuote(orderId, quotePayload);
const refundQuote = quoteResponse.data || quoteResponse;
const payments = buildSplitRefundPayload(refundQuote, requestedTotal);
console.log(
`order_id=${orderId} requested_total=${requestedTotal} split=${JSON.stringify(payments)} ` +
`(${DRY_RUN ? "dry run" : "posting"})`
);
if (DRY_RUN) {
return { orderId, payments, posted: false };
}
const result = await postRefund(orderId, payments);
return { orderId, payments, posted: true, result };
}
export async function run() {
const orderId = process.env.ORDER_ID || "0";
const requestedTotal = process.env.REQUESTED_TOTAL || "0.00";
const outcome = await refundOrder(orderId, { reason: "Customer request" }, requestedTotal);
console.log(`Done. order_id=${outcome.orderId} posted=${outcome.posted}`);
return outcome;
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The split builder is the part most worth testing, because it decides exactly what money moves where. Because build_split_refund_payload takes only a plain refund quote dict and a plain string total, the test needs no network and no BigCommerce store. It just feeds in crafted quotes and checks the split, including the error paths.
import pytest
from refund_split_payment import build_split_refund_payload
def test_single_tender_refunds_full_amount_to_one_provider():
quote = {"refund_methods": [{"provider_id": "gw_a", "amount": "80.00"}]}
assert build_split_refund_payload(quote, "80.00") == [
{"provider_id": "gw_a", "amount": "80.00"}
]
def test_multi_tender_splits_across_providers_in_order():
quote = {
"refund_methods": [
{"provider_id": "gw_b", "amount": "60.00"},
{"provider_id": "gw_a", "amount": "20.00"},
]
}
assert build_split_refund_payload(quote, "80.00") == [
{"provider_id": "gw_a", "amount": "20.00"},
{"provider_id": "gw_b", "amount": "60.00"},
]
def test_partial_refund_never_exceeds_a_single_methods_max():
quote = {
"refund_methods": [
{"provider_id": "gw_a", "amount": "20.00"},
{"provider_id": "gw_b", "amount": "60.00"},
]
}
payload = build_split_refund_payload(quote, "30.00")
assert payload == [
{"provider_id": "gw_a", "amount": "20.00"},
{"provider_id": "gw_b", "amount": "10.00"},
]
def test_raises_on_over_refund_attempt():
quote = {"refund_methods": [{"provider_id": "gw_a", "amount": "20.00"}]}
with pytest.raises(ValueError):
build_split_refund_payload(quote, "20.01")
def test_raises_on_zero_or_negative_total():
quote = {"refund_methods": [{"provider_id": "gw_a", "amount": "20.00"}]}
with pytest.raises(ValueError):
build_split_refund_payload(quote, "0.00")
with pytest.raises(ValueError):
build_split_refund_payload(quote, "-5.00")
def test_raises_when_refund_methods_is_empty():
with pytest.raises(ValueError):
build_split_refund_payload({"refund_methods": []}, "10.00")
import { test } from "node:test";
import assert from "node:assert/strict";
import { buildSplitRefundPayload } from "./refund-split-payment.js";
test("single tender refunds full amount to one provider", () => {
const quote = { refund_methods: [{ provider_id: "gw_a", amount: "80.00" }] };
assert.deepEqual(buildSplitRefundPayload(quote, "80.00"), [
{ provider_id: "gw_a", amount: "80.00" },
]);
});
test("multi tender splits across providers in order", () => {
const quote = {
refund_methods: [
{ provider_id: "gw_b", amount: "60.00" },
{ provider_id: "gw_a", amount: "20.00" },
],
};
assert.deepEqual(buildSplitRefundPayload(quote, "80.00"), [
{ provider_id: "gw_a", amount: "20.00" },
{ provider_id: "gw_b", amount: "60.00" },
]);
});
test("partial refund never exceeds a single method's max", () => {
const quote = {
refund_methods: [
{ provider_id: "gw_a", amount: "20.00" },
{ provider_id: "gw_b", amount: "60.00" },
],
};
assert.deepEqual(buildSplitRefundPayload(quote, "30.00"), [
{ provider_id: "gw_a", amount: "20.00" },
{ provider_id: "gw_b", amount: "10.00" },
]);
});
test("throws on over refund attempt", () => {
const quote = { refund_methods: [{ provider_id: "gw_a", amount: "20.00" }] };
assert.throws(() => buildSplitRefundPayload(quote, "20.01"));
});
test("throws on zero or negative total", () => {
const quote = { refund_methods: [{ provider_id: "gw_a", amount: "20.00" }] };
assert.throws(() => buildSplitRefundPayload(quote, "0.00"));
assert.throws(() => buildSplitRefundPayload(quote, "-5.00"));
});
test("throws when refund_methods is empty", () => {
assert.throws(() => buildSplitRefundPayload({ refund_methods: [] }, "10.00"));
});
Case studies
The store that always refunded the credit card provider
A merchant's support team had a habit that worked fine for single-tender orders: cancel the order, refund the full amount to whatever provider_id showed up first in their notes, usually the credit card gateway. On gift card plus credit card orders that habit started throwing "invalid split payment" every time, because the credit card provider had only captured part of the total.
Switching to always requesting a refund quote first fixed it immediately. The quote's refund_methods array showed both providers and their real captured amounts, the split builder divided the refund correctly between them, and the same two-step process now works whether an order has one tender or three.
The integration that cached a stale provider_id
A custom returns app cached the provider_id it saw on the original order confirmation and reused it on every later refund attempt for that order. After a partial refund had already been processed through PayPal, a second refund attempt reused the same cached provider_id and amount, which no longer matched what PayPal could still refund, and the API rejected it.
Requesting a fresh refund quote immediately before each refund attempt, instead of caching anything from checkout time, resolved it. The quote always reflects what each provider can refund right now, not what it looked like when the order was first placed.
Every refund attempt starts with a fresh call to POST /v3/orders/{order_id}/payment_actions/refund_quotes, and the payments array sent to POST /v3/orders/{order_id}/payment_actions/refunds is built only from that response's own refund_methods, never from a guess about how the order total should divide. Multi-tender orders refund correctly across every provider that actually captured money, over-refund attempts raise before anything is posted, and one order never has two refund calls racing each other.
FAQ
Why does BigCommerce reject my refund with 'invalid split payment'?
The order was paid with more than one tender, for example part gift card and part credit card, so it settled as separate transactions against separate providers, each capped at what that provider actually captured. The refund endpoint requires the payments array in your refund request to match, provider_id and amount, an entry the gateway already approved in a prior refund quote. A lump-sum refund to one provider, a stale provider_id, or an amount over what that provider captured all trigger the rejection.
Can I just send the full refund amount to one payment provider?
No. On a multi-tender order each provider only captured part of the total, so any single provider_id can refund at most its own share. Sending the full order total to one provider, or to the wrong provider_id, is exactly the mismatch that produces the invalid split payment error. Always split the refund across the providers that actually captured the money.
Do I always need to call the refund quote endpoint first?
Yes. POST /v3/orders/{order_id}/payment_actions/refund_quotes is the only source of truth for which provider_id values and amounts the gateway will currently accept for that order. Build your refund payments array by copying provider_id and amount straight out of the quote's refund_methods array rather than guessing the split, then post it to POST /v3/orders/{order_id}/payment_actions/refunds.
Related field notes
Citations
On the problem:
- BigCommerce Support Community: error trying to refund order from API, "the requested refund had invalid split payment." support.bigcommerce.com invalid split payment
- BigCommerce Support Community: the payment provider encountered an error when processing refund. support.bigcommerce.com payment provider error processing refund
- BigCommerce Support Community: how to request a refund quote using the V3 Order Refunds API. bigcommerce.my.site.com refund quote v3
On the solution:
- BigCommerce API Reference: Create Refund Quote. docs.bigcommerce.com create refund quote
- BigCommerce API Reference: Create Refund. docs.bigcommerce.com create refund
- BigCommerce Developer Center: Order Refunds. developer.bigcommerce.com order refunds
Stuck on a tricky one?
If you have a problem in BigCommerce orders, payments, webhooks, 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 get your split-tender refunds moving?
If this saved you a support escalation or stopped a refund script from guessing wrong, 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