Reconciler Subscriptions and billing

Shopify duplicate renewal charges from a retry with no idempotency key

A renewal times out, or a webhook fires twice, so the caller retries the billing attempt. It generated a new idempotency key instead of reusing the old one, so Shopify treated the retry as a brand new sale. Now the subscriber has two orders and two successful charges for one billing cycle. Here is why a retry turns into a real duplicate and a small script that finds the extra charge on each contract and flags it for a refund review.

Python and Node.js Admin GraphQL API Safe by default (dry run)
A pen resting on a calendar
Photo by Renáta-Adrienn on Unsplash
The short answer

Duplicate renewal charges happen when a retry, whether from a timeout, a crashed worker, or a redelivered webhook, sends the same billing attempt through again without anything to tell Shopify it is the same attempt. Shopify's own subscriptionBillingAttemptCreate mutation takes an idempotencyKey for exactly this reason, generate one value per intended charge and send the same value on every retry, and Shopify will not create a second order for it. For charges that already slipped through, run a small Python or Node.js job that walks each subscription contract's recent billing attempts, groups the successful ones by the day they landed on, and tags every attempt after the first one in a cycle for a refund review. Full code, tests, and a dry run guard are below.

The problem in plain words

A subscription renewal is not one call, it is a small pipeline. Something decides a contract is due, Shopify runs a billing attempt for that cycle, the attempt captures a payment, and on success it creates an order. Each of those steps can fail partway, and when it does, the natural response is to retry.

The trouble is that a retry only stays safe if Shopify can tell "try it again" apart from "charge them again." Shopify's billing attempt mutation takes an idempotency key for exactly this reason, but if the caller does not send the same key on the retry that it sent the first time, the two calls look like two separate intended charges. Shopify does what it is told, runs a second attempt, and the subscriber now has two successful charges and two orders for one renewal instead of one.

Contract due renewal for this cycle Billing attempt 1 key A Response times out caller cannot tell if it worked Retries with key B not the same key Second order looks brand new Billed twice
Shopify cannot tell the retry apart from a fresh charge unless it carries the same idempotency key, so it runs a second billing attempt and creates a second order for the same cycle.

Why it happens

Every one of these starts the same way, a network or process failure hides whether the first attempt actually succeeded, and the retry generates a fresh key instead of reusing the one from before:

None of these are exotic. Timeouts, redeploys, and duplicate webhook deliveries happen to every store eventually. What turns them into a real duplicate charge is a key that changes on retry, an idempotency key only protects you if the same value is sent again, a new key on every attempt gives Shopify no way to recognize a repeat. See the citations at the end for the exact docs on idempotent requests and webhook delivery.

The key insight

A retry is only safe if it carries the exact same idempotency key as the attempt it is retrying, generated once per intended charge and stored before the first call goes out, not regenerated on every attempt. For charges that already happened before that discipline was in place, a contract's billing attempts still carry enough information to reconstruct what went wrong, group the successful attempts by the day they landed on, and anything after the first attempt in that cycle is the duplicate, not a second legitimate sale.

The fix, as a flow

We do not touch how renewals are billed today. We add a reconciler that walks each subscription contract's recent billing attempts, groups the successful ones by the day they landed on, and flags every attempt after the first one in a cycle as a duplicate. In dry run it only reports what it found. Once you trust the list, it tags the extra order with a review tag through tagsAdd, so a human can issue the refund with full context, and it never touches the first charge for the cycle.

Scheduled job runs on a timer List billing attempts per subscription contract Group by billing day originTime, per contract More than one attempt? yes no, leave alone Keep first attempt, tag the rest
The reconciler only tags an order for review when the same contract already has an earlier successful attempt in the same billing cycle. The first attempt in a cycle is always left alone.

Build it step by step

1

Get an Admin API access token

Create a custom app in your Shopify admin under Settings, Apps and sales channels, Develop apps. Give it the read_own_subscription_contracts and read_orders scopes, plus write_orders if you plan to let a human act on the tag, and install it to get an Admin API access token that starts with shpat_. Keep the token and the shop domain in environment variables, never in the file.

setup (shell)
pip install requests

