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.

Python and Node.js Admin GraphQL API Safe by default (dry run)
A close up of a typewriter with a sign that reads contact
Photo by Markus Winkler on Unsplash
The short answer

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.

Attempt queued next billing cycle Customer cancels contract status flips Attempt not reached still queued to run Attempt resolves card is charged Order created on a dead contract
The cancellation changes the contract's status, but a billing attempt already in the queue is not automatically pulled back, so it can still create an order afterward.

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:

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.

The key insight

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.

Scheduled job runs on a timer List cancelled contracts CANCELLED or EXPIRED Read billing attempts createdAt, ready, order Ran after cancelledAt? yes no, skip tagsAdd order flagged for review
The script only tags orders that came from an attempt whose timing outran the cancellation. Everything that billed before the cancel is left untouched.

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_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.

setup (shell)
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
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="billed-after-cancel"
export DRY_RUN="true"   // start safe, change to false to write
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 contracts and to run the tag 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 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.

step3.py
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"]
step3.js
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;
  }
}
4

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.

decide.py
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
decide.js
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;
}
5

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.

apply.py
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"])
apply.js
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));
}
6

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.

Run it safe

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_attempts_after_cancel.py
"""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-attempts-after-cancel.js
/**
 * 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.

test_billing_after_cancel.py
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
billing-after-cancel.test.js
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

Chargeback risk

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.

Retry queue

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.

What good looks like

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:

  1. Shopify Help Center: manage a customer's subscription contract, including cancellation. help.shopify.com/en/manual/products/purchase-options/selling-plans
  2. Shopify Admin GraphQL: the SubscriptionContract object and its status values. shopify.dev/docs/api/admin-graphql/latest/objects/SubscriptionContract
  3. Shopify Admin GraphQL: the SubscriptionBillingAttempt object. shopify.dev/docs/api/admin-graphql/latest/objects/SubscriptionBillingAttempt

On the solution:

  1. Shopify Admin GraphQL: the subscriptionContracts query and its search syntax. shopify.dev/docs/api/admin-graphql/latest/queries/subscriptionContracts
  2. 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 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

Back to all Shopify field notes