Repair Orders and payments

Duplicate orders from a double submit

The gateway took a few extra seconds to respond, or the customer clicked Place Order twice because nothing on screen told them it was working. Either way, BigCommerce now shows two orders with the same items, the same total, and the same customer, created seconds apart. Here is why BigCommerce lets this happen and a small script that finds the pair and cancels the one that should not exist.

Python and Node.js V2 orders REST API Safe by default (dry run)
The short answer

BigCommerce's hosted checkout does not natively debounce the final Place Order submit button, so a slow gateway response or a double click can produce two separate orders for one purchase, each with its own order id. Run a small Python or Node.js script that lists recent orders per customer, groups the ones with matching products, total, and a creation time within a few minutes of each other, keeps the earliest (or the one with a settled payment) and cancels the rest with PUT /v2/orders/{id} setting status_id to 5, but only after confirming the duplicate has no captured transaction. Full code, tests, and a dry run guard are below.

The problem in plain words

Placing an order in BigCommerce's hosted checkout is a single click that kicks off a cart to order conversion and a payment authorization. That conversion takes a moment: the cart has to be locked, the order has to be created, and the payment gateway has to respond. Nothing in that flow disables the button while it waits.

If the gateway is slow, or the page does not visibly change, a customer will often click Place Order again. BigCommerce treats each click as its own checkout session. It does not know the second click is a repeat of the first, so it runs the same conversion twice: two orders, same line items, same totals, same customer, seconds apart. Each one gets its own order id, and often each one gets its own payment authorization, so the store owner ends up with two Pending or Awaiting Payment orders for a single real purchase.

Click 1 Place Order Gateway is slow no response yet Click 2 customer clicks again button not disabled Order #1001 created Order #1002 created, seconds later Same items same total
One cart, one real purchase, but two independent checkout sessions produce two order ids, often each with its own payment authorization.

Why it happens

BigCommerce's checkout has no built in guard against this, and each submission is handled as a fully independent conversion. A few common triggers:

Because BigCommerce assigns a new order id and typically a new payment authorization to every conversion, the platform has no built in way to recognize that two orders are really one purchase. It relies on the merchant to notice the duplicate and cancel it by hand, or, more reliably, on a script that checks for the pattern on a schedule. See the citations at the end for the exact support threads and API docs, including a cross-platform report of the same double-click race condition in Magento's checkout.

The key insight

Two orders are the same purchase only when the customer, the products, and the total all match, and the two orders were created close together in time. That is a narrow, testable rule, so we keep it in one pure function and never touch an order that has already shipped or completed. We also never cancel an order that has a captured payment on it without a manual refund step, because a status change alone does not reverse money that was already taken.

The fix, as a flow

We do not touch checkout. We add a job that lists each customer's recent orders still awaiting fulfillment, groups the ones with matching products and totals inside a short time window, keeps the earliest order in each group, and cancels the rest, but only after confirming the one being cancelled has no captured transaction on it.

Scheduled job GET /v2/orders Group by customer, products, total, time window Cluster has more than 1? yes no, leave alone Re-check transactions on the duplicate before acting No capture: cancel Captured: flag manual
The script only cancels the later order in a matched pair, and only after re-checking that it has no captured payment. Orders with a captured payment are flagged for a manual refund instead.

Build it step by step

1

Get a V2 orders API token

Create an API account in your BigCommerce control panel under Settings, API, Store-level API accounts. Give it Orders read and modify scope and note the store hash and the access token. Every call uses the X-Auth-Token header against https://api.bigcommerce.com/stores/{store_hash}/. Keep the store hash and token in environment variables, never in the file.

setup (shell)
pip install requests

export BIGCOMMERCE_STORE_HASH="abc123"
export BIGCOMMERCE_ACCESS_TOKEN="..."
export DUPLICATE_WINDOW_SECONDS="300"
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="..."
export DUPLICATE_WINDOW_SECONDS="300"
export DRY_RUN="true"   // start safe, change to false to write
2

Talk to the V2 orders REST API

Every call sends the token in the X-Auth-Token header and expects JSON back. A small helper wraps GET and PUT and raises on any non-2xx response, so both the listing step and the cancel step share the same error handling.

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}/"

HEADERS = {"X-Auth-Token": TOKEN, "Accept": "application/json", "Content-Type": "application/json"}

