Repair Orders and payments

Declined order still holds stock

A gateway declined the charge, or a fraud tool pushed the order to Declined, and the order correctly shows no payment. But the inventory it debited when the order was created is often still gone. Nobody paid for it, nobody is shipping it, and it is sitting withheld from your sellable count anyway. Here is why BigCommerce leaves that stock behind and a small script that finds the affected orders and gives the stock back, safely.

Python and Node.js V2 Orders + V3 Inventory REST Safe by default (dry run)
The short answer

BigCommerce takes stock out of inventory_level at order creation or payment authorization time, and only puts it back automatically when the order lands on a status your Inventory Settings treat as a restock trigger, usually Cancelled or Refunded. Declined (status_id 6) frequently is not one of those statuses, or the order was pushed to Declined by an app or webhook that bypassed the checkout flow that normally fires the restock hook. Run a small Python or Node.js script that lists recently Declined orders, confirms with GET /v2/orders/{id}/transactions that nothing was actually approved or captured, and returns each line item's quantity with POST /v3/inventory/adjustments/relative. Full code, tests, and a dry run guard are below.

The problem in plain words

When a shopper places an order, BigCommerce debits the stock for every line item right away, at order creation or when the payment is authorized. That is by design: it stops two shoppers from buying the last unit of the same thing at the same moment.

The other half of that design is supposed to give the stock back the moment the order clearly is not going anywhere. That happens through a mapping in your store's Inventory Settings, which says which order statuses return stock, and Cancelled and Refunded are almost always on that list. Declined often is not, because a lot of merchants never think to add it, or the change to Declined came from a fraud app or a payment webhook calling the Orders API directly, a path that can skip the normal storefront hook that triggers the restock. The result is an order that correctly shows no money moved, sitting on stock that is still gone from what you can sell.

Order created inventory_level debited Payment declined or fraud tool flags it not mapped to restock status_id 6 Declined, no charge Stock still withheld
The order is correctly Declined and unpaid, but the stock it took at creation was never returned because Declined was not covered by the restock mapping.

Why it happens

BigCommerce's restock behavior runs off a status mapping, not off "was this order paid." A few common ways stores end up with stock stuck on Declined orders:

This is a well known gap. BigCommerce's own support content covers merchants finding items stuck out of stock after cancelling an order and having to manually zero it out and back, and the Inventory Tracking and Order Statuses docs explain the pieces separately without spelling out every status combination. See the citations at the end for the exact articles.

The key insight

Declined does not mean untouched. It means the order should not exist as far as revenue goes, but the stock debit that happened at creation time is a separate fact that BigCommerce does not always clean up for you. So the safe rule is not "restock every Declined order." It is "restock only the Declined orders that truly have no money behind them." We check that with GET /v2/orders/{id}/transactions before touching a single unit.

The fix, as a flow

We do not touch checkout or the fraud tool. We add a job that lists recently Declined orders, reads each order's line items and transactions, and only restocks the ones where nothing was ever approved or captured. Anything with real money behind it, or anything already handled, is left alone.

Scheduled job runs on a timer List Declined orders status_id=6, recent window Read products and transactions No money and not adjusted? yes no, flag or skip adjustments/relative quantity added back
The script only restocks the Declined orders that have zero approved or captured transactions and have not already been adjusted. Everything else is flagged for a human or skipped.

Build it step by step

1

Get a store hash and an API account token

In your BigCommerce control panel, go to Settings, API, Store-level API accounts, and create an account with the Orders and Inventory scopes. Note the store hash from your control panel URL and the access token from the API account. Keep both in environment variables, never in the file.

setup (shell)
pip install requests

export BIGCOMMERCE_STORE_HASH="abc123"
export BIGCOMMERCE_ACCESS_TOKEN="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
export RESTOCK_LOOKBACK_DAYS="3"
export LOCATION_ID="1"
export DRY_RUN="true"   # start safe, change to false to write
setup (shell)
// Node 18+ has fetch built in, no dependencies needed

export BIGCOMMERCE_STORE_HASH="abc123"
export BIGCOMMERCE_ACCESS_TOKEN="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
export RESTOCK_LOOKBACK_DAYS="3"
export LOCATION_ID="1"
export DRY_RUN="true"   // start safe, change to false to write
2

Talk to the BigCommerce REST APIs

Every call sends your token in the X-Auth-Token header against https://api.bigcommerce.com/stores/{store_hash}/. A small helper wraps GET and POST, raises on a non-2xx response, and unwraps the V3 {"data": ...} envelope so callers get plain objects back.

