Reconciler Subscriptions and billing
Shopify subscription renewals that fail on an expired card
A subscriber has been happy for a year. Their card quietly expires. On the next renewal the charge fails, the subscription goes past due, and unless your dunning saves it, they churn without ever meaning to leave. The frustrating part is that this one is predictable. You can see the expiry coming. Here is why renewals fail on an old card and a small job that emails those customers to update it before the renewal date.
A renewal fails when the card on the subscription contract has expired or the payment method was revoked. Run a small Python or Node.js job that walks the active contracts, checks each customer payment method for a revoked flag or a card expiry that is now in the past, and sends the built-in update email with customerPaymentMethodSendUpdateEmail. The customer fixes it before the renewal, so it never fails. Full code, tests, and a dry run guard are below.
The problem in plain words
A subscription stores a payment method with the card details, including the month and year it expires. Every billing cycle, Shopify tries to charge that card. As long as it is valid, the renewal goes through in the background and the customer never thinks about it.
Cards do not last forever. When the expiry date passes, or the customer removes the card, the stored method is no longer chargeable. The next renewal attempt fails. Now the subscription is past due, and you are relying on retries and dunning emails to recover a customer who did nothing wrong and may not notice the emails.
Why it happens
Failed renewals have a few causes, but the card is the big one, and the most preventable. Common cases:
- The card simply reached its printed expiry date and the customer has not entered the new one.
- The customer removed or replaced the payment method, so the one on the contract was revoked.
- A bank reissued the card with a new number after a breach, leaving the old one dead.
- Dunning waits for the failure before it emails, so the first message goes out after the subscription is already past due.
The key point is timing. The expiry date is known ahead of time, so you do not have to wait for the failure. You can reach out while the subscription is still healthy and let the customer fix it in a minute. See the citations at the end for the docs on payment methods and the update email.
A failed renewal is a lagging signal. An expired card is a leading one. Instead of recovering churn after a charge fails, read the expiry that is already on the contract and prompt the customer before the renewal. The best dunning is the message you send before anything breaks.
The fix, as a flow
We do not change your billing schedule or touch card details. We add a job that reads each active subscription contract and its saved payment method. If the method was revoked, or the card expiry is now in the past, we send Shopify's own update email, which gives the customer a secure link to enter a fresh card.
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 scopes to read customers and manage subscription contracts, such as read_customers and read_own_subscription_contracts, and install it for an Admin API access token that starts with shpat_. Keep the token and shop domain in environment variables, never in the file.
pip install requests
export SHOPIFY_SHOP="yourstore.myshopify.com"
export SHOPIFY_ACCESS_TOKEN="shpat_..."
export DRY_RUN="true" # start safe, change to false to send emails
// Node 18+ has fetch built in, no dependencies needed
export SHOPIFY_SHOP="yourstore.myshopify.com"
export SHOPIFY_ACCESS_TOKEN="shpat_..."
export DRY_RUN="true" // start safe, change to false to send emails
List active contracts and their payment method
Ask for the subscription contracts and, for each, the customer payment method: its id, whether it was revoked, and the card expiry month and year. The expiry lives on the credit card instrument. We page through with a cursor so the job handles every contract.
CONTRACTS_QUERY = """
query($cursor: String) {
subscriptionContracts(first: 50, after: $cursor) {
pageInfo { hasNextPage endCursor }
nodes {
id status
customerPaymentMethod {
id revokedAt
instrument { ... on CustomerCreditCard { expiryMonth expiryYear } }
}
}
}
}"""
def contracts():
cursor = None
while True:
data = gql(CONTRACTS_QUERY, {"cursor": cursor})["subscriptionContracts"]
for node in data["nodes"]:
yield node
if not data["pageInfo"]["hasNextPage"]:
return
cursor = data["pageInfo"]["endCursor"]
const CONTRACTS_QUERY = `
query($cursor: String) {
subscriptionContracts(first: 50, after: $cursor) {
pageInfo { hasNextPage endCursor }
nodes {
id status
customerPaymentMethod {
id revokedAt
instrument { ... on CustomerCreditCard { expiryMonth expiryYear } }
}
}
}
}`;
async function* contracts() {
let cursor = null;
while (true) {
const data = (await gql(CONTRACTS_QUERY, { cursor })).subscriptionContracts;
for (const node of data.nodes) yield node;
if (!data.pageInfo.hasNextPage) return;
cursor = data.pageInfo.endCursor;
}
}
Decide, with pure functions
Keep the logic in small functions. One says whether a card is expired given the current month and year. The other says whether a contract needs an email: it must be active, and its method must be revoked or its card expired. Passing the date in keeps the functions pure, so we can test every case without waiting for a real month to pass.
def card_expired(month, year, now_year, now_month):
if month is None or year is None:
return False
return (year, month) < (now_year, now_month)
def needs_update(contract, now_year, now_month):
if contract.get("status") != "ACTIVE":
return False
pm = contract.get("customerPaymentMethod")
if pm is None:
return False
if pm.get("revokedAt"):
return True
card = pm.get("instrument") or {}
return card_expired(card.get("expiryMonth"), card.get("expiryYear"), now_year, now_month)
export function cardExpired(month, year, nowYear, nowMonth) {
if (month == null || year == null) return false;
return year < nowYear || (year === nowYear && month < nowMonth);
}
export function needsUpdate(contract, nowYear, nowMonth) {
if (contract.status !== "ACTIVE") return false;
const pm = contract.customerPaymentMethod;
if (!pm) return false;
if (pm.revokedAt) return true;
const card = pm.instrument || {};
return cardExpired(card.expiryMonth, card.expiryYear, nowYear, nowMonth);
}
Send the update-card email
When a contract needs it, call customerPaymentMethodSendUpdateEmail with the payment method id. Shopify emails the customer a secure link to enter a new card, and the method updates on their subscription. You never see or handle the card yourself. Always read back userErrors.
SEND_EMAIL = """
mutation($id: ID!) {
customerPaymentMethodSendUpdateEmail(customerPaymentMethodId: $id) {
customer { id }
userErrors { field message }
}
}"""
def send_update_email(payment_method_id):
result = gql(SEND_EMAIL, {"id": payment_method_id})["customerPaymentMethodSendUpdateEmail"]
if result["userErrors"]:
raise RuntimeError(result["userErrors"])
const SEND_EMAIL = `
mutation($id: ID!) {
customerPaymentMethodSendUpdateEmail(customerPaymentMethodId: $id) {
customer { id }
userErrors { field message }
}
}`;
async function sendUpdateEmail(paymentMethodId) {
const result = (await gql(SEND_EMAIL, { id: paymentMethodId })).customerPaymentMethodSendUpdateEmail;
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 and dedupes by payment method, so a customer with several subscriptions gets one email, not five. On the first run, leave DRY_RUN on so it only reports who it would email. Read the list, then switch it off. A weekly run catches cards before their renewal comes around.
Start with DRY_RUN=true, and dedupe by payment method so nobody is emailed twice. This job only sends Shopify's own update email, so the worst case is one extra reminder. Run it weekly rather than daily so customers are not nudged too often.
The full code
Here is the complete job in one file for each language. It reads settings from the environment, logs what it does, respects the dry run flag, dedupes by payment method, and is safe to run again and again.
View this code on GitHub Full runnable folder with tests in the shopify-fixes repo.
"""Email subscribers whose saved card is expired or revoked, before the renewal fails.
Run on a schedule. Safe to run again and again.
"""
import os
import datetime
import logging
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("notify_expired_cards")
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"
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
CONTRACTS_QUERY = """
query($cursor: String) {
subscriptionContracts(first: 50, after: $cursor) {
pageInfo { hasNextPage endCursor }
nodes {
id status
customerPaymentMethod {
id revokedAt
instrument { ... on CustomerCreditCard { expiryMonth expiryYear } }
}
}
}
}"""
SEND_EMAIL = """
mutation($id: ID!) {
customerPaymentMethodSendUpdateEmail(customerPaymentMethodId: $id) {
customer { 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 card_expired(month, year, now_year, now_month):
if month is None or year is None:
return False
return (year, month) < (now_year, now_month)
def needs_update(contract, now_year, now_month):
if contract.get("status") != "ACTIVE":
return False
pm = contract.get("customerPaymentMethod")
if pm is None:
return False
if pm.get("revokedAt"):
return True
card = pm.get("instrument") or {}
return card_expired(card.get("expiryMonth"), card.get("expiryYear"), now_year, now_month)
def send_update_email(payment_method_id):
result = gql(SEND_EMAIL, {"id": payment_method_id})["customerPaymentMethodSendUpdateEmail"]
if result["userErrors"]:
raise RuntimeError(result["userErrors"])
def contracts():
cursor = None
while True:
data = gql(CONTRACTS_QUERY, {"cursor": cursor})["subscriptionContracts"]
for node in data["nodes"]:
yield node
if not data["pageInfo"]["hasNextPage"]:
return
cursor = data["pageInfo"]["endCursor"]
def run():
today = datetime.date.today()
notified = 0
seen = set()
for contract in contracts():
if not needs_update(contract, today.year, today.month):
continue
pm_id = contract["customerPaymentMethod"]["id"]
if pm_id in seen:
continue
seen.add(pm_id)
log.warning("Contract %s has an expired or revoked card. %s",
contract["id"], "would email" if DRY_RUN else "emailing")
if not DRY_RUN:
send_update_email(pm_id)
notified += 1
log.info("Done. %d customer(s) %s.", notified, "to email" if DRY_RUN else "emailed")
if __name__ == "__main__":
run()
/**
* Email subscribers whose saved card is expired or revoked, before the renewal fails.
* Run on a schedule. Safe to run again and again.
*/
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 DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
export function cardExpired(month, year, nowYear, nowMonth) {
if (month == null || year == null) return false;
return year < nowYear || (year === nowYear && month < nowMonth);
}
export function needsUpdate(contract, nowYear, nowMonth) {
if (contract.status !== "ACTIVE") return false;
const pm = contract.customerPaymentMethod;
if (!pm) return false;
if (pm.revokedAt) return true;
const card = pm.instrument || {};
return cardExpired(card.expiryMonth, card.expiryYear, nowYear, nowMonth);
}
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) {
pageInfo { hasNextPage endCursor }
nodes {
id status
customerPaymentMethod {
id revokedAt
instrument { ... on CustomerCreditCard { expiryMonth expiryYear } }
}
}
}
}`;
const SEND_EMAIL = `
mutation($id: ID!) {
customerPaymentMethodSendUpdateEmail(customerPaymentMethodId: $id) {
customer { id }
userErrors { field message }
}
}`;
async function* contracts() {
let cursor = null;
while (true) {
const data = (await gql(CONTRACTS_QUERY, { cursor })).subscriptionContracts;
for (const node of data.nodes) yield node;
if (!data.pageInfo.hasNextPage) return;
cursor = data.pageInfo.endCursor;
}
}
async function sendUpdateEmail(paymentMethodId) {
const result = (await gql(SEND_EMAIL, { id: paymentMethodId })).customerPaymentMethodSendUpdateEmail;
if (result.userErrors.length) throw new Error(JSON.stringify(result.userErrors));
}
export async function run() {
const now = new Date();
const nowYear = now.getUTCFullYear();
const nowMonth = now.getUTCMonth() + 1;
let notified = 0;
const seen = new Set();
for await (const contract of contracts()) {
if (!needsUpdate(contract, nowYear, nowMonth)) continue;
const pmId = contract.customerPaymentMethod.id;
if (seen.has(pmId)) continue;
seen.add(pmId);
console.warn(`Contract ${contract.id} has an expired or revoked card. ${DRY_RUN ? "would email" : "emailing"}`);
if (!DRY_RUN) await sendUpdateEmail(pmId);
notified++;
}
console.log(`Done. ${notified} customer(s) ${DRY_RUN ? "to email" : "emailed"}.`);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The expiry and eligibility rules are the parts most worth testing, because they decide who gets an email. Because both are pure and take the date as input, the tests need no network and no waiting for a month to pass. They just feed in plain contracts and a date, and check the answer.
from notify_expired_cards import card_expired, needs_update
def contract(**over):
base = {
"status": "ACTIVE",
"customerPaymentMethod": {
"id": "gid://shopify/CustomerPaymentMethod/1",
"revokedAt": None,
"instrument": {"expiryMonth": 1, "expiryYear": 2025},
},
}
base.update(over)
return base
def test_card_expired_before_this_month():
assert card_expired(1, 2025, 2026, 7) is True
def test_card_not_expired_this_month():
assert card_expired(7, 2026, 2026, 7) is False
def test_needs_update_for_expired_card():
assert needs_update(contract(), 2026, 7) is True
def test_needs_update_for_revoked_method():
c = contract(customerPaymentMethod={"id": "pm", "revokedAt": "2026-01-01T00:00:00Z",
"instrument": {"expiryMonth": 1, "expiryYear": 2099}})
assert needs_update(c, 2026, 7) is True
def test_no_update_when_card_valid():
c = contract(customerPaymentMethod={"id": "pm", "revokedAt": None,
"instrument": {"expiryMonth": 12, "expiryYear": 2099}})
assert needs_update(c, 2026, 7) is False
def test_no_update_when_not_active():
assert needs_update(contract(status="PAUSED"), 2026, 7) is False
import { test } from "node:test";
import assert from "node:assert/strict";
import { cardExpired, needsUpdate } from "./notify-expired-cards.js";
const contract = (over = {}) => ({
status: "ACTIVE",
customerPaymentMethod: {
id: "gid://shopify/CustomerPaymentMethod/1",
revokedAt: null,
instrument: { expiryMonth: 1, expiryYear: 2025 },
},
...over,
});
test("card expired before this month", () => {
assert.equal(cardExpired(1, 2025, 2026, 7), true);
});
test("card not expired this month", () => {
assert.equal(cardExpired(7, 2026, 2026, 7), false);
});
test("needsUpdate for expired card", () => {
assert.equal(needsUpdate(contract(), 2026, 7), true);
});
test("needsUpdate for revoked method", () => {
const c = contract({ customerPaymentMethod: { id: "pm", revokedAt: "2026-01-01T00:00:00Z", instrument: { expiryMonth: 1, expiryYear: 2099 } } });
assert.equal(needsUpdate(c, 2026, 7), true);
});
test("no update when card valid", () => {
const c = contract({ customerPaymentMethod: { id: "pm", revokedAt: null, instrument: { expiryMonth: 12, expiryYear: 2099 } } });
assert.equal(needsUpdate(c, 2026, 7), false);
});
test("no update when not active", () => {
assert.equal(needsUpdate(contract({ status: "PAUSED" }), 2026, 7), false);
});
Case studies
The subscribers who left without meaning to
A coffee subscription noticed a steady trickle of cancellations that were really failed renewals. Loyal customers with expired cards hit a failed charge, got a dunning email they missed, and lapsed. The team was recovering churn instead of preventing it.
The weekly job now emails anyone whose card is expired before their next box. A large share update their card the same day, and the quiet churn from dead cards mostly went away.
The bank breach that hit a whole cohort
After a card network breach, a batch of subscribers got reissued cards with new numbers, so their old saved methods were revoked all at once. Left alone, that whole cohort would have failed on their next renewal in the same week.
The team ran the job in dry run, saw the cohort clearly, then let it email all of them at once. Most updated their card before the renewal, and a wave of failures never happened.
After this runs on a schedule, an expiring card is a friendly reminder, not a failed charge and a lost customer. You prevent churn instead of chasing it, your renewal success rate climbs, and dunning becomes the safety net it was meant to be rather than the first line of defense. The best save is the one that happens before the failure.
FAQ
Why do my Shopify subscription renewals fail?
The most common reason is the saved card. When a card on a subscription contract expires or the customer removes the payment method, the renewal billing attempt has nothing valid to charge and fails. Emailing those customers to update their card before the renewal date prevents the failure.
How do I find subscriptions with an expired card in Shopify?
Read each active subscription contract and its customer payment method, including whether it was revoked and the card expiry month and year. A card is expired once the current month is past its expiry. Those contracts are the ones at risk on their next renewal.
How do I ask a customer to update their card?
Use the customerPaymentMethodSendUpdateEmail mutation with the customer payment method id. Shopify sends the customer a secure link to enter a new card, which updates the method on their subscription without you handling card details.
Related field notes
Citations
On the problem:
- Shopify Help Center: managing subscriptions and failed billing attempts. help.shopify.com/en/manual/products/subscriptions/managing-subscriptions
- Shopify dev docs: subscription billing attempts and why they fail. shopify.dev subscriptions billing cycles
- Shopify Community: subscription renewals failing on expired cards. community.shopify.com subscriptions
On the solution:
- Shopify Admin GraphQL: the
customerPaymentMethodSendUpdateEmailmutation. shopify.dev customerPaymentMethodSendUpdateEmail - Shopify Admin GraphQL: the SubscriptionContract and its customerPaymentMethod. shopify.dev/docs/api/admin-graphql/latest/objects/SubscriptionContract
- Shopify Admin GraphQL: the CustomerCreditCard instrument with expiryMonth and expiryYear. shopify.dev/docs/api/admin-graphql/latest/objects/CustomerCreditCard
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 save a subscriber?
If this kept a renewal from failing on a dead card, 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