export SHOPIFY_SHOP="yourstore.myshopify.com"
export SHOPIFY_ACCESS_TOKEN="shpat_..."
export REVIEW_TAG="duplicate-renewal"
export DRY_RUN="true"   # start safe, change to false to tag
setup (shell)
// Node 18+ has fetch built in, no dependencies needed

export SHOPIFY_SHOP="yourstore.myshopify.com"
export SHOPIFY_ACCESS_TOKEN="shpat_..."
export REVIEW_TAG="duplicate-renewal"
export DRY_RUN="true"   // start safe, change to false to tag
2

Talk to the Admin GraphQL API

Every call goes to one GraphQL endpoint with your token in the X-Shopify-Access-Token header. A small helper sends a query and returns the data, and raises if Shopify reports an error. We use this same helper to read subscription contracts and to run the tagging mutation.

step2.py
import os, requests

SHOP = os.environ["SHOPIFY_SHOP"]
TOKEN = os.environ["SHOPIFY_ACCESS_TOKEN"]
API_VERSION = os.environ.get("SHOPIFY_API_VERSION", "2025-01")
ENDPOINT = f"https://{SHOP}/admin/api/{API_VERSION}/graphql.json"

def gql(query, variables=None):
    r = requests.post(
        ENDPOINT,
        json={"query": query, "variables": variables or {}},
        headers={"X-Shopify-Access-Token": TOKEN, "Content-Type": "application/json"},
        timeout=30,
    )
    r.raise_for_status()
    body = r.json()
    if body.get("errors"):
        raise RuntimeError(body["errors"])
    return body["data"]
step2.js
const SHOP = process.env.SHOPIFY_SHOP;
const TOKEN = process.env.SHOPIFY_ACCESS_TOKEN;
const API_VERSION = process.env.SHOPIFY_API_VERSION || "2025-01";
const ENDPOINT = `https://${SHOP}/admin/api/${API_VERSION}/graphql.json`;

async function gql(query, variables = {}) {
  const res = await fetch(ENDPOINT, {
    method: "POST",
    headers: { "X-Shopify-Access-Token": TOKEN, "Content-Type": "application/json" },
    body: JSON.stringify({ query, variables }),
  });
  if (!res.ok) throw new Error(`Shopify ${res.status}`);
  const body = await res.json();
  if (body.errors) throw new Error(JSON.stringify(body.errors));
  return body.data;
}
3

List each contract's billing attempts

Ask for subscription contracts and, for each one, its most recent billing attempts, newest first. Read back what the decision needs: the idempotencyKey Shopify stored for the attempt, originTime for when it landed, and the order it produced with its tags and total. We page through contracts with a cursor so the job handles a large store.

step3.py
CONTRACTS_QUERY = """
query($cursor: String) {
  subscriptionContracts(first: 25, after: $cursor) {
    pageInfo { hasNextPage endCursor }
    nodes {
      id
      billingAttempts(first: 20, reverse: true) {
        nodes {
          id
          idempotencyKey
          originTime
          order { id name tags totalPriceSet { shopMoney { amount currencyCode } } }
        }
      }
    }
  }
}"""

def contracts():
    cursor = None
    while True:
        data = gql(CONTRACTS_QUERY, {"cursor": cursor})["subscriptionContracts"]
        for node in data["nodes"]:
            yield node
        if not data["pageInfo"]["hasNextPage"]:
            return
        cursor = data["pageInfo"]["endCursor"]
step3.js
const CONTRACTS_QUERY = `
query($cursor: String) {
  subscriptionContracts(first: 25, after: $cursor) {
    pageInfo { hasNextPage endCursor }
    nodes {
      id
      billingAttempts(first: 20, reverse: true) {
        nodes {
          id
          idempotencyKey
          originTime
          order { id name tags totalPriceSet { shopMoney { amount currencyCode } } }
        }
      }
    }
  }
}`;

async function* contracts() {
  let cursor = null;
  while (true) {
    const data = (await gql(CONTRACTS_QUERY, { cursor })).subscriptionContracts;
    for (const node of data.nodes) yield node;
    if (!data.pageInfo.hasNextPage) return;
    cursor = data.pageInfo.endCursor;
  }
}
4

Group attempts by the billing cycle they landed in

