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.

Python and Node.js Admin GraphQL API Safe by default (dry run)
A black and white photo of a parking sign
Photo by Erik Mclean on Unsplash
The short answer

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.

Card removed or revoked by bank Billing cycle comes due nothing left to charge NO_PAYMENT_METHOD no decline, no charge Subscription goes quiet
There is no card to decline, so nothing generates the usual failed payment alert. The contract just stops billing.

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:

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.

The key insight

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.

Scheduled job runs on a timer List active contracts status:active, paged Read status and method lastPaymentStatus, revokedAt No valid method? yes no, skip tagsAdd flagged for a prompt
The script only tags contracts that Shopify's own fields say cannot bill. It never touches money, and it never repeats a tag it already added.

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

setup (shell)
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
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="needs-payment-method"
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 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.

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

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.

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

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.

needs_tag.py
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 [])
needs-tag.js
export function needsTag(contract, reviewTag) {
  if (!hasNoValidPaymentMethod(contract)) return false;
  return !(contract.tags || []).includes(reviewTag);
}
6

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.

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

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_contracts_missing_payment_method.py
"""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-contracts-missing-payment-method.js
/**
 * 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.

test_contract_missing_payment_method.py
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
find-contracts-missing-payment-method.test.js
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

Revoked card

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.

Never attached

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.

What good looks like

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:

  1. Shopify Help Center: how subscriptions bill and what happens when a payment method fails. help.shopify.com/en/manual/products/subscriptions
  2. Shopify Help Center: managing a customer's stored payment methods and Shop Pay wallet entries. help.shopify.com/en/manual/payments/shop-pay
  3. Shopify Community: subscription contracts stuck with no payment method after a card is removed. community.shopify.com shopify apis and sdks

On the solution:

  1. Shopify Admin GraphQL: the SubscriptionContract object, including lastPaymentStatus and status. shopify.dev/docs/api/admin-graphql/latest/objects/SubscriptionContract
  2. Shopify Admin GraphQL: the CustomerPaymentMethod object, including revokedAt. shopify.dev/docs/api/admin-graphql/latest/objects/CustomerPaymentMethod
  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 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

Back to all Shopify field notes