Reconciler Subscriptions and billing
Billing runs on a cancelled contract
A customer cancels their subscription, and a day or two later a fresh charge shows up anyway. Support says the contract is cancelled, the customer says they were billed, and both are right. A scheduled billing attempt that was already in flight went ahead and created an order after the contract's status had already moved to cancelled. Here is why Shopify lets that attempt slip through and a small script that finds the orders it created so you can catch them and make it right.
Cancelling a SubscriptionContract stops future billing cycles, but a SubscriptionBillingAttempt that was already queued, or that a retry re-queued, is not automatically cancelled with it. That attempt can still resolve later and create an order. Run a small Python or Node.js script that lists recently cancelled or expired contracts, walks their billing attempts, and tags with tagsAdd any order that came from an attempt created after the contract's cancelledAt timestamp, plus any attempt still pending after that point. Full code, tests, and a dry run guard are below.
The problem in plain words
A subscription contract in Shopify does not bill itself moment to moment. A billing cycle schedules an attempt ahead of time, and that attempt sits queued until Shopify's billing engine picks it up and tries to charge the customer's payment method.
Cancelling the contract changes its status right away, but it does not reach back in time and erase an attempt that is already sitting in that queue. If the timing lines up wrong, the attempt resolves after the cancellation, the charge succeeds, and Shopify creates an order like it would for any other successful billing cycle. The contract says cancelled. The order says paid. Nobody told the attempt to stop.
Why it happens
Shopify's billing engine and the contract's status are two separate things moving on their own schedules. A few common ways stores end up with an order that should not exist:
- A customer cancels minutes before their scheduled billing time, and the attempt was already handed off to run before the cancellation was recorded.
- A failed attempt is retried automatically, the retry gets queued, then the contract is cancelled, and the retry still fires later.
- An app or a staff member cancels the contract directly instead of using the cancellation flow that also cleans up any in-flight attempt.
- A time zone or clock skew between when
cancelledAtis recorded and when the billing engine actually evaluates the queue.
This is a common source of confusion. Support sees a cancelled contract and assumes no more charges are possible, then a chargeback or an angry email says otherwise. Shopify does not expose a way to reach into the billing queue and pull an attempt back once it is in flight, so the practical fix is to catch what slipped through, not to prevent the race at the source. See the citations at the end for the exact docs.
You cannot stop an attempt that already left the queue, so do not try to guess at prevention. Instead, treat the contract's status as the source of truth and compare every billing attempt's timestamp against it after the fact. Any attempt whose createdAt is later than the contract's cancelledAt, or that is still pending past that point, is a candidate that a human needs to look at and likely refund.
The fix, as a flow
We do not touch the live billing engine or refund anything automatically. We add a job that lists cancelled or expired contracts, reads their billing attempts, and tags the order from any attempt that ran too late for review. Everything that ran before the cancellation is left alone.
Build it step by step
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_orders, write_orders, and read_own_subscription_contracts or read_customer_payment_methods scopes needed for subscription contracts in your setup, 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.
pip install requests
export SHOPIFY_SHOP="yourstore.myshopify.com"
export SHOPIFY_ACCESS_TOKEN="shpat_..."
export REVIEW_TAG="billed-after-cancel"
export DRY_RUN="true" # start safe, change to false to write
// Node 18+ has fetch built in, no dependencies needed
export SHOPIFY_SHOP="yourstore.myshopify.com"
export SHOPIFY_ACCESS_TOKEN="shpat_..."
export REVIEW_TAG="billed-after-cancel"
export DRY_RUN="true" // start safe, change to false to write
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 contracts and to run the tag mutation.
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"]
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;
}
List cancelled contracts and their billing attempts
Ask for subscription contracts whose status is cancelled or expired, and read back the fields the decision needs: the contract's status and cancelledAt, and for each billing attempt its createdAt, whether it is still ready, and the order it produced if any. We page through with a cursor so the job handles a large backlog.
CONTRACTS_QUERY = """
query($cursor: String) {
subscriptionContracts(first: 25, after: $cursor,
query: "status:CANCELLED OR status:EXPIRED") {
pageInfo { hasNextPage endCursor }
nodes {
id status cancelledAt
billingAttempts(first: 20) {
nodes {
id createdAt ready
order { id name tags }
}
}
}
}
}"""
def cancelled_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"]
const CONTRACTS_QUERY = `
query($cursor: String) {
subscriptionContracts(first: 25, after: $cursor,
query: "status:CANCELLED OR status:EXPIRED") {
pageInfo { hasNextPage endCursor }
nodes {
id status cancelledAt
billingAttempts(first: 20) {
nodes {
id createdAt ready
order { id name tags }
}
}
}
}
}`;
async function* cancelledContracts() {
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;
}
}
Decide, with one pure function
Keep the decision in its own function that takes a contract and a billing attempt and returns true or false. A pure function like this is easy to read and easy to test, which we do later. The rule is strict on purpose. The contract must be cancelled or expired, it must carry a cancelledAt timestamp, the attempt's createdAt must be later than that timestamp, and the attempt must have either produced an order or still be pending. If any of those is missing, we leave the attempt alone.
CANCELLED_STATUSES = {"CANCELLED", "EXPIRED"}
def attempt_ran_after_cancel(contract, attempt):
if contract.get("status") not in CANCELLED_STATUSES:
return False
cancelled_at = contract.get("cancelledAt")
if not cancelled_at:
return False
created_at = attempt.get("createdAt")
if not created_at:
return False
if created_at <= cancelled_at:
return False
return attempt.get("order") is not None or attempt.get("ready") is False
const CANCELLED_STATUSES = new Set(["CANCELLED", "EXPIRED"]);
export function attemptRanAfterCancel(contract, attempt) {
if (!CANCELLED_STATUSES.has(contract.status)) return false;
const cancelledAt = contract.cancelledAt;
if (!cancelledAt) return false;
const createdAt = attempt.createdAt;
if (!createdAt) return false;
if (createdAt <= cancelledAt) return false;
return attempt.order != null || attempt.ready === false;
}
Tag the order the way a reviewer would
When an attempt is flagged and it has an order attached, call the tagsAdd mutation with the order id and your review tag. This never refunds or cancels anything. It just makes the order visible to whoever handles refunds and customer messages. Always read back userErrors. If Shopify refuses, the error tells you why, and the script should stop on it rather than pretend it worked.
TAGS_ADD = """
mutation($id: ID!, $tags: [String!]!) {
tagsAdd(id: $id, tags: $tags) { node { id } userErrors { field message } }
}"""
def tag_order_for_review(order_id, review_tag):
result = gql(TAGS_ADD, {"id": order_id, "tags": [review_tag]})["tagsAdd"]
if result["userErrors"]:
raise RuntimeError(result["userErrors"])
const TAGS_ADD = `
mutation($id: ID!, $tags: [String!]!) {
tagsAdd(id: $id, tags: $tags) { node { id } userErrors { field message } }
}`;
async function tagOrderForReview(orderId, reviewTag) {
const result = (await gql(TAGS_ADD, { id: orderId, tags: [reviewTag] })).tagsAdd;
if (result.userErrors.length) throw new Error(JSON.stringify(result.userErrors));
}
Wire it together with a dry run guard
The loop ties every piece together. Notice the dry run guard. On the first few runs, leave DRY_RUN on so the script only reports which orders it would tag, plus any attempt still pending after a cancel that has not created an order yet. Read the output, agree with it, then switch it off to let it write. Run it on a schedule that matches how often cancellations happen, for example once an hour.
Always start with DRY_RUN=true, and remember this script only tags. Refunding the customer and cleaning up the subscription is still a decision for a human, since the right outcome depends on your refund policy, not on what the API can technically do.
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 tags orders that a billing attempt created after its contract was already cancelled or expired.
View this code on GitHub Full runnable folder with tests in the shopify-fixes repo.
"""Flag Shopify orders created by a billing attempt that fired after the
subscription contract was already cancelled.
Cancelling a SubscriptionContract stops future billing cycles, but a billing
attempt that was already queued (or that a retry re-queued) can still land and
create an order after the contract's status flips to CANCELLED. The order
looks ordinary, but it was never supposed to exist. This job walks recent
subscription contracts, reads their billing attempts, and tags any attempt
that produced an order (or is still pending) after the contract's cancelledAt
timestamp with a review tag on the resulting order via 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("flag_attempts_after_cancel")
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", "billed-after-cancel")
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
CANCELLED_STATUSES = {"CANCELLED", "EXPIRED"}
CONTRACTS_QUERY = """
query($cursor: String) {
subscriptionContracts(first: 25, after: $cursor,
query: "status:CANCELLED OR status:EXPIRED") {
pageInfo { hasNextPage endCursor }
nodes {
id status cancelledAt
billingAttempts(first: 20) {
nodes {
id createdAt ready
order { id name tags }
}
}
}
}
}"""
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 attempt_ran_after_cancel(contract, attempt):
"""True when a billing attempt fired (or is still pending) after the
contract was cancelled or expired. Pure: takes plain dicts, no I/O.
"""
if contract.get("status") not in CANCELLED_STATUSES:
return False
cancelled_at = contract.get("cancelledAt")
if not cancelled_at:
return False
created_at = attempt.get("createdAt")
if not created_at:
return False
if created_at <= cancelled_at:
return False
return attempt.get("order") is not None or attempt.get("ready") is False
def attempts_needing_review(contract):
"""Yield the billing attempts on a contract that need a review tag."""
for attempt in (contract.get("billingAttempts") or {}).get("nodes", []):
if attempt_ran_after_cancel(contract, attempt):
yield attempt
def tag_order_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 cancelled_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 cancelled_contracts():
for attempt in attempts_needing_review(contract):
order = attempt.get("order")
if order is None:
log.warning(
"Contract %s has a billing attempt %s still pending after cancel.",
contract["id"], attempt["id"],
)
continue
if REVIEW_TAG in (order.get("tags") or []):
continue
log.warning(
"Order %s was created by attempt %s after contract %s was cancelled. %s",
order["name"], attempt["id"], contract["id"],
"would tag" if DRY_RUN else "tagging",
)
if not DRY_RUN:
tag_order_for_review(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()
/**
* Flag Shopify orders created by a billing attempt that fired after the
* subscription contract was already cancelled.
*
* Cancelling a SubscriptionContract stops future billing cycles, but a
* billing attempt that was already queued (or that a retry re-queued) can
* still land and create an order after the contract's status flips to
* CANCELLED. This job walks recent subscription contracts, reads their
* billing attempts, and tags any attempt that produced an order (or is still
* pending) after the contract's cancelledAt timestamp with a review tag on
* the resulting order via tagsAdd. Run on a schedule.
*
* Guide: https://www.allanninal.dev/shopify/billing-runs-on-a-cancelled-contract/
*/
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 || "billed-after-cancel";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const CANCELLED_STATUSES = new Set(["CANCELLED", "EXPIRED"]);
/**
* True when a billing attempt fired (or is still pending) after the
* contract was cancelled or expired. Pure: takes plain objects, no I/O.
*/
export function attemptRanAfterCancel(contract, attempt) {
if (!CANCELLED_STATUSES.has(contract.status)) return false;
const cancelledAt = contract.cancelledAt;
if (!cancelledAt) return false;
const createdAt = attempt.createdAt;
if (!createdAt) return false;
if (createdAt <= cancelledAt) return false;
return attempt.order != null || attempt.ready === false;
}
export function attemptsNeedingReview(contract) {
const nodes = (contract.billingAttempts && contract.billingAttempts.nodes) || [];
return nodes.filter((attempt) => attemptRanAfterCancel(contract, attempt));
}
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,
query: "status:CANCELLED OR status:EXPIRED") {
pageInfo { hasNextPage endCursor }
nodes {
id status cancelledAt
billingAttempts(first: 20) {
nodes {
id createdAt ready
order { id name tags }
}
}
}
}
}`;
const TAGS_ADD = `
mutation($id: ID!, $tags: [String!]!) {
tagsAdd(id: $id, tags: $tags) { node { id } userErrors { field message } }
}`;
async function* cancelledContracts() {
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 tagOrderForReview(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 cancelledContracts()) {
for (const attempt of attemptsNeedingReview(contract)) {
const order = attempt.order;
if (order == null) {
console.warn(`Contract ${contract.id} has a billing attempt ${attempt.id} still pending after cancel.`);
continue;
}
if ((order.tags || []).includes(REVIEW_TAG)) continue;
console.warn(
`Order ${order.name} was created by attempt ${attempt.id} after contract ${contract.id} was cancelled. ${DRY_RUN ? "would tag" : "tagging"}`
);
if (!DRY_RUN) await tagOrderForReview(order.id, 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 decision rule is the part most worth testing, because it decides which orders get flagged as suspect. Because we kept attempt_ran_after_cancel pure, the test needs no network and no Shopify account. It just feeds in plain objects and checks the answer.
from flag_attempts_after_cancel import attempt_ran_after_cancel, attempts_needing_review
def contract(status="CANCELLED", cancelled_at="2026-06-01T00:00:00Z", attempts=None):
return {
"id": "gid://shopify/SubscriptionContract/1",
"status": status,
"cancelledAt": cancelled_at,
"billingAttempts": {"nodes": attempts or []},
}
def attempt(created_at, ready=True, order=None):
return {"id": "gid://shopify/SubscriptionBillingAttempt/1",
"createdAt": created_at, "ready": ready, "order": order}
def test_flags_order_created_after_cancel():
c = contract()
a = attempt("2026-06-02T00:00:00Z", order={"id": "gid://shopify/Order/1", "name": "#1001", "tags": []})
assert attempt_ran_after_cancel(c, a) is True
def test_flags_pending_attempt_after_cancel_even_without_order_yet():
c = contract()
a = attempt("2026-06-02T00:00:00Z", ready=False, order=None)
assert attempt_ran_after_cancel(c, a) is True
def test_ignores_attempt_before_cancel():
c = contract()
a = attempt("2026-05-20T00:00:00Z", order={"id": "gid://shopify/Order/1", "name": "#1001", "tags": []})
assert attempt_ran_after_cancel(c, a) is False
def test_ignores_active_contract():
c = contract(status="ACTIVE")
a = attempt("2026-06-02T00:00:00Z", order={"id": "gid://shopify/Order/1", "name": "#1001", "tags": []})
assert attempt_ran_after_cancel(c, a) is False
import { test } from "node:test";
import assert from "node:assert/strict";
import { attemptRanAfterCancel, attemptsNeedingReview } from "./flag-attempts-after-cancel.js";
const contract = ({ status = "CANCELLED", cancelledAt = "2026-06-01T00:00:00Z", attempts = [] } = {}) => ({
id: "gid://shopify/SubscriptionContract/1",
status,
cancelledAt,
billingAttempts: { nodes: attempts },
});
const attempt = (createdAt, { ready = true, order = null } = {}) => ({
id: "gid://shopify/SubscriptionBillingAttempt/1",
createdAt,
ready,
order,
});
test("flags order created after cancel", () => {
const c = contract();
const a = attempt("2026-06-02T00:00:00Z", { order: { id: "gid://shopify/Order/1", name: "#1001", tags: [] } });
assert.equal(attemptRanAfterCancel(c, a), true);
});
test("flags pending attempt after cancel even without an order yet", () => {
const c = contract();
const a = attempt("2026-06-02T00:00:00Z", { ready: false, order: null });
assert.equal(attemptRanAfterCancel(c, a), true);
});
test("ignores attempt before cancel", () => {
const c = contract();
const a = attempt("2026-05-20T00:00:00Z", { order: { id: "gid://shopify/Order/1", name: "#1001", tags: [] } });
assert.equal(attemptRanAfterCancel(c, a), false);
});
Case studies
The skincare box that billed one day late
A subscription box store had customers cancel through a self-serve portal minutes before their monthly renewal. A handful of contracts each month showed cancelled, yet an order still appeared the next morning, and the customer filed a chargeback before support even saw the order.
Once the team ran this job hourly, every one of those orders got tagged the same day it was created, well before the chargeback window closed. Support now refunds proactively instead of finding out from the bank.
The retry that outlived the contract
A subscription app retried failed payments automatically. A customer's card failed, they cancelled in frustration, and the queued retry fired two days later on the now cancelled contract, charging a card the customer thought they had walked away from.
The script caught it because the retry's billing attempt carried a createdAt well after cancelledAt. The store added a manual review step for every tagged order before it ships, so a stray charge never quietly becomes a fulfilled order.
After this runs on a schedule, a billing attempt that outran a cancellation gets caught the same day, not weeks later in a chargeback. The tag gives support and finance a clear list to work from, refunds go out before customers have to ask, and the contract's cancelled status finally means what everyone assumed it already meant.
FAQ
Why did a customer get billed after they cancelled their subscription?
Cancelling a subscription contract in Shopify stops future billing cycles, but it does not always stop a billing attempt that was already queued or that a retry re-queued. That attempt can still resolve after the cancellation and create a real order, so the customer is charged for a subscription they already ended.
Is it safe to run a script that touches billing attempts and orders?
Yes, when the script only reads subscription contracts and billing attempts and writes a single review tag on the order, and runs in dry run first. It never refunds, cancels, or charges anything on its own, so it cannot make the situation worse, only visible.
What is a SubscriptionBillingAttempt in Shopify?
A SubscriptionBillingAttempt is Shopify's record of one try at charging a subscription contract for a billing cycle. It tracks whether the attempt is still pending with ready, and it links to the order it created once the charge succeeds, which is exactly what this script inspects.
Related field notes
Citations
On the problem:
- Shopify Help Center: manage a customer's subscription contract, including cancellation. help.shopify.com/en/manual/products/purchase-options/selling-plans
- Shopify Admin GraphQL: the SubscriptionContract object and its status values. shopify.dev/docs/api/admin-graphql/latest/objects/SubscriptionContract
- Shopify Admin GraphQL: the SubscriptionBillingAttempt object. shopify.dev/docs/api/admin-graphql/latest/objects/SubscriptionBillingAttempt
On the solution:
- Shopify Admin GraphQL: the
subscriptionContractsquery and its search syntax. shopify.dev/docs/api/admin-graphql/latest/queries/subscriptionContracts - Shopify Admin GraphQL: the
tagsAddmutation. 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.
Did this catch a stray charge?
If this saved you an angry email or a chargeback you did not see coming, you can buy me a coffee. It is the best way to keep these field notes free and growing.
Buy me a coffee on Ko-fi