An attempt's originTime is the anniversary date Shopify assigns it, so a retry that runs minutes or hours after the original still carries the same day. Truncating that timestamp to the day gives a stable cycle key without needing anything the caller has to invent. Only attempts that actually produced an order count as a real charge.

cycle.py
def billing_cycle_key(attempt):
    """The billing cycle an attempt belongs to, truncated to the day.

    originTime is the anniversary date Shopify assigns to the attempt, so two
    attempts for the same renewal share the same day even if one was a retry
    made minutes or hours after the first.
    """
    origin = attempt.get("originTime") or ""
    return origin[:10]


def successful_attempts(attempts):
    """Only attempts that produced a real order are charges worth counting."""
    return [a for a in attempts if a.get("order") is not None]
cycle.js
export function billingCycleKey(attempt) {
  // originTime is the anniversary date Shopify assigns to the attempt, so two
  // attempts for the same renewal share the same day even if one was a retry
  // made minutes or hours after the first.
  const origin = attempt.originTime || "";
  return origin.slice(0, 10);
}

function successfulAttempts(attempts) {
  // Only attempts that produced a real order are charges worth counting.
  return (attempts || []).filter((a) => a.order != null);
}
5

Decide, with one pure function

Keep the decision in its own function that takes one contract, with its billing attempts already ordered oldest first, and returns which orders are duplicates. Within each cycle, the first attempt is the legitimate charge and every attempt after it is a duplicate, unless it is already tagged, in which case a previous run already caught it.

decide.py
def to_cents(amount):
    return round(float(amount) * 100)


def find_duplicate_orders(contract, review_tag):
    """Pure decision function. No I/O.

    Groups a contract's successful billing attempts by cycle. Within a cycle,
    the first attempt (attempts must be passed oldest first) is the legitimate
    charge and every attempt after it is a duplicate. Returns the order id,
    name, and amount in cents for each duplicate that is not already tagged,
    never the original charge.
    """
    attempts = successful_attempts(contract.get("billingAttempts") or [])
    seen_cycles = set()
    duplicates = []
    for attempt in attempts:
        cycle = billing_cycle_key(attempt)
        order = attempt["order"]
        if cycle not in seen_cycles:
            seen_cycles.add(cycle)
            continue
        if review_tag in (order.get("tags") or []):
            continue
        amount = to_cents(order["totalPriceSet"]["shopMoney"]["amount"])
        duplicates.append({"order_id": order["id"], "name": order["name"], "amount_cents": amount})
    return duplicates
decide.js
export function toCents(amount) {
  return Math.round(parseFloat(amount) * 100);
}

/**
 * Pure decision function. No I/O.
 *
 * Groups a contract's successful billing attempts by cycle. Within a cycle,
 * the first attempt (attempts must be passed oldest first) is the legitimate
 * charge and every attempt after it is a duplicate. Returns the order id,
 * name, and amount in cents for each duplicate that is not already tagged,
 * never the original charge.
 */
export function findDuplicateOrders(contract, reviewTag) {
  const attempts = successfulAttempts(contract.billingAttempts);
  const seenCycles = new Set();
  const duplicates = [];
  for (const attempt of attempts) {
    const cycle = billingCycleKey(attempt);
    const order = attempt.order;
    if (!seenCycles.has(cycle)) {
      seenCycles.add(cycle);
      continue;
    }
    if ((order.tags || []).includes(reviewTag)) continue;
    const amount = toCents(order.totalPriceSet.shopMoney.amount);
    duplicates.push({ orderId: order.id, name: order.name, amountCents: amount });
  }
  return duplicates;
}
6

Tag the duplicate and wire it together with a dry run guard

When a duplicate is found, tag the order with tagsAdd so it surfaces in a saved search for someone to review and refund with full context, rather than refunding blind from a script. Notice the dry run guard. On the first few runs, leave DRY_RUN on so the job only reports which orders it would tag. Read the output, agree with it, then switch it off to let it write. Run it on a schedule that matches how often renewals fire, for example once a day.

Run it safe

Always start with DRY_RUN=true, and never tag the earliest attempt in a cycle, only the ones after it. The billing attempt that landed first was almost always the intended one, everything after it for the same contract and cycle is the retry that should not have billed.

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 tags an order that is provably a later duplicate of an earlier billing attempt on the same contract and cycle.

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