def bc_get(path, params=None):
    r = requests.get(BASE + path, headers=HEADERS, params=params or {}, timeout=30)
    r.raise_for_status()
    return r.json()

def bc_put(path, body):
    r = requests.put(BASE + path, headers=HEADERS, json=body, timeout=30)
    r.raise_for_status()
    return r.json()
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}/`;

const HEADERS = { "X-Auth-Token": TOKEN, "Accept": "application/json", "Content-Type": "application/json" };

async function bcGet(path, params = {}) {
  const qs = new URLSearchParams(params).toString();
  const res = await fetch(BASE + path + (qs ? `?${qs}` : ""), { headers: HEADERS });
  if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
  return res.json();
}

async function bcPut(path, body) {
  const res = await fetch(BASE + path, { method: "PUT", headers: HEADERS, body: JSON.stringify(body) });
  if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
  return res.json();
}
3

List recent pre-fulfillment orders and build a product signature

Call GET /v2/orders?min_date_created=...&sort=date_created:asc restricted to the not yet fulfilled status ids, 0 Incomplete, 1 Pending, 7 Awaiting Payment, 9 Awaiting Fulfillment, and 11 Awaiting Fulfillment, so a shipped or completed order is never touched. For each order, call GET /v2/orders/{id}/products and turn the line items into a stable signature string so two orders with identical carts compare equal.

step3.py
PRE_FULFILLMENT_STATUS_IDS = {0, 1, 7, 9, 11}

def product_signature(products):
    """A stable string for a cart's contents, order does not matter."""
    parts = sorted(f"{p['product_id']}x{p['quantity']}" for p in products)
    return "|".join(parts)

def recent_candidate_orders(min_date_created):
    orders = bc_get("v2/orders", {
        "min_date_created": min_date_created,
        "sort": "date_created:asc",
    })
    out = []
    for o in orders:
        if o["status_id"] not in PRE_FULFILLMENT_STATUS_IDS:
            continue
        products = bc_get(f"v2/orders/{o['id']}/products")
        out.append({
            "id": o["id"],
            "customer_id": o["customer_id"],
            "date_created": o["date_created"],
            "total_inc_tax": o["total_inc_tax"],
            "status_id": o["status_id"],
            "product_signature": product_signature(products),
        })
    return out
step3.js
const PRE_FULFILLMENT_STATUS_IDS = new Set([0, 1, 7, 9, 11]);

function productSignature(products) {
  const parts = products.map((p) => `${p.product_id}x${p.quantity}`).sort();
  return parts.join("|");
}

async function recentCandidateOrders(minDateCreated) {
  const orders = await bcGet("v2/orders", {
    min_date_created: minDateCreated,
    sort: "date_created:asc",
  });
  const out = [];
  for (const o of orders) {
    if (!PRE_FULFILLMENT_STATUS_IDS.has(o.status_id)) continue;
    const products = await bcGet(`v2/orders/${o.id}/products`);
    out.push({
      id: o.id,
      customer_id: o.customer_id,
      date_created: o.date_created,
      total_inc_tax: o.total_inc_tax,
      status_id: o.status_id,
      product_signature: productSignature(products),
    });
  }
  return out;
}
4

Decide, with one pure function

Keep the grouping and clustering logic in its own function that takes plain order records and returns duplicate groups. It filters to pre-fulfillment orders, groups by customer, product signature, and total, sorts each group by time, then walks the sorted list splitting into clusters wherever consecutive orders are more than the window apart. Any cluster with more than one order is a duplicate group, first id is the keeper.

decide.py
from datetime import datetime, timezone

PRE_FULFILLMENT_STATUS_IDS = {0, 1, 7, 9, 11}

def _parse(date_created):
    return datetime.strptime(date_created, "%a, %d %b %Y %H:%M:%S %z")

def find_duplicate_order_groups(orders, window_seconds=300):
    eligible = [o for o in orders if o["status_id"] in PRE_FULFILLMENT_STATUS_IDS]

    groups = {}
    for o in eligible:
        key = (o["customer_id"], o["product_signature"], o["total_inc_tax"])
        groups.setdefault(key, []).append(o)

    duplicate_groups = []
    for members in groups.values():
        members = sorted(members, key=lambda o: _parse(o["date_created"]))
        cluster = [members[0]]
        for prev, curr in zip(members, members[1:]):
            delta = (_parse(curr["date_created"]) - _parse(prev["date_created"])).total_seconds()
            if delta <= window_seconds:
                cluster.append(curr)
            else:
                if len(cluster) > 1:
                    duplicate_groups.append([o["id"] for o in cluster])
                cluster = [curr]
        if len(cluster) > 1:
            duplicate_groups.append([o["id"] for o in cluster])

    return duplicate_groups
decide.js
const PRE_FULFILLMENT_STATUS_IDS = new Set([0, 1, 7, 9, 11]);

function toMillis(dateCreated) {
  return new Date(dateCreated).getTime();
}

export function findDuplicateOrderGroups(orders, windowSeconds = 300) {
  const eligible = orders.filter((o) => PRE_FULFILLMENT_STATUS_IDS.has(o.status_id));

  const groups = new Map();
  for (const o of eligible) {
    const key = `${o.customer_id}|${o.product_signature}|${o.total_inc_tax}`;
    if (!groups.has(key)) groups.set(key, []);
    groups.get(key).push(o);
  }

  const duplicateGroups = [];
  for (const members of groups.values()) {
    const sorted = [...members].sort((a, b) => toMillis(a.date_created) - toMillis(b.date_created));
    let cluster = [sorted[0]];
    for (let i = 1; i < sorted.length; i++) {
      const deltaSeconds = (toMillis(sorted[i].date_created) - toMillis(sorted[i - 1].date_created)) / 1000;
      if (deltaSeconds <= windowSeconds) {
        cluster.push(sorted[i]);
      } else {
        if (cluster.length > 1) duplicateGroups.push(cluster.map((o) => o.id));
        cluster = [sorted[i]];
      }
    }
    if (cluster.length > 1) duplicateGroups.push(cluster.map((o) => o.id));
  }

  return duplicateGroups;
}
5

Re-check transactions, then cancel the duplicate

Before cancelling any duplicate id, call GET /v2/orders/{id}/transactions one more time. If it shows a captured or authorized transaction, do not cancel automatically, since a status change alone does not reverse captured money. If it has none, call PUT /v2/orders/{id} with {"status_id": 5} to cancel it.

apply.py
CANCELLED_STATUS_ID = 5

def has_settled_transaction(order_id):
    transactions = bc_get(f"v2/orders/{order_id}/transactions")
    return any(t.get("status") in ("captured", "authorized") for t in transactions)

def cancel_order(order_id):
    return bc_put(f"v2/orders/{order_id}", {"status_id": CANCELLED_STATUS_ID})
apply.js
const CANCELLED_STATUS_ID = 5;

async function hasSettledTransaction(orderId) {
  const transactions = await bcGet(`v2/orders/${orderId}/transactions`);
  return transactions.some((t) => ["captured", "authorized"].includes(t.status));
}

async function cancelOrder(orderId) {
  return bcPut(`v2/orders/${orderId}`, { status_id: CANCELLED_STATUS_ID });
}
6

Wire it together with a dry run guard

The loop finds the duplicate groups, then for every candidate id past the first in the group, re-checks transactions and only cancels when it is safe. On the first few runs leave DRY_RUN on so the script only logs which orders it would cancel. Run it on a schedule that matches how quickly you want duplicates cleared, for example every fifteen minutes.

Run it safe

Always start with DRY_RUN=true, and never let the script cancel an order that has a captured transaction. That case gets flagged for a human to refund first, then cancel by hand.

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 only ever touches orders still awaiting fulfillment and re-verifies transactions right before cancelling.

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

find_duplicate_orders.py
"""Find and cancel duplicate BigCommerce orders created by a double submit.

A slow payment gateway or an impatient double click on Place Order can turn one
checkout into two separate orders: same customer, same products, same total,
created seconds apart. This lists recent pre-fulfillment orders, groups them
with a pure function, keeps the earliest order in each group, and cancels the
rest, but only after re-checking that the duplicate has no captured payment.
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("find_duplicate_orders")

STORE_HASH = os.environ["BIGCOMMERCE_STORE_HASH"]
TOKEN = os.environ["BIGCOMMERCE_ACCESS_TOKEN"]
BASE = f"https://api.bigcommerce.com/stores/{STORE_HASH}/"
WINDOW_SECONDS = int(os.environ.get("DUPLICATE_WINDOW_SECONDS", "300"))
LOOKBACK_MINUTES = int(os.environ.get("LOOKBACK_MINUTES", "15"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"

HEADERS = {"X-Auth-Token": TOKEN, "Accept": "application/json", "Content-Type": "application/json"}

PRE_FULFILLMENT_STATUS_IDS = {0, 1, 7, 9, 11}
CANCELLED_STATUS_ID = 5


def bc_get(path, params=None):
    r = requests.get(BASE + path, headers=HEADERS, params=params or {}, timeout=30)
    r.raise_for_status()
    return r.json()


def bc_put(path, body):
    r = requests.put(BASE + path, headers=HEADERS, json=body, timeout=30)
    r.raise_for_status()
    return r.json()


def product_signature(products):
    """A stable string for a cart's contents, order does not matter."""
    parts = sorted(f"{p['product_id']}x{p['quantity']}" for p in products)
    return "|".join(parts)


def _parse(date_created):
    return datetime.strptime(date_created, "%a, %d %b %Y %H:%M:%S %z")


def find_duplicate_order_groups(orders, window_seconds=300):
    """Pure grouping and clustering logic. No I/O.

    orders: list of {id, customer_id, date_created, total_inc_tax, status_id, product_signature}
    returns: list of [keeper_id, *duplicate_ids]
    """
    eligible = [o for o in orders if o["status_id"] in PRE_FULFILLMENT_STATUS_IDS]

    groups = {}
    for o in eligible:
        key = (o["customer_id"], o["product_signature"], o["total_inc_tax"])
        groups.setdefault(key, []).append(o)

    duplicate_groups = []
    for members in groups.values():
        members = sorted(members, key=lambda o: _parse(o["date_created"]))
        cluster = [members[0]]
        for prev, curr in zip(members, members[1:]):
            delta = (_parse(curr["date_created"]) - _parse(prev["date_created"])).total_seconds()
            if delta <= window_seconds:
                cluster.append(curr)
            else:
                if len(cluster) > 1:
                    duplicate_groups.append([o["id"] for o in cluster])
                cluster = [curr]
        if len(cluster) > 1:
            duplicate_groups.append([o["id"] for o in cluster])

    return duplicate_groups


def recent_candidate_orders(min_date_created):
    orders = bc_get("v2/orders", {
        "min_date_created": min_date_created,
        "sort": "date_created:asc",
    })
    out = []
    for o in orders:
        if o["status_id"] not in PRE_FULFILLMENT_STATUS_IDS:
            continue
        products = bc_get(f"v2/orders/{o['id']}/products")
        out.append({
            "id": o["id"],
            "customer_id": o["customer_id"],
            "date_created": o["date_created"],
            "total_inc_tax": o["total_inc_tax"],
            "status_id": o["status_id"],
            "product_signature": product_signature(products),
        })
    return out


def has_settled_transaction(order_id):
    transactions = bc_get(f"v2/orders/{order_id}/transactions")
    return any(t.get("status") in ("captured", "authorized") for t in transactions)


def cancel_order(order_id):
    return bc_put(f"v2/orders/{order_id}", {"status_id": CANCELLED_STATUS_ID})


def run():
    min_date_created = (datetime.now(timezone.utc) - timedelta(minutes=LOOKBACK_MINUTES)).strftime(
        "%Y-%m-%dT%H:%M:%S+00:00"
    )
    orders = recent_candidate_orders(min_date_created)
    groups = find_duplicate_order_groups(orders, WINDOW_SECONDS)

    cancelled = 0
    flagged = 0
    for group in groups:
        keeper_id, *duplicate_ids = group
        for order_id in duplicate_ids:
            if has_settled_transaction(order_id):
                log.warning("Order %s has a captured transaction, flagging for manual refund then cancel.", order_id)
                flagged += 1
                continue
            log.info("Order %s is a duplicate of %s. %s", order_id, keeper_id,
                      "would cancel" if DRY_RUN else "cancelling")
            if not DRY_RUN:
                cancel_order(order_id)
            cancelled += 1

    log.info("Done. %d order(s) %s, %d flagged for manual review.",
              cancelled, "to cancel" if DRY_RUN else "cancelled", flagged)


if __name__ == "__main__":
    run()
find-duplicate-orders.js
/**
 * Find and cancel duplicate BigCommerce orders created by a double submit.
 *
 * A slow payment gateway or an impatient double click on Place Order can turn one
 * checkout into two separate orders: same customer, same products, same total,
 * created seconds apart. This lists recent pre-fulfillment orders, groups them
 * with a pure function, keeps the earliest order in each group, and cancels the
 * rest, but only after re-checking that the duplicate has no captured payment.
 * Run on a schedule. Safe to run again and again.
 *
 * Guide: https://www.allanninal.dev/bigcommerce/duplicate-orders-from-a-double-submit/
 */
import { pathToFileURL } from "node:url";

const STORE_HASH = process.env.BIGCOMMERCE_STORE_HASH || "storehash123";
const TOKEN = process.env.BIGCOMMERCE_ACCESS_TOKEN || "dummy_token";
const BASE = `https://api.bigcommerce.com/stores/${STORE_HASH}/`;
const WINDOW_SECONDS = Number(process.env.DUPLICATE_WINDOW_SECONDS || 300);
const LOOKBACK_MINUTES = Number(process.env.LOOKBACK_MINUTES || 15);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