step2.py
import os, requests

STORE_HASH = os.environ["BIGCOMMERCE_STORE_HASH"]
TOKEN = os.environ["BIGCOMMERCE_ACCESS_TOKEN"]
BASE = f"https://api.bigcommerce.com/stores/{STORE_HASH}/"

def bc(method, path, **kwargs):
    r = requests.request(
        method, BASE + path.lstrip("/"),
        headers={"X-Auth-Token": TOKEN, "Content-Type": "application/json", "Accept": "application/json"},
        timeout=30, **kwargs,
    )
    r.raise_for_status()
    if not r.content:
        return None
    body = r.json()
    return body["data"] if isinstance(body, dict) and "data" in body else body
step2.js
const STORE_HASH = process.env.BIGCOMMERCE_STORE_HASH;
const TOKEN = process.env.BIGCOMMERCE_ACCESS_TOKEN;
const BASE = `https://api.bigcommerce.com/stores/${STORE_HASH}/`;

async function bc(method, path, body) {
  const res = await fetch(BASE + path.replace(/^\//, ""), {
    method,
    headers: { "X-Auth-Token": TOKEN, "Content-Type": "application/json", "Accept": "application/json" },
    body: body ? JSON.stringify(body) : undefined,
  });
  if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
  const text = await res.text();
  if (!text) return null;
  const json = JSON.parse(text);
  return json && typeof json === "object" && "data" in json ? json.data : json;
}
3

List recently Declined orders, then read line items and transactions

Call GET /v2/orders?status_id=6&min_date_modified={window} to page through Declined orders modified in your lookback window. For each one, call GET /v2/orders/{id}/products for the line items (sku, quantity, variant_id) and GET /v2/orders/{id}/transactions to see whether any transaction actually went through.

step3.py
from datetime import datetime, timedelta, timezone

DECLINED_STATUS_ID = 6

def declined_orders(lookback_days):
    since = (datetime.now(timezone.utc) - timedelta(days=lookback_days)).strftime("%Y-%m-%dT%H:%M:%S")
    page = 1
    while True:
        batch = bc("GET", f"/v2/orders?status_id={DECLINED_STATUS_ID}&min_date_modified={since}&page={page}&limit=50")
        if not batch:
            return
        for order in batch:
            yield order
        page += 1

def order_products(order_id):
    return bc("GET", f"/v2/orders/{order_id}/products") or []

def order_transactions(order_id):
    return bc("GET", f"/v2/orders/{order_id}/transactions") or []
step3.js
const DECLINED_STATUS_ID = 6;

async function* declinedOrders(lookbackDays) {
  const since = new Date(Date.now() - lookbackDays * 86400000).toISOString().slice(0, 19);
  let page = 1;
  while (true) {
    const batch = await bc("GET", `/v2/orders?status_id=${DECLINED_STATUS_ID}&min_date_modified=${since}&page=${page}&limit=50`);
    if (!batch || !batch.length) return;
    for (const order of batch) yield order;
    page += 1;
  }
}

async function orderProducts(orderId) {
  return (await bc("GET", `/v2/orders/${orderId}/products`)) || [];
}

async function orderTransactions(orderId) {
  return (await bc("GET", `/v2/orders/${orderId}/transactions`)) || [];
}
4

Decide, with one pure function

Keep the decision in its own function that takes the order, its line items, its transactions, and returns an action: restock, flag, or skip. It skips anything that is not Declined or was already adjusted, flags anything with an approved or captured transaction so a human checks it, and only returns restock when the order is genuinely unpaid.

decide.py
DECLINED_STATUS_ID = 6
CHARGED_STATUSES = {"approved", "captured"}

def decide_restock(order, transactions):
    if order["status_id"] != DECLINED_STATUS_ID:
        return {"action": "skip", "items": []}
    if order.get("already_adjusted"):
        return {"action": "skip", "items": []}
    if any(t.get("status") in CHARGED_STATUSES for t in transactions):
        return {"action": "flag", "items": []}
    items = [{"variant_id": p["variant_id"], "qty": p["quantity"]} for p in order["products"]]
    return {"action": "restock", "items": items}
decide.js
const DECLINED_STATUS_ID = 6;
const CHARGED_STATUSES = new Set(["approved", "captured"]);