find_duplicate_renewals.py
"""Find subscription renewals that got billed twice for the same cycle.

A retry after a timeout or a webhook redelivered without an idempotency key can
create a second successful billing attempt for a contract that already has one
for the same cycle. Each attempt makes its own order and charges the card again,
so the customer pays twice for one box. This walks each active subscription
contract's recent billing attempts, groups them by billing cycle (the anniversary
date Shopify records on the attempt), flags every successful attempt after the
first one in a cycle as a duplicate, and tags the extra order for a refund review
with tagsAdd. Read only apart from the tag. Run on a schedule. Safe to run again
and again.
"""
import os
import logging
import requests

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

SHOP = os.environ["SHOPIFY_SHOP"]
TOKEN = os.environ["SHOPIFY_ACCESS_TOKEN"]
API_VERSION = os.environ.get("SHOPIFY_API_VERSION", "2025-01")
ENDPOINT = f"https://{SHOP}/admin/api/{API_VERSION}/graphql.json"
REVIEW_TAG = os.environ.get("REVIEW_TAG", "duplicate-renewal")
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"

CONTRACTS_QUERY = """
query($cursor: String) {
  subscriptionContracts(first: 25, after: $cursor) {
    pageInfo { hasNextPage endCursor }
    nodes {
      id
      billingAttempts(first: 20, reverse: true) {
        nodes {
          id
          ready
          idempotencyKey
          originTime
          order {
            id
            name
            tags
            totalPriceSet { shopMoney { amount currencyCode } }
          }
        }
      }
    }
  }
}"""

TAGS_ADD = """
mutation($id: ID!, $tags: [String!]!) {
  tagsAdd(id: $id, tags: $tags) { node { id } userErrors { field message } }
}"""


def gql(query, variables=None):
    r = requests.post(
        ENDPOINT,
        json={"query": query, "variables": variables or {}},
        headers={"X-Shopify-Access-Token": TOKEN, "Content-Type": "application/json"},
        timeout=30,
    )
    r.raise_for_status()
    body = r.json()
    if body.get("errors"):
        raise RuntimeError(body["errors"])
    return body["data"]


def to_cents(amount):
    return round(float(amount) * 100)


def billing_cycle_key(attempt):
    """The billing cycle an attempt belongs to, truncated to the day.

    originTime is the anniversary date Shopify assigns to the attempt, so two
    attempts for the same renewal share the same day even if one was a retry
    made minutes or hours after the first.
    """
    origin = attempt.get("originTime") or ""
    return origin[:10]


def successful_attempts(attempts):
    """Only attempts that produced a real order are charges worth counting."""
    return [a for a in attempts if a.get("order") is not None]


def find_duplicate_orders(contract, review_tag):
    """Pure decision function. No I/O.

    Groups a contract's successful billing attempts by cycle. Within a cycle,
    the first attempt (attempts must be passed oldest first) is the legitimate
    charge and every attempt after it is a duplicate. Returns the order id,
    name, and amount in cents for each duplicate that is not already tagged,
    never the original charge.
    """
    attempts = successful_attempts(contract.get("billingAttempts") or [])
    seen_cycles = set()
    duplicates = []
    for attempt in attempts:
        cycle = billing_cycle_key(attempt)
        order = attempt["order"]
        if cycle not in seen_cycles:
            seen_cycles.add(cycle)
            continue
        if review_tag in (order.get("tags") or []):
            continue
        amount = to_cents(order["totalPriceSet"]["shopMoney"]["amount"])
        duplicates.append({"order_id": order["id"], "name": order["name"], "amount_cents": amount})
    return duplicates


def tag_for_review(order_id, review_tag):
    result = gql(TAGS_ADD, {"id": order_id, "tags": [review_tag]})["tagsAdd"]
    if result["userErrors"]:
        raise RuntimeError(result["userErrors"])


def contracts():
    cursor = None
    while True:
        data = gql(CONTRACTS_QUERY, {"cursor": cursor})["subscriptionContracts"]
        for node in data["nodes"]:
            yield node
        if not data["pageInfo"]["hasNextPage"]:
            return
        cursor = data["pageInfo"]["endCursor"]