const HEADERS = { "X-Auth-Token": TOKEN, "Accept": "application/json", "Content-Type": "application/json" };

const PRE_FULFILLMENT_STATUS_IDS = new Set([0, 1, 7, 9, 11]);
const CANCELLED_STATUS_ID = 5;

export function productSignature(products) {
  const parts = products.map((p) => `${p.product_id}x${p.quantity}`).sort();
  return parts.join("|");
}

function toMillis(dateCreated) {
  return new Date(dateCreated).getTime();
}

export function findDuplicateOrderGroups(orders, windowSeconds = 300) {
  const eligible = orders.filter((o) => PRE_FULFILLMENT_STATUS_IDS.has(o.status_id));

  const groups = new Map();
  for (const o of eligible) {
    const key = `${o.customer_id}|${o.product_signature}|${o.total_inc_tax}`;
    if (!groups.has(key)) groups.set(key, []);
    groups.get(key).push(o);
  }

  const duplicateGroups = [];
  for (const members of groups.values()) {
    const sorted = [...members].sort((a, b) => toMillis(a.date_created) - toMillis(b.date_created));
    let cluster = [sorted[0]];
    for (let i = 1; i < sorted.length; i++) {
      const deltaSeconds = (toMillis(sorted[i].date_created) - toMillis(sorted[i - 1].date_created)) / 1000;
      if (deltaSeconds <= windowSeconds) {
        cluster.push(sorted[i]);
      } else {
        if (cluster.length > 1) duplicateGroups.push(cluster.map((o) => o.id));
        cluster = [sorted[i]];
      }
    }
    if (cluster.length > 1) duplicateGroups.push(cluster.map((o) => o.id));
  }

  return duplicateGroups;
}