export function decideRestock(order, transactions) {
  if (order.status_id !== DECLINED_STATUS_ID) return { action: "skip", items: [] };
  if (order.already_adjusted) return { action: "skip", items: [] };
  if (transactions.some((t) => CHARGED_STATUSES.has(t.status))) return { action: "flag", items: [] };
  const items = order.products.map((p) => ({ variant_id: p.variant_id, qty: p.quantity }));
  return { action: "restock", items };
}
5

Apply the restock and mark the order as handled

When the decision is restock, call POST /v3/inventory/adjustments/relative with one adjustment per variant, a positive quantity, a reason, and the location_id the order shipped from. Then write an idempotency marker, a private note on the order, so a second run never adds the same quantity back twice.

apply.py
def restock_items(items, location_id):
    payload = {
        "reason": "Declined order restock reconciliation",
        "location_id": location_id,
        "items": [{"variant_id": i["variant_id"], "quantity": i["qty"]} for i in items],
    }
    return bc("POST", "/v3/inventory/adjustments/relative", json=payload)

def mark_adjusted(order_id):
    bc("POST", f"/v2/orders/{order_id}/notes", json={"note": "declined-order-restocked-by-script"})
apply.js
async function restockItems(items, locationId) {
  const payload = {
    reason: "Declined order restock reconciliation",
    location_id: locationId,
    items: items.map((i) => ({ variant_id: i.variant_id, quantity: i.qty })),
  };
  return bc("POST", "/v3/inventory/adjustments/relative", payload);
}

async function markAdjusted(orderId) {
  await bc("POST", `/v2/orders/${orderId}/notes`, { note: "declined-order-restocked-by-script" });
}
6

Wire it together with a dry run guard

The loop ties every piece together. On the first few runs, leave DRY_RUN on so the script only logs what it would restock and what it would flag. Read the flagged list yourself, since those are the orders where money moved despite the Declined status and deserve a human look. Run the job on a schedule, for example every few hours.

Run it safe

Always start with DRY_RUN=true. Never auto-restock an order with an approved or captured transaction, even if its status says Declined, because a human may have manually captured or shipped it later. Those go to the flag pile, not the restock pile.

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 it never restocks an order twice and never restocks one with real money behind it.

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

restock_declined_orders.py
"""Restock BigCommerce Declined orders whose stock was never returned.

BigCommerce debits inventory_level at order creation, and only returns it when
the order reaches a status your Inventory Settings map to "return stock",
typically Cancelled or Refunded. Declined (status_id 6) is often not covered,
so the debited stock sits withheld from real buyers. This lists recently
Declined orders, confirms with GET /v2/orders/{id}/transactions that nothing
was actually approved or captured, and returns each line item's quantity with
POST /v3/inventory/adjustments/relative. Orders with real money behind them
are flagged for a human instead of auto-restocked. Run on a schedule. Safe to
run again and again.
"""
import os
import logging
from datetime import datetime, timedelta, timezone
import requests

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