def run():
    flagged = 0
    for contract in contracts():
        # billingAttempts comes back newest first, flip it so the earliest
        # attempt in a cycle is treated as the original charge.
        attempts = list(reversed(contract.get("billingAttempts", {}).get("nodes") or []))
        ordered_contract = {**contract, "billingAttempts": attempts}
        for dup in find_duplicate_orders(ordered_contract, REVIEW_TAG):
            log.warning(
                "Order %s is a duplicate renewal charge (%s cents). %s",
                dup["name"], dup["amount_cents"], "would tag" if DRY_RUN else "tagging",
            )
            if not DRY_RUN:
                tag_for_review(dup["order_id"], REVIEW_TAG)
            flagged += 1
    log.info("Done. %d order(s) %s.", flagged, "to tag" if DRY_RUN else "tagged")


if __name__ == "__main__":
    run()
find-duplicate-renewals.js
/**
 * Find subscription renewals that got billed twice for the same cycle.
 *
 * A retry after a timeout or a webhook redelivered without an idempotency key
 * can create a second successful billing attempt for a contract that already
 * has one for the same cycle. Each attempt makes its own order and charges the
 * card again, so the customer pays twice for one box. This walks each active
 * subscription contract's recent billing attempts, groups them by billing
 * cycle (the anniversary date Shopify records on the attempt), flags every
 * successful attempt after the first one in a cycle as a duplicate, and tags
 * the extra order for a refund review with tagsAdd. Run on a schedule.
 *
 * Guide: https://www.allanninal.dev/shopify/duplicate-renewal-charges/
 */
import { pathToFileURL } from "node:url";

const SHOP = process.env.SHOPIFY_SHOP || "example.myshopify.com";
const TOKEN = process.env.SHOPIFY_ACCESS_TOKEN || "shpat_dummy";
const API_VERSION = process.env.SHOPIFY_API_VERSION || "2025-01";
const ENDPOINT = `https://${SHOP}/admin/api/${API_VERSION}/graphql.json`;
const REVIEW_TAG = process.env.REVIEW_TAG || "duplicate-renewal";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

export function toCents(amount) {
  return Math.round(parseFloat(amount) * 100);
}

export function billingCycleKey(attempt) {
  // originTime is the anniversary date Shopify assigns to the attempt, so two
  // attempts for the same renewal share the same day even if one was a retry
  // made minutes or hours after the first.
  const origin = attempt.originTime || "";
  return origin.slice(0, 10);
}

function successfulAttempts(attempts) {
  // Only attempts that produced a real order are charges worth counting.
  return (attempts || []).filter((a) => a.order != null);
}

/**
 * Pure decision function. No I/O.
 *
 * Groups a contract's successful billing attempts by cycle. Within a cycle,
 * the first attempt (attempts must be passed oldest first) is the legitimate
 * charge and every attempt after it is a duplicate. Returns the order id,
 * name, and amount in cents for each duplicate that is not already tagged,
 * never the original charge.
 */
export function findDuplicateOrders(contract, reviewTag) {
  const attempts = successfulAttempts(contract.billingAttempts);
  const seenCycles = new Set();
  const duplicates = [];
  for (const attempt of attempts) {
    const cycle = billingCycleKey(attempt);
    const order = attempt.order;
    if (!seenCycles.has(cycle)) {
      seenCycles.add(cycle);
      continue;
    }
    if ((order.tags || []).includes(reviewTag)) continue;
    const amount = toCents(order.totalPriceSet.shopMoney.amount);
    duplicates.push({ orderId: order.id, name: order.name, amountCents: amount });
  }
  return duplicates;
}

async function gql(query, variables = {}) {
  const res = await fetch(ENDPOINT, {
    method: "POST",
    headers: { "X-Shopify-Access-Token": TOKEN, "Content-Type": "application/json" },
    body: JSON.stringify({ query, variables }),
  });
  if (!res.ok) throw new Error(`Shopify ${res.status}`);
  const body = await res.json();
  if (body.errors) throw new Error(JSON.stringify(body.errors));
  return body.data;
}