async function bcGet(path, params = {}) {
  const qs = new URLSearchParams(params).toString();
  const res = await fetch(BASE + path + (qs ? `?${qs}` : ""), { headers: HEADERS });
  if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
  return res.json();
}

async function bcPut(path, body) {
  const res = await fetch(BASE + path, { method: "PUT", headers: HEADERS, body: JSON.stringify(body) });
  if (!res.ok) throw new Error(`BigCommerce ${res.status}`);
  return res.json();
}

async function recentCandidateOrders(minDateCreated) {
  const orders = await bcGet("v2/orders", {
    min_date_created: minDateCreated,
    sort: "date_created:asc",
  });
  const out = [];
  for (const o of orders) {
    if (!PRE_FULFILLMENT_STATUS_IDS.has(o.status_id)) continue;
    const products = await bcGet(`v2/orders/${o.id}/products`);
    out.push({
      id: o.id,
      customer_id: o.customer_id,
      date_created: o.date_created,
      total_inc_tax: o.total_inc_tax,
      status_id: o.status_id,
      product_signature: productSignature(products),
    });
  }
  return out;
}

async function hasSettledTransaction(orderId) {
  const transactions = await bcGet(`v2/orders/${orderId}/transactions`);
  return transactions.some((t) => ["captured", "authorized"].includes(t.status));
}