STORE_HASH = os.environ["BIGCOMMERCE_STORE_HASH"]
TOKEN = os.environ["BIGCOMMERCE_ACCESS_TOKEN"]
BASE = f"https://api.bigcommerce.com/stores/{STORE_HASH}/"
LOOKBACK_DAYS = int(os.environ.get("RESTOCK_LOOKBACK_DAYS", "3"))
LOCATION_ID = int(os.environ.get("LOCATION_ID", "1"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"

DECLINED_STATUS_ID = 6
CHARGED_STATUSES = {"approved", "captured"}
ADJUSTED_NOTE = "declined-order-restocked-by-script"


def bc(method, path, **kwargs):
    r = requests.request(
        method, BASE + path.lstrip("/"),
        headers={"X-Auth-Token": TOKEN, "Content-Type": "application/json", "Accept": "application/json"},
        timeout=30, **kwargs,
    )
    r.raise_for_status()
    if not r.content:
        return None
    body = r.json()
    return body["data"] if isinstance(body, dict) and "data" in body else body


def decide_restock(order, transactions):
    if order["status_id"] != DECLINED_STATUS_ID:
        return {"action": "skip", "items": []}
    if order.get("already_adjusted"):
        return {"action": "skip", "items": []}
    if any(t.get("status") in CHARGED_STATUSES for t in transactions):
        return {"action": "flag", "items": []}
    items = [{"variant_id": p["variant_id"], "qty": p["quantity"]} for p in order["products"]]
    return {"action": "restock", "items": items}


def declined_orders():
    since = (datetime.now(timezone.utc) - timedelta(days=LOOKBACK_DAYS)).strftime("%Y-%m-%dT%H:%M:%S")
    page = 1
    while True:
        batch = bc("GET", f"/v2/orders?status_id={DECLINED_STATUS_ID}&min_date_modified={since}&page={page}&limit=50")
        if not batch:
            return
        for order in batch:
            yield order
        page += 1


def order_products(order_id):
    products = bc("GET", f"/v2/orders/{order_id}/products") or []
    return [{"variant_id": p["variant_id"], "quantity": p["quantity"]} for p in products]


def order_transactions(order_id):
    return bc("GET", f"/v2/orders/{order_id}/transactions") or []


def order_notes(order_id):
    return bc("GET", f"/v2/orders/{order_id}/notes") or []


def already_adjusted(order_id):
    return any(ADJUSTED_NOTE in (n.get("note") or "") for n in order_notes(order_id))


def restock_items(items, location_id):
    payload = {
        "reason": "Declined order restock reconciliation",
        "location_id": location_id,
        "items": [{"variant_id": i["variant_id"], "quantity": i["qty"]} for i in items],
    }
    return bc("POST", "/v3/inventory/adjustments/relative", json=payload)


def mark_adjusted(order_id):
    bc("POST", f"/v2/orders/{order_id}/notes", json={"note": ADJUSTED_NOTE})


def run():
    restocked = 0
    flagged = 0
    for order in declined_orders():
        order_id = order["id"]
        full_order = {
            "status_id": order["status_id"],
            "products": order_products(order_id),
            "already_adjusted": already_adjusted(order_id),
        }
        transactions = order_transactions(order_id)
        decision = decide_restock(full_order, transactions)

        if decision["action"] == "skip":
            continue
        if decision["action"] == "flag":
            log.warning("Order %s Declined but has approved/captured transactions. Flagging for review.", order_id)
            flagged += 1
            continue

        log.info("Order %s eligible to restock %d item(s). %s", order_id, len(decision["items"]),
                  "would restock" if DRY_RUN else "restocking")
        if not DRY_RUN:
            restock_items(decision["items"], LOCATION_ID)
            mark_adjusted(order_id)
        restocked += 1

    log.info("Done. %d order(s) %s, %d order(s) flagged for review.",
              restocked, "to restock" if DRY_RUN else "restocked", flagged)


if __name__ == "__main__":
    run()
restock-declined-orders.js
/**
 * Restock BigCommerce Declined orders whose stock was never returned.
 *
 * BigCommerce debits inventory_level at order creation, and only returns it when
 * the order reaches a status your Inventory Settings map to "return stock",
 * typically Cancelled or Refunded. Declined (status_id 6) is often not covered,
 * so the debited stock sits withheld from real buyers. This lists recently
 * Declined orders, confirms with GET /v2/orders/{id}/transactions that nothing
 * was actually approved or captured, and returns each line item's quantity with
 * POST /v3/inventory/adjustments/relative. Orders with real money behind them
 * are flagged for a human instead of auto-restocked. Run on a schedule. Safe to
 * run again and again.
 *
 * Guide: https://www.allanninal.dev/bigcommerce/declined-order-still-holds-stock/
 */
import { pathToFileURL } from "node:url";

const STORE_HASH = process.env.BIGCOMMERCE_STORE_HASH || "example-store-hash";
const TOKEN = process.env.BIGCOMMERCE_ACCESS_TOKEN || "dummy-token";
const BASE = `https://api.bigcommerce.com/stores/${STORE_HASH}/`;
const LOOKBACK_DAYS = Number(process.env.RESTOCK_LOOKBACK_DAYS || 3);
const LOCATION_ID = Number(process.env.LOCATION_ID || 1);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

const DECLINED_STATUS_ID = 6;
const CHARGED_STATUSES = new Set(["approved", "captured"]);
const ADJUSTED_NOTE = "declined-order-restocked-by-script";

export function decideRestock(order, transactions) {
  if (order.status_id !== DECLINED_STATUS_ID) return { action: "skip", items: [] };
  if (order.already_adjusted) return { action: "skip", items: [] };
  if (transactions.some((t) => CHARGED_STATUSES.has(t.status))) return { action: "flag", items: [] };
  const items = order.products.map((p) => ({ variant_id: p.variant_id, qty: p.quantity }));
  return { action: "restock", items };
}

async function bc(method, path, body) {
  const res = await fetch(BASE + path.replace(/^\//, ""), {
    method,
    headers: { "X-Auth-Token": TOKEN, "Content-Type": "application/json", "Accept": "application/json" },
    body: body ? JSON.stringify(body) : undefined,
  });
  if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
  const text = await res.text();
  if (!text) return null;
  const json = JSON.parse(text);
  return json && typeof json === "object" && "data" in json ? json.data : json;
}

async function* declinedOrders() {
  const since = new Date(Date.now() - LOOKBACK_DAYS * 86400000).toISOString().slice(0, 19);
  let page = 1;
  while (true) {
    const batch = await bc("GET", `/v2/orders?status_id=${DECLINED_STATUS_ID}&min_date_modified=${since}&page=${page}&limit=50`);
    if (!batch || !batch.length) return;
    for (const order of batch) yield order;
    page += 1;
  }
}

async function orderProducts(orderId) {
  const products = (await bc("GET", `/v2/orders/${orderId}/products`)) || [];
  return products.map((p) => ({ variant_id: p.variant_id, quantity: p.quantity }));
}

async function orderTransactions(orderId) {
  return (await bc("GET", `/v2/orders/${orderId}/transactions`)) || [];
}

async function orderNotes(orderId) {
  return (await bc("GET", `/v2/orders/${orderId}/notes`)) || [];
}

async function alreadyAdjusted(orderId) {
  const notes = await orderNotes(orderId);
  return notes.some((n) => (n.note || "").includes(ADJUSTED_NOTE));
}

async function restockItems(items, locationId) {
  const payload = {
    reason: "Declined order restock reconciliation",
    location_id: locationId,
    items: items.map((i) => ({ variant_id: i.variant_id, quantity: i.qty })),
  };
  return bc("POST", "/v3/inventory/adjustments/relative", payload);
}

async function markAdjusted(orderId) {
  await bc("POST", `/v2/orders/${orderId}/notes`, { note: ADJUSTED_NOTE });
}

export async function run() {
  let restocked = 0;
  let flagged = 0;
  for await (const order of declinedOrders()) {
    const orderId = order.id;
    const fullOrder = {
      status_id: order.status_id,
      products: await orderProducts(orderId),
      already_adjusted: await alreadyAdjusted(orderId),
    };
    const transactions = await orderTransactions(orderId);
    const decision = decideRestock(fullOrder, transactions);

    if (decision.action === "skip") continue;
    if (decision.action === "flag") {
      console.warn(`Order ${orderId} Declined but has approved/captured transactions. Flagging for review.`);
      flagged++;
      continue;
    }

    console.log(`Order ${orderId} eligible to restock ${decision.items.length} item(s). ${DRY_RUN ? "would restock" : "restocking"}`);
    if (!DRY_RUN) {
      await restockItems(decision.items, LOCATION_ID);
      await markAdjusted(orderId);
    }
    restocked++;
  }
  console.log(`Done. ${restocked} order(s) ${DRY_RUN ? "to restock" : "restocked"}, ${flagged} order(s) flagged for review.`);
}

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

Add a test

The decision rule is the part most worth testing, because it decides whether real stock gets added back. Because we kept decide_restock pure, the test needs no network and no BigCommerce store. It just feeds in plain objects and checks the answer.

test_declined_restock.py
from restock_declined_orders import decide_restock


def order(**over):
    base = {
        "status_id": 6,
        "already_adjusted": False,
        "products": [{"variant_id": 101, "sku": "ABC-1", "quantity": 2}],
    }
    base.update(over)
    return base


def test_restocks_declined_order_with_no_charge():
    result = decide_restock(order(), [])
    assert result["action"] == "restock"
    assert result["items"] == [{"variant_id": 101, "qty": 2}]


def test_skips_non_declined_order():
    result = decide_restock(order(status_id=5), [])
    assert result == {"action": "skip", "items": []}


def test_skips_already_adjusted_order():
    result = decide_restock(order(already_adjusted=True), [])
    assert result == {"action": "skip", "items": []}


def test_flags_when_transaction_was_approved():
    result = decide_restock(order(), [{"status": "approved"}])
    assert result == {"action": "flag", "items": []}


def test_flags_when_transaction_was_captured():
    result = decide_restock(order(), [{"status": "captured"}])
    assert result == {"action": "flag", "items": []}


def test_ignores_declined_transactions():
    result = decide_restock(order(), [{"status": "declined"}])
    assert result["action"] == "restock"
declined-restock.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { decideRestock } from "./restock-declined-orders.js";

const order = (over = {}) => ({
  status_id: 6,
  already_adjusted: false,
  products: [{ variant_id: 101, sku: "ABC-1", quantity: 2 }],
  ...over,
});

test("restocks Declined order with no charge", () => {
  const result = decideRestock(order(), []);
  assert.equal(result.action, "restock");
  assert.deepEqual(result.items, [{ variant_id: 101, qty: 2 }]);
});

test("skips non Declined order", () => {
  assert.deepEqual(decideRestock(order({ status_id: 5 }), []), { action: "skip", items: [] });
});

test("skips already adjusted order", () => {
  assert.deepEqual(decideRestock(order({ already_adjusted: true }), []), { action: "skip", items: [] });
});

test("flags when transaction was approved", () => {
  assert.deepEqual(decideRestock(order(), [{ status: "approved" }]), { action: "flag", items: [] });
});

test("flags when transaction was captured", () => {
  assert.deepEqual(decideRestock(order(), [{ status: "captured" }]), { action: "flag", items: [] });
});

test("ignores declined transactions", () => {
  assert.equal(decideRestock(order(), [{ status: "declined" }]).action, "restock");
});

Case studies

Fraud screening

The app that declined orders behind the scenes

A store ran a fraud-screening app that pushed suspicious orders straight to Declined through the Orders API whenever its risk score crossed a threshold. Inventory Settings only returned stock on Cancelled and Refunded, so every one of those app-declined orders quietly kept its stock debited.

The team ran the reconciliation job in dry run, saw a growing list of Declined orders holding stock with zero transactions on them, confirmed the list looked right, then let it write for real on an hourly schedule. Sellable quantity stopped drifting down for no reason.

Manual payment failure

The card that failed after the order was already created

A store took manual card payments where the order record was created first and the charge attempt happened a moment later. When a card failed, staff marked the order Declined by hand, but nobody thought to check whether the stock the order had already grabbed came back.

After a few months, popular variants were quietly showing lower stock than what was actually on the shelf. The script found the backlog of Declined orders with no approved transaction, restocked the honest ones, and flagged two where a transaction had, in fact, gone through later. Those two were reviewed by hand, exactly as designed.

What good looks like

After this runs on a schedule, a Declined order stops being a silent inventory leak. Every order that truly has no money behind it gets its stock back automatically, every order with real charges gets flagged instead of touched, and the idempotency note means the job can run every hour without ever double-restocking the same order.

FAQ

Why does a Declined BigCommerce order still hold stock?

BigCommerce decrements inventory_level at order creation or payment authorization time, and only returns that stock automatically when the order moves into a status your Inventory Settings map to return stock, typically Cancelled or Refunded. Declined (status_id 6) is often outside that mapping, or the status change came from an app or webhook path that skipped the storefront flow that normally triggers the restock hook, so the stock stays debited.

Is it safe to auto-restock every Declined order?

No. A Declined order can still have an approved or captured transaction on it, for example if a human manually captured the payment after the status was set. Auto-restocking that order would let it oversell. Only auto-restock when GET /v2/orders/{id}/transactions shows no transaction with status approved or captured, and flag everything else for manual review.

Which endpoint actually returns the stock?

A relative adjustment through POST /v3/inventory/adjustments/relative, scoped to the same location_id the order shipped from and keyed to each line item's variant. Use an idempotency marker, such as a note or custom field on the order, so a second run of the job never adds the same quantity back twice.

Related field notes

Citations

On the problem:

  1. BigCommerce Support: out of stock items back in stock after cancelling an order and changing back to zero in stock. support.bigcommerce.com out-of-stock-items-back-in-stock
  2. BigCommerce Help Center: Inventory Tracking. support.bigcommerce.com/s/article/Inventory-Tracking
  3. BigCommerce Help Center: Order Statuses. support.bigcommerce.com/s/article/Order-Statuses

On the solution:

  1. BigCommerce Developer Center: Inventory Adjustments (REST Management). developer.bigcommerce.com/docs/rest-management/inventory/adjustments
  2. BigCommerce Developer Center: Order Status (REST Management). developer.bigcommerce.com/docs/rest-management/orders/order-status
  3. BigCommerce Developer Center: Orders Overview (Store Operations). developer.bigcommerce.com/docs/store-operations/orders

Stuck on a tricky one?

If you have a problem in BigCommerce orders, payments, inventory, webhooks, 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 save you some sellable stock?

If this found stock you did not know you were missing, 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 BigCommerce field notes