const CONTRACTS_QUERY = `
query($cursor: String) {
  subscriptionContracts(first: 25, after: $cursor) {
    pageInfo { hasNextPage endCursor }
    nodes {
      id
      billingAttempts(first: 20, reverse: true) {
        nodes {
          id
          ready
          idempotencyKey
          originTime
          order {
            id
            name
            tags
            totalPriceSet { shopMoney { amount currencyCode } }
          }
        }
      }
    }
  }
}`;

const TAGS_ADD = `
mutation($id: ID!, $tags: [String!]!) {
  tagsAdd(id: $id, tags: $tags) { node { id } userErrors { field message } }
}`;

async function* contracts() {
  let cursor = null;
  while (true) {
    const data = (await gql(CONTRACTS_QUERY, { cursor })).subscriptionContracts;
    for (const node of data.nodes) yield node;
    if (!data.pageInfo.hasNextPage) return;
    cursor = data.pageInfo.endCursor;
  }
}

async function tagForReview(orderId, reviewTag) {
  const result = (await gql(TAGS_ADD, { id: orderId, tags: [reviewTag] })).tagsAdd;
  if (result.userErrors.length) throw new Error(JSON.stringify(result.userErrors));
}

export async function run() {
  let flagged = 0;
  for await (const contract of contracts()) {
    // billingAttempts comes back newest first, flip it so the earliest
    // attempt in a cycle is treated as the original charge.
    const attempts = [...(contract.billingAttempts?.nodes || [])].reverse();
    const orderedContract = { ...contract, billingAttempts: attempts };
    for (const dup of findDuplicateOrders(orderedContract, REVIEW_TAG)) {
      console.warn(
        `Order ${dup.name} is a duplicate renewal charge (${dup.amountCents} cents). ${DRY_RUN ? "would tag" : "tagging"}`
      );
      if (!DRY_RUN) await tagForReview(dup.orderId, REVIEW_TAG);
      flagged++;
    }
  }
  console.log(`Done. ${flagged} order(s) ${DRY_RUN ? "to tag" : "tagged"}.`);
}

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

Add a test

The grouping rule is the part most worth testing, because it decides which order gets tagged and which one stays untouched. Because we kept find_duplicate_orders pure and working entirely in cents, the test needs no network and no Shopify account. It just feeds in plain contract objects, oldest attempt first, and checks the answer.

test_duplicate_renewal_cycles.py
from find_duplicate_renewals import find_duplicate_orders, billing_cycle_key, to_cents


def attempt(order_id, name, origin, amount="29.00", tags=None):
    return {
        "id": f"gid://shopify/SubscriptionBillingAttempt/{order_id}",
        "ready": True,
        "idempotencyKey": f"key-{order_id}",
        "originTime": origin,
        "order": {
            "id": f"gid://shopify/Order/{order_id}",
            "name": name,
            "tags": tags or [],
            "totalPriceSet": {"shopMoney": {"amount": amount, "currencyCode": "USD"}},
        },
    }


def contract(attempts):
    return {"id": "gid://shopify/SubscriptionContract/1", "billingAttempts": attempts}


def test_to_cents_rounds():
    assert to_cents("29.00") == 2900
    assert to_cents("9.99") == 999


def test_billing_cycle_key_truncates_to_day():
    a = attempt(1, "#1001", "2026-07-01T08:00:00Z")
    assert billing_cycle_key(a) == "2026-07-01"


def test_no_duplicates_for_single_attempt_per_cycle():
    c = contract([
        attempt(1, "#1001", "2026-06-01T08:00:00Z"),
        attempt(2, "#1002", "2026-07-01T08:00:00Z"),
    ])
    assert find_duplicate_orders(c, "duplicate-renewal") == []


def test_second_attempt_same_day_is_a_duplicate():
    c = contract([
        attempt(1, "#1001", "2026-07-01T08:00:00Z"),
        attempt(2, "#1002", "2026-07-01T08:14:00Z"),
    ])
    dups = find_duplicate_orders(c, "duplicate-renewal")
    assert len(dups) == 1
    assert dups[0]["order_id"] == "gid://shopify/Order/2"
    assert dups[0]["amount_cents"] == 2900