async function cancelOrder(orderId) {
  return bcPut(`v2/orders/${orderId}`, { status_id: CANCELLED_STATUS_ID });
}

export async function run() {
  const minDateCreated = new Date(Date.now() - LOOKBACK_MINUTES * 60 * 1000).toISOString();
  const orders = await recentCandidateOrders(minDateCreated);
  const groups = findDuplicateOrderGroups(orders, WINDOW_SECONDS);

  let cancelled = 0;
  let flagged = 0;
  for (const group of groups) {
    const [keeperId, ...duplicateIds] = group;
    for (const orderId of duplicateIds) {
      if (await hasSettledTransaction(orderId)) {
        console.warn(`Order ${orderId} has a captured transaction, flagging for manual refund then cancel.`);
        flagged++;
        continue;
      }
      console.log(`Order ${orderId} is a duplicate of ${keeperId}. ${DRY_RUN ? "would cancel" : "cancelling"}`);
      if (!DRY_RUN) await cancelOrder(orderId);
      cancelled++;
    }
  }

  console.log(`Done. ${cancelled} order(s) ${DRY_RUN ? "to cancel" : "cancelled"}, ${flagged} flagged for manual review.`);
}

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

Add a test

The clustering rule is the part most worth testing, because it decides which order gets cancelled. Because we kept find_duplicate_order_groups pure, the test needs no network and no BigCommerce store. It just feeds in plain order records with fixed timestamps and checks the grouping.

