Diagnostic Subscriptions and billing
Shopify subscription contract has no valid payment method
A customer removed their card, a bank revoked the stored token, or a contract was created and nobody ever attached a way to pay. Shopify will still try to bill the next cycle, and it will still fail, but nothing shouts about it until a customer notices their subscription quietly stopped shipping. Here is why a contract ends up with no valid payment method and a small script that finds the ones that cannot bill and flags them so you can ask the buyer for a new card.
A subscription contract cannot bill when it has no working payment method attached, which Shopify often surfaces as lastPaymentStatus: NO_PAYMENT_METHOD on the SubscriptionContract object, alongside a customerPaymentMethod that is missing or has a revokedAt date. Run a small Python or Node.js script that pages through active contracts, applies one pure decision function to spot the ones that cannot charge, and tags each one with tagsAdd so a human can email the buyer for a new card. Full code, tests, and a dry run guard are below.
The problem in plain words
A subscription only keeps working while it has a live way to charge the customer. Shopify stores that as a customerPaymentMethod attached to the contract, usually a saved card or a Shop Pay wallet entry, and it reuses that same method every billing cycle without asking the customer to type anything again.
That method can stop existing without the merchant doing anything. The customer might remove the card from their account. The card network or Shop Pay can revoke the stored token because of a lost card, a fraud flag, or an expired vault entry. Or a contract created through the Admin API or a migration might never have had a payment method attached in the first place. In every one of those cases, when the billing cycle comes due, Shopify has nothing to charge, so the attempt cannot even reach the point of a decline. It simply cannot happen.
Why it happens
Shopify sets the contract's billing state from whatever payment method is on file. When there is none, the state reflects that directly instead of producing a normal decline. A few common ways stores end up here:
- The customer deleted their saved card or disconnected Shop Pay from their account, with no replacement added.
- The card issuer or wallet provider revoked the stored vault token, for example after a reported lost card, well before the card's printed expiry date.
- A contract was created through the Admin API, a migration from another platform, or a manual import, and a payment method was never attached to it.
- A checkout extension or custom flow created the contract before the customer finished authorizing a payment method, leaving it in a half-set-up state.
This is easy to miss because it does not look like a failed payment. There is no card decline, no retry, and often no email trigger, since most dunning flows are built around a charge attempt that failed, not an attempt that could never be made. Store owners usually find out only when a customer asks why their subscription stopped shipping. See the citations at the end for the exact docs.
A contract with no valid payment method is not a billing failure to retry, it is a setup gap to close. So the safe pattern is not "try to charge it again." It is "find it, and ask the buyer to add a card." We do that by trusting Shopify's own lastPaymentStatus and customerPaymentMethod fields rather than guessing, and we only write a review tag, never a charge.
The fix, as a flow
We do not touch billing at all. We add a job that lists active subscription contracts, checks whether each one is missing a working payment method using one pure decision function, and tags the ones that need attention so a human can send the buyer a link to add a new card. Everything else 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_own_subscription_contracts or read_customers scope needed to read contracts plus write_orders for tagging, then 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="needs-payment-method"
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="needs-payment-method"
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 the active contracts and their payment method
Ask for subscription contracts that are still active, and read back the fields the decision needs: the status, the last billing status, the attached customerPaymentMethod with its revokedAt date, and the tags. We page through with a cursor so the job handles a large customer base.
CONTRACTS_QUERY = """
query($cursor: String) {
subscriptionContracts(first: 50, after: $cursor, query: "status:active") {
pageInfo { hasNextPage endCursor }
nodes {
id
status
lastPaymentStatus
customer { id email }
customerPaymentMethod { id revokedAt instrument { __typename } }
currentPeriodEnd
tags
}
}
}"""
def active_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: 50, after: $cursor, query: "status:active") {
pageInfo { hasNextPage endCursor }
nodes {
id
status
lastPaymentStatus
customer { id email }
customerPaymentMethod { id revokedAt instrument { __typename } }
currentPeriodEnd
tags
}
}
}`;
async function* activeContracts() {
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 returns true or false. A pure function like this is easy to read and easy to test, which we do later. The rule checks three things in order: Shopify's own lastPaymentStatus already saying there is no method, the customerPaymentMethod being missing entirely, or that method carrying a revokedAt date. Cancelled or paused contracts are always left alone, since a contract that is not trying to bill cannot have this problem.
ACTIVE_STATUSES = {"ACTIVE"}
NO_METHOD_PAYMENT_STATUSES = {"NO_PAYMENT_METHOD", "PENDING_SETTING_UP_PAYMENT_METHOD"}
def has_no_valid_payment_method(contract):
if contract.get("status") not in ACTIVE_STATUSES:
return False
method = contract.get("customerPaymentMethod")
if contract.get("lastPaymentStatus") in NO_METHOD_PAYMENT_STATUSES:
return True
if method is None:
return True
if method.get("revokedAt"):
return True
return False
const ACTIVE_STATUSES = new Set(["ACTIVE"]);
const NO_METHOD_PAYMENT_STATUSES = new Set(["NO_PAYMENT_METHOD", "PENDING_SETTING_UP_PAYMENT_METHOD"]);
export function hasNoValidPaymentMethod(contract) {
if (!ACTIVE_STATUSES.has(contract.status)) return false;
const method = contract.customerPaymentMethod;
if (NO_METHOD_PAYMENT_STATUSES.has(contract.lastPaymentStatus)) return true;
if (!method) return true;
if (method.revokedAt) return true;
return false;
}
Skip contracts already flagged
A second small pure function wraps the first one and adds one more rule: do not flag a contract again if your review tag is already on it. That keeps the job idempotent, so running it every hour never spams the same contract with duplicate work downstream.
def needs_tag(contract, review_tag):
if not has_no_valid_payment_method(contract):
return False
return review_tag not in (contract.get("tags") or [])
export function needsTag(contract, reviewTag) {
if (!hasNoValidPaymentMethod(contract)) return false;
return !(contract.tags || []).includes(reviewTag);
}
Tag it for review and wire it together
When a contract needs attention, call tagsAdd with the contract id and your review tag, the same object you would edit by hand from the Admin. Always read back userErrors. The loop ties every piece together and respects a dry run guard. On the first few runs, leave DRY_RUN on so the script only reports which contracts it would tag. Read the output, agree with it, then switch it off to let it write. Run it on a schedule, for example once a day, since payment methods do not usually vanish faster than that.
TAGS_ADD = """
mutation($id: ID!, $tags: [String!]!) {
tagsAdd(id: $id, tags: $tags) { node { id } userErrors { field message } }
}"""
def tag_for_review(contract_id, review_tag):
result = gql(TAGS_ADD, {"id": contract_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 tagForReview(contractId, reviewTag) {
const result = (await gql(TAGS_ADD, { id: contractId, tags: [reviewTag] })).tagsAdd;
if (result.userErrors.length) throw new Error(JSON.stringify(result.userErrors));
}
Always start with DRY_RUN=true. This script only ever adds a review tag, it never charges a card or touches billing, so the worst case of a mistake is an extra tag, not a wrong charge. Still, confirm the list before you turn writes on.
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 contracts that already have no valid payment method and skips anything already flagged.
View this code on GitHub Full runnable folder with tests in the shopify-fixes repo.
"""Find active Shopify subscription contracts that have no valid payment method.
A contract can end up with lastPaymentStatus of NO_PAYMENT_METHOD when the
customer's card was removed, the vault entry was revoked by the customer's
bank, or the contract was created without one attached. Shopify will keep
trying to bill on schedule and keep failing silently unless someone notices.
This job pages through active contracts, applies a pure decision function to
flag the ones that cannot bill, and tags them for review with tagsAdd so a
human can email the buyer for a new card. It never touches billing or money
itself. 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_contracts_missing_payment_method")
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", "needs-payment-method")
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
ACTIVE_STATUSES = {"ACTIVE"}
NO_METHOD_PAYMENT_STATUSES = {"NO_PAYMENT_METHOD", "PENDING_SETTING_UP_PAYMENT_METHOD"}
CONTRACTS_QUERY = """
query($cursor: String) {
subscriptionContracts(first: 50, after: $cursor, query: "status:active") {
pageInfo { hasNextPage endCursor }
nodes {
id
status
lastPaymentStatus
customer { id email }
customerPaymentMethod { id revokedAt instrument { __typename } }
currentPeriodEnd
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 has_no_valid_payment_method(contract):
"""True when an active contract cannot bill because it has no usable payment method.
A contract cannot bill when any of these hold:
- Shopify's own lastPaymentStatus already says there is no payment method
- the payment method was revoked (the bank or the customer pulled it)
- the contract has no payment method attached at all
Cancelled, paused, or already-flagged contracts are left alone.
"""
if contract.get("status") not in ACTIVE_STATUSES:
return False
method = contract.get("customerPaymentMethod")
if contract.get("lastPaymentStatus") in NO_METHOD_PAYMENT_STATUSES:
return True
if method is None:
return True
if method.get("revokedAt"):
return True
return False
def needs_tag(contract, review_tag):
if not has_no_valid_payment_method(contract):
return False
return review_tag not in (contract.get("tags") or [])
def tag_for_review(contract_id, review_tag):
result = gql(TAGS_ADD, {"id": contract_id, "tags": [review_tag]})["tagsAdd"]
if result["userErrors"]:
raise RuntimeError(result["userErrors"])
def active_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 active_contracts():
if not needs_tag(contract, REVIEW_TAG):
continue
email = (contract.get("customer") or {}).get("email", "unknown")
log.warning("Contract %s (%s) has no valid payment method. %s",
contract["id"], email, "would tag" if DRY_RUN else "tagging")
if not DRY_RUN:
tag_for_review(contract["id"], REVIEW_TAG)
flagged += 1
log.info("Done. %d contract(s) %s.", flagged, "to tag" if DRY_RUN else "tagged")
if __name__ == "__main__":
run()
/**
* Find active Shopify subscription contracts that have no valid payment method.
*
* A contract can end up with lastPaymentStatus of NO_PAYMENT_METHOD when the
* customer's card was removed, the vault entry was revoked by the customer's
* bank, or the contract was created without one attached. Shopify will keep
* trying to bill on schedule and keep failing silently unless someone notices.
* This job pages through active contracts, applies a pure decision function to
* flag the ones that cannot bill, and tags them for review with tagsAdd so a
* human can email the buyer for a new card. It never touches billing or money
* itself. Run on a schedule.
*/
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 || "needs-payment-method";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const ACTIVE_STATUSES = new Set(["ACTIVE"]);
const NO_METHOD_PAYMENT_STATUSES = new Set(["NO_PAYMENT_METHOD", "PENDING_SETTING_UP_PAYMENT_METHOD"]);
export function hasNoValidPaymentMethod(contract) {
if (!ACTIVE_STATUSES.has(contract.status)) return false;
const method = contract.customerPaymentMethod;
if (NO_METHOD_PAYMENT_STATUSES.has(contract.lastPaymentStatus)) return true;
if (!method) return true;
if (method.revokedAt) return true;
return false;
}
export function needsTag(contract, reviewTag) {
if (!hasNoValidPaymentMethod(contract)) return false;
return !(contract.tags || []).includes(reviewTag);
}
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: 50, after: $cursor, query: "status:active") {
pageInfo { hasNextPage endCursor }
nodes {
id
status
lastPaymentStatus
customer { id email }
customerPaymentMethod { id revokedAt instrument { __typename } }
currentPeriodEnd
tags
}
}
}`;
const TAGS_ADD = `
mutation($id: ID!, $tags: [String!]!) {
tagsAdd(id: $id, tags: $tags) { node { id } userErrors { field message } }
}`;
async function* activeContracts() {
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(contractId, reviewTag) {
const result = (await gql(TAGS_ADD, { id: contractId, 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 activeContracts()) {
if (!needsTag(contract, REVIEW_TAG)) continue;
const email = contract.customer?.email || "unknown";
console.warn(`Contract ${contract.id} (${email}) has no valid payment method. ${DRY_RUN ? "would tag" : "tagging"}`);
if (!DRY_RUN) await tagForReview(contract.id, REVIEW_TAG);
flagged++;
}
console.log(`Done. ${flagged} contract(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 customers get asked for a new card. Because we kept has_no_valid_payment_method and needs_tag pure, the test needs no network and no Shopify account. It just feeds in plain objects and checks the answer.
from find_contracts_missing_payment_method import (
has_no_valid_payment_method,
needs_tag,
)
def contract(**over):
base = {
"status": "ACTIVE",
"lastPaymentStatus": "PENDING",
"customerPaymentMethod": {"id": "gid://shopify/CustomerPaymentMethod/1", "revokedAt": None},
"tags": [],
}
base.update(over)
return base
def test_flags_when_last_payment_status_says_no_method():
c = contract(lastPaymentStatus="NO_PAYMENT_METHOD")
assert has_no_valid_payment_method(c) is True
def test_flags_when_method_is_missing():
c = contract(customerPaymentMethod=None)
assert has_no_valid_payment_method(c) is True
def test_flags_when_method_was_revoked():
c = contract(customerPaymentMethod={"id": "gid://shopify/CustomerPaymentMethod/1", "revokedAt": "2026-06-01T00:00:00Z"})
assert has_no_valid_payment_method(c) is True
def test_ok_when_method_present_and_not_revoked():
c = contract()
assert has_no_valid_payment_method(c) is False
def test_ignores_cancelled_contracts():
c = contract(status="CANCELLED", customerPaymentMethod=None)
assert has_no_valid_payment_method(c) is False
def test_needs_tag_false_when_already_tagged():
c = contract(customerPaymentMethod=None, tags=["needs-payment-method"])
assert needs_tag(c, "needs-payment-method") is False
import { test } from "node:test";
import assert from "node:assert/strict";
import { hasNoValidPaymentMethod, needsTag } from "./find-contracts-missing-payment-method.js";
const contract = (over = {}) => ({
status: "ACTIVE",
lastPaymentStatus: "PENDING",
customerPaymentMethod: { id: "gid://shopify/CustomerPaymentMethod/1", revokedAt: null },
tags: [],
...over,
});
test("flags when lastPaymentStatus says no method", () => {
assert.equal(hasNoValidPaymentMethod(contract({ lastPaymentStatus: "NO_PAYMENT_METHOD" })), true);
});
test("flags when method is missing", () => {
assert.equal(hasNoValidPaymentMethod(contract({ customerPaymentMethod: null })), true);
});
test("flags when method was revoked", () => {
assert.equal(
hasNoValidPaymentMethod(contract({ customerPaymentMethod: { id: "gid://shopify/CustomerPaymentMethod/1", revokedAt: "2026-06-01T00:00:00Z" } })),
true
);
});
test("ok when method present and not revoked", () => {
assert.equal(hasNoValidPaymentMethod(contract()), false);
});
test("needsTag false when already tagged", () => {
assert.equal(needsTag(contract({ customerPaymentMethod: null, tags: ["needs-payment-method"] }), "needs-payment-method"), false);
});
Case studies
The coffee club nobody warned
A monthly coffee subscription had a batch of customers whose bank reissued cards after a regional fraud alert. The old vault tokens were revoked all at once, but there was no failed charge to trigger the usual dunning email, so those subscriptions just stopped renewing.
The store now runs this job nightly. It caught the whole batch by their revokedAt date the same day, tagged them, and support sent a short email with a link to add a new card before anyone even noticed a missed shipment.
Contracts created by a migration script
A merchant moved from another subscription app and used the Admin API to recreate existing subscribers as SubscriptionContract records. A handful of contracts were created successfully but the payment method attach step failed partway through the import and nobody noticed.
Running the script in dry run surfaced exactly those contracts by their missing customerPaymentMethod, days before their first bill was due. The team reached out proactively instead of after a failed renewal.
After this runs on a schedule, a contract with no valid payment method gets caught within a day instead of surfacing as a customer complaint weeks later. Nobody's card is charged by the script, nothing about billing changes on its own, and the only output is a short list of buyers worth a friendly email asking them to add a new card.
FAQ
Why does a Shopify subscription contract have no valid payment method?
Either the customer removed the card from their account, the card issuer or Shop Pay revoked the stored vault entry, or the contract was created without a payment method ever being attached. In every case Shopify has nothing left to charge, so it cannot bill the next cycle on its own.
Is it safe to detect this with a script instead of waiting for a failed charge?
Yes, when the script only reads contract and payment method fields, never charges a card itself, and simply tags the contracts that already have no valid payment method so a human can prompt the buyer. Running in dry run first lets you confirm the list before any tag is written.
What does lastPaymentStatus of NO_PAYMENT_METHOD actually mean?
It is a field on the SubscriptionContract object that Shopify sets when its most recent billing attempt could not find a payment method to charge. Checking it, together with whether customerPaymentMethod is missing or revoked, is the most direct way to know a contract cannot bill without waiting for the next failed attempt.
Related field notes
Citations
On the problem:
- Shopify Help Center: how subscriptions bill and what happens when a payment method fails. help.shopify.com/en/manual/products/subscriptions
- Shopify Help Center: managing a customer's stored payment methods and Shop Pay wallet entries. help.shopify.com/en/manual/payments/shop-pay
- Shopify Community: subscription contracts stuck with no payment method after a card is removed. community.shopify.com shopify apis and sdks
On the solution:
- Shopify Admin GraphQL: the
SubscriptionContractobject, includinglastPaymentStatusandstatus. shopify.dev/docs/api/admin-graphql/latest/objects/SubscriptionContract - Shopify Admin GraphQL: the
CustomerPaymentMethodobject, includingrevokedAt. shopify.dev/docs/api/admin-graphql/latest/objects/CustomerPaymentMethod - 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 broken contract before your customer did?
If this saved a subscriber relationship or a quiet revenue leak, 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