def test_first_attempt_in_a_cycle_is_never_flagged():
    c = contract([
        attempt(1, "#1001", "2026-07-01T08:00:00Z"),
        attempt(2, "#1002", "2026-07-01T08:14:00Z"),
        attempt(3, "#1003", "2026-07-01T09:00:00Z"),
    ])
    dups = find_duplicate_orders(c, "duplicate-renewal")
    order_ids = [d["order_id"] for d in dups]
    assert "gid://shopify/Order/1" not in order_ids
    assert len(dups) == 2


def test_attempts_without_an_order_are_ignored():
    failed = attempt(1, "#1001", "2026-07-01T08:00:00Z")
    failed["order"] = None
    ok = attempt(2, "#1002", "2026-07-01T08:14:00Z")
    c = contract([failed, ok])
    assert find_duplicate_orders(c, "duplicate-renewal") == []


def test_already_tagged_duplicate_is_skipped():
    c = contract([
        attempt(1, "#1001", "2026-07-01T08:00:00Z"),
        attempt(2, "#1002", "2026-07-01T08:14:00Z", tags=["duplicate-renewal"]),
    ])
    assert find_duplicate_orders(c, "duplicate-renewal") == []


def test_different_cycles_are_each_allowed_one_charge():
    c = contract([
        attempt(1, "#1001", "2026-05-01T08:00:00Z"),
        attempt(2, "#1002", "2026-06-01T08:00:00Z"),
        attempt(3, "#1003", "2026-07-01T08:00:00Z"),
    ])
    assert find_duplicate_orders(c, "duplicate-renewal") == []
duplicate-renewal.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { findDuplicateOrders, billingCycleKey, toCents } from "./find-duplicate-renewals.js";

const attempt = (orderId, name, origin, { amount = "29.00", tags = [] } = {}) => ({
  id: `gid://shopify/SubscriptionBillingAttempt/${orderId}`,
  ready: true,
  idempotencyKey: `key-${orderId}`,
  originTime: origin,
  order: {
    id: `gid://shopify/Order/${orderId}`,
    name,
    tags,
    totalPriceSet: { shopMoney: { amount, currencyCode: "USD" } },
  },
});

const contract = (attempts) => ({ id: "gid://shopify/SubscriptionContract/1", billingAttempts: attempts });

test("toCents rounds", () => {
  assert.equal(toCents("29.00"), 2900);
  assert.equal(toCents("9.99"), 999);
});

test("billingCycleKey truncates to day", () => {
  assert.equal(billingCycleKey(attempt(1, "#1001", "2026-07-01T08:00:00Z")), "2026-07-01");
});

test("no duplicates for single attempt per cycle", () => {
  const c = contract([
    attempt(1, "#1001", "2026-06-01T08:00:00Z"),
    attempt(2, "#1002", "2026-07-01T08:00:00Z"),
  ]);
  assert.deepEqual(findDuplicateOrders(c, "duplicate-renewal"), []);
});

test("second attempt same day is a duplicate", () => {
  const c = contract([
    attempt(1, "#1001", "2026-07-01T08:00:00Z"),
    attempt(2, "#1002", "2026-07-01T08:14:00Z"),
  ]);
  const dups = findDuplicateOrders(c, "duplicate-renewal");
  assert.equal(dups.length, 1);
  assert.equal(dups[0].orderId, "gid://shopify/Order/2");
  assert.equal(dups[0].amountCents, 2900);
});

test("first attempt in a cycle is never flagged", () => {
  const c = contract([
    attempt(1, "#1001", "2026-07-01T08:00:00Z"),
    attempt(2, "#1002", "2026-07-01T08:14:00Z"),
    attempt(3, "#1003", "2026-07-01T09:00:00Z"),
  ]);
  const dups = findDuplicateOrders(c, "duplicate-renewal");
  const orderIds = dups.map((d) => d.orderId);
  assert.equal(orderIds.includes("gid://shopify/Order/1"), false);
  assert.equal(dups.length, 2);
});

test("attempts without an order are ignored", () => {
  const failed = attempt(1, "#1001", "2026-07-01T08:00:00Z");
  failed.order = null;
  const ok = attempt(2, "#1002", "2026-07-01T08:14:00Z");
  const c = contract([failed, ok]);
  assert.deepEqual(findDuplicateOrders(c, "duplicate-renewal"), []);
});

test("already tagged duplicate is skipped", () => {
  const c = contract([
    attempt(1, "#1001", "2026-07-01T08:00:00Z"),
    attempt(2, "#1002", "2026-07-01T08:14:00Z", { tags: ["duplicate-renewal"] }),
  ]);
  assert.deepEqual(findDuplicateOrders(c, "duplicate-renewal"), []);
});

test("different cycles are each allowed one charge", () => {
  const c = contract([
    attempt(1, "#1001", "2026-05-01T08:00:00Z"),
    attempt(2, "#1002", "2026-06-01T08:00:00Z"),
    attempt(3, "#1003", "2026-07-01T08:00:00Z"),
  ]);
  assert.deepEqual(findDuplicateOrders(c, "duplicate-renewal"), []);
});

Case studies

Timeout retry

The billing worker that retried on a slow response

A subscription app billed contracts nightly, and once a week the Shopify API answered slowly enough that the worker's own timeout fired first. The worker read that as a failure and started a new billing attempt with a freshly generated idempotency key, even though the first attempt had already gone through and created an order.

Running the reconciler over the last month of billing attempts made the pattern clear, one contract with two successful attempts for the same cycle five minutes apart. The team tagged the second order on each for review, issued the refunds by hand with full context, and fixed the worker to reuse the same key on any retry so it stopped happening.

Webhook redelivery

The redeployed app that billed on every delivery

An app handled a billing webhook by starting a new billing attempt every time the handler ran, with no memory of whether it had already billed that cycle. A deploy caused Shopify to redeliver a batch of recent webhooks, and every one of them triggered a fresh attempt for a cycle that was already paid.

The dry run report showed dozens of contracts each with three or four successful attempts for the same cycle, all within the same few minutes. After tagging and refunding the extras, the handler was changed to check for an existing attempt before starting a new one, so a redelivery becomes a no-op instead of a new bill.

What good looks like

After this runs on a schedule, a retry is just a retry again, it either recognizes the attempt it already made and stops, or it makes exactly one. Subscribers get billed once per cycle, the reconciler catches anything that slips through from before the fix, and no one has to explain a second charge on a support ticket. Keep the idempotency key stable across retries, that is what makes the whole thing hold.

FAQ

Why was my subscriber billed twice for one renewal?

A renewal job retried a charge after a timeout or a redelivered webhook, and because the retry carried no idempotency key, Shopify had no way to tell it apart from a brand new charge, so it created a second billing attempt and a second order for the same billing cycle.

What is an idempotency key and why does a renewal job need one?

An idempotency key is a value you generate once per intended charge and send with every attempt, including retries, so Shopify can recognize a retry as the same billing attempt instead of a new one. Shopify's own subscriptionBillingAttemptCreate mutation takes this key for exactly that reason.

Is it safe to flag duplicate renewal charges with a script?

Yes, when the script only groups a contract's successful billing attempts by the day they landed on, only ever flags the attempts after the first one in that cycle, skips anything already tagged, and runs in dry run first so you can review the exact list before anything is refunded.

Related field notes

Citations

On the problem:

  1. Shopify Dev: retry safety and rate limits on the Admin GraphQL API, why blind retries can duplicate a write. shopify.dev/docs/api/usage/rate-limits
  2. Shopify Dev: webhooks can be delivered more than once, handlers must expect duplicates. shopify.dev/docs/apps/build/webhooks
  3. Shopify Help Center: how subscription billing cycles and orders are created. help.shopify.com/en/manual/products/purchase-options/subscriptions

On the solution:

  1. Shopify Admin GraphQL: the subscriptionBillingAttemptCreate mutation and its idempotencyKey input. shopify.dev/docs/api/admin-graphql/latest/mutations/subscriptionBillingAttemptCreate
  2. Shopify Admin GraphQL: the SubscriptionContract object and its billingAttempts connection. shopify.dev/docs/api/admin-graphql/latest/objects/SubscriptionContract
  3. Shopify Admin GraphQL: the tagsAdd mutation. shopify.dev/docs/api/admin-graphql/latest/mutations/tagsAdd

Stuck on a tricky one?

If you have a problem in Shopify orders, payments, subscriptions, 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 stop a duplicate charge?

If this saved a subscriber from a second charge or saved you a refund cleanup, 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 Shopify field notes