test_double_submit.py
from find_duplicate_orders import find_duplicate_order_groups


def order(id, customer_id=1, minute=0, second=0, total="49.99", status_id=1, sig="10x1"):
    return {
        "id": id,
        "customer_id": customer_id,
        "date_created": f"Fri, 10 Jul 2026 10:{minute:02d}:{second:02d} +0000",
        "total_inc_tax": total,
        "status_id": status_id,
        "product_signature": sig,
    }


def test_two_close_orders_form_a_duplicate_group():
    orders = [order(1001, second=0), order(1002, second=20)]
    groups = find_duplicate_order_groups(orders, window_seconds=300)
    assert groups == [[1001, 1002]]


def test_orders_far_apart_are_not_grouped():
    orders = [order(1001, minute=0), order(1002, minute=20)]
    groups = find_duplicate_order_groups(orders, window_seconds=300)
    assert groups == []


def test_different_totals_are_not_grouped():
    orders = [order(1001, second=0, total="49.99"), order(1002, second=20, total="59.99")]
    groups = find_duplicate_order_groups(orders, window_seconds=300)
    assert groups == []


def test_different_customers_are_not_grouped():
    orders = [order(1001, customer_id=1, second=0), order(1002, customer_id=2, second=20)]
    groups = find_duplicate_order_groups(orders, window_seconds=300)
    assert groups == []


def test_shipped_orders_are_ignored():
    orders = [order(1001, second=0, status_id=2), order(1002, second=20, status_id=2)]
    groups = find_duplicate_order_groups(orders, window_seconds=300)
    assert groups == []


def test_three_in_a_row_form_one_cluster():
    orders = [order(1001, second=0), order(1002, second=10), order(1003, second=20)]
    groups = find_duplicate_order_groups(orders, window_seconds=300)
    assert groups == [[1001, 1002, 1003]]
double-submit.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { findDuplicateOrderGroups } from "./find-duplicate-orders.js";

const order = (id, { customerId = 1, minute = 0, second = 0, total = "49.99", statusId = 1, sig = "10x1" } = {}) => ({
  id,
  customer_id: customerId,
  date_created: `2026-07-10T10:${String(minute).padStart(2, "0")}:${String(second).padStart(2, "0")}Z`,
  total_inc_tax: total,
  status_id: statusId,
  product_signature: sig,
});

test("two close orders form a duplicate group", () => {
  const orders = [order(1001, { second: 0 }), order(1002, { second: 20 })];
  assert.deepEqual(findDuplicateOrderGroups(orders, 300), [[1001, 1002]]);
});

test("orders far apart are not grouped", () => {
  const orders = [order(1001, { minute: 0 }), order(1002, { minute: 20 })];
  assert.deepEqual(findDuplicateOrderGroups(orders, 300), []);
});

test("different totals are not grouped", () => {
  const orders = [order(1001, { second: 0, total: "49.99" }), order(1002, { second: 20, total: "59.99" })];
  assert.deepEqual(findDuplicateOrderGroups(orders, 300), []);
});

test("different customers are not grouped", () => {
  const orders = [order(1001, { customerId: 1, second: 0 }), order(1002, { customerId: 2, second: 20 })];
  assert.deepEqual(findDuplicateOrderGroups(orders, 300), []);
});

test("shipped orders are ignored", () => {
  const orders = [order(1001, { second: 0, statusId: 2 }), order(1002, { second: 20, statusId: 2 })];
  assert.deepEqual(findDuplicateOrderGroups(orders, 300), []);
});

test("three in a row form one cluster", () => {
  const orders = [order(1001, { second: 0 }), order(1002, { second: 10 }), order(1003, { second: 20 })];
  assert.deepEqual(findDuplicateOrderGroups(orders, 300), [[1001, 1002, 1003]]);
});

Case studies

Slow gateway

The store on a redirect based gateway

A home goods store used a payment gateway that redirected the customer off-site and took eight to ten seconds to bounce back on a bad connection. Customers assumed the click did not register and pressed Place Order again. The store was quietly collecting two authorizations most weekends.

Running the script every fifteen minutes now catches the pair before either one ships, cancels the one with no captured payment, and leaves a clean audit trail in the order notes for the one that was flagged.

Mobile checkout

The mobile traffic spike after a promo email

After a promotional email went out, a store saw a spike in mobile checkouts and, with it, a spike in duplicate orders from customers double tapping the submit button on small screens. Support was manually hunting for pairs by eye in the order list, which did not scale during the surge.

The team ran the script in dry run first, confirmed the pairs it found matched what support had been finding by hand, then let it run for real on a schedule. The backlog cleared and new duplicates now get resolved within fifteen minutes instead of piling up.

What good looks like

After this runs on a schedule, a double submit is caught and cleaned up within minutes instead of sitting in the order list until someone notices. The keeper order ships normally, the duplicate is cancelled automatically when it is safe, and anything with a captured payment is flagged for a person to refund properly. Nobody has to eyeball the order list looking for near-identical pairs.

FAQ

Why does BigCommerce create two orders for one checkout?

BigCommerce's hosted checkout does not natively debounce the final Place Order submit button. If a payment gateway responds slowly or a customer clicks twice, each click starts its own cart to order conversion before the first one returns, so BigCommerce assigns each submission its own order id and often its own payment authorization.

Is it safe to auto-cancel the duplicate order?

Only after you confirm it has no captured payment. Re-check GET /v2/orders/{id}/transactions right before cancelling. If a captured transaction exists on the duplicate, do not cancel it automatically, flag it for a manual refund and then cancel, because changing status_id does not reverse a captured payment.

How do I know which of the two orders to keep?

Keep the order with the lowest id, or the one that shows a successful captured or authorized transaction if only one of the pair actually settled payment. The other order in the pair, matched by same customer, same product signature, same total, and created within a few minutes, is the duplicate to cancel.

Related field notes

Citations

On the problem:

  1. BigCommerce Help Center: Duplicate Orders. support.bigcommerce.com/s/topic/0TO130000005C1lGAE/duplicate-orders
  2. BigCommerce Help Center: Duplicate Order topic. support.bigcommerce.com/s/topic/0TO4O0000002PLNWA2/duplicate-order
  3. A cross-platform report of the same double-click race condition on the Pay button. github.com/magento/magento2/issues/10767

On the solution:

  1. BigCommerce Developer Center: Orders (REST Management API). developer.bigcommerce.com/docs/rest-management/orders
  2. BigCommerce Developer Center: Order Status. developer.bigcommerce.com/docs/rest-management/orders/order-status
  3. BigCommerce API Reference: List Orders. docs.bigcommerce.com/developer/api-reference/rest/admin/management/orders/get-orders

Stuck on a tricky one?

If you have a problem in BigCommerce orders, payments, inventory, or fulfillment that you would rather hand off, this is the kind of work I do. Message me and we can work through it together.

Contact me on LinkedIn

Did this clear your duplicate orders?

If this saved you a pile of manual cancellations or a confusing revenue report, 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