Repair Orders, payments, and webhooks
Shopify webhook subscription auto-deleted after too many failed deliveries
Your endpoint went down for a while, or it started returning errors, and Shopify quietly gave up on it. The webhook subscription is gone, no email arrived to say so, and the app keeps running while it silently stops hearing about orders, fulfillments, or refunds. Here is why Shopify removes a subscription on its own and a small script that finds the gap and recreates it safely.
Shopify automatically deletes a webhook subscription once your endpoint keeps failing to accept deliveries, and it does not notify you when this happens. Run a small Python or Node.js script that lists your shop's actual subscriptions with webhookSubscriptions, compares them against the topic and endpoint pairs your app is supposed to have, and calls webhookSubscriptionCreate for any pair that is missing. Full code, tests, and a dry run guard are below.
The problem in plain words
A webhook subscription tells Shopify to push an event, like an order being paid, to a URL you control. As long as your endpoint answers those deliveries quickly and with a success status, the subscription keeps working without you thinking about it.
But Shopify is not patient forever. If your endpoint is down during a deploy, behind a broken load balancer, or returns errors because of a bug, Shopify keeps retrying for a while and then stops. Past a point, it removes the subscription entirely rather than keep retrying it forever. There is no email, no admin banner, nothing in your logs unless you happen to be watching for it. The app just goes quiet on that topic, and the first sign is usually a customer asking why their order never triggered the email, the fulfillment, or the sync you built the webhook for.
Why it happens
Shopify sets a hard limit on how much a broken endpoint can cost its delivery system. A few common ways stores end up here:
- A deploy or a host outage takes the receiving endpoint offline for hours, and every delivery attempt during that window fails.
- A code change on the endpoint starts returning a 4xx or 5xx status for a topic it used to accept, so Shopify treats every one of those deliveries as a failure too.
- A TLS certificate expires or a firewall rule changes, so the connection itself fails before Shopify's request is even answered.
- The subscription was created by an old version of the app, or by a script that ran once, and nothing ever re-checks that it is still registered.
This is a common source of confusion. Teams assume that once a webhook is set up, it stays set up, so the removal is never on anyone's checklist. Shopify does expose the current list of subscriptions through the Admin API, but nobody polls it until something downstream breaks. See the citations at the end for the exact docs on delivery failures and the query and mutation involved.
You cannot stop Shopify from deleting a subscription that keeps failing, and you should not want to, since a subscription with no working endpoint is just noise for Shopify's queue. What you can do is treat your required subscriptions as a small piece of configuration, check it against reality on a schedule, and recreate anything that has drifted. That turns an invisible failure into a five minute fix the next time it runs.
The fix, as a flow
We do not change how Shopify decides to delete a subscription. We add a job that declares the topic and endpoint pairs the app depends on, reads back what Shopify actually has registered with webhookSubscriptions, and calls webhookSubscriptionCreate only for the pairs that are missing. A subscription that already exists is never touched, so the job is safe to run as often as you like.
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_webhooks and write_webhooks scopes and install it to get an Admin API access token that starts with shpat_. Keep the token and the shop domain in environment variables, never in the file.
pip install requests
export SHOPIFY_SHOP="yourstore.myshopify.com"
export SHOPIFY_ACCESS_TOKEN="shpat_..."
export REQUIRED_WEBHOOKS='[{"topic":"ORDERS_PAID","uri":"https://app.example.com/webhooks/orders-paid"}]'
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 REQUIRED_WEBHOOKS='[{"topic":"ORDERS_PAID","uri":"https://app.example.com/webhooks/orders-paid"}]'
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 subscriptions and to run the create 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 subscriptions Shopify actually has
Ask for every webhook subscription registered for the shop, and read back the fields the decision needs: the topic, the destination uri, and the format. We page through with a cursor so the check works even on a shop with dozens of subscriptions across several apps or integrations.
SUBSCRIPTIONS_QUERY = """
query($cursor: String) {
webhookSubscriptions(first: 50, after: $cursor) {
pageInfo { hasNextPage endCursor }
nodes { id topic uri format createdAt }
}
}"""
def existing_subscriptions():
cursor = None
while True:
data = gql(SUBSCRIPTIONS_QUERY, {"cursor": cursor})["webhookSubscriptions"]
for node in data["nodes"]:
yield node
if not data["pageInfo"]["hasNextPage"]:
return
cursor = data["pageInfo"]["endCursor"]
const SUBSCRIPTIONS_QUERY = `
query($cursor: String) {
webhookSubscriptions(first: 50, after: $cursor) {
pageInfo { hasNextPage endCursor }
nodes { id topic uri format createdAt }
}
}`;
async function* existingSubscriptions() {
let cursor = null;
while (true) {
const data = (await gql(SUBSCRIPTIONS_QUERY, { cursor })).webhookSubscriptions;
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 what Shopify has and what the app requires, and returns the gaps. A pure function like this is easy to read and easy to test, which we do later. It compares each required topic and uri pair against a set built from the actual subscriptions, comparing the topic case-insensitively since Shopify always returns it in upper snake case. Anything with a match is left alone. Anything without one is a gap.
def missing_subscriptions(existing, required):
live_pairs = {
((item.get("topic") or "").upper(), item.get("uri"))
for item in existing
}
gaps = []
for need in required:
key = (need.get("topic", "").upper(), need.get("uri"))
if key in live_pairs:
continue
gaps.append(need)
return gaps
export function missingSubscriptions(existing, required) {
const livePairs = new Set(
existing.map((item) => `${(item.topic || "").toUpperCase()}::${item.uri}`)
);
return required.filter((need) => {
const key = `${(need.topic || "").toUpperCase()}::${need.uri}`;
return !livePairs.has(key);
});
}
Recreate a subscription the same way the app would on first install
When a required pair is missing, call webhookSubscriptionCreate with the topic and the destination uri, sending the payload as JSON. Shopify registers the subscription and starts delivering that topic again. Always read back userErrors. If Shopify refuses, the error tells you why, and the script should stop on it rather than pretend it worked.
CREATE_MUTATION = """
mutation($topic: WebhookSubscriptionTopic!, $uri: String!) {
webhookSubscriptionCreate(topic: $topic, webhookSubscription: { uri: $uri, format: JSON }) {
webhookSubscription { id topic uri }
userErrors { field message }
}
}"""
def create_subscription(topic, uri):
result = gql(CREATE_MUTATION, {"topic": topic, "uri": uri})["webhookSubscriptionCreate"]
if result["userErrors"]:
raise RuntimeError(result["userErrors"])
return result["webhookSubscription"]
const CREATE_MUTATION = `
mutation($topic: WebhookSubscriptionTopic!, $uri: String!) {
webhookSubscriptionCreate(topic: $topic, webhookSubscription: { uri: $uri, format: JSON }) {
webhookSubscription { id topic uri }
userErrors { field message }
}
}`;
async function createSubscription(topic, uri) {
const result = (await gql(CREATE_MUTATION, { topic, uri })).webhookSubscriptionCreate;
if (result.userErrors.length) throw new Error(JSON.stringify(result.userErrors));
return result.webhookSubscription;
}
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 subscriptions it would recreate. Read the output, agree with it, then switch it off to let it write. Run it on a schedule, for example every few hours, so a deleted subscription does not stay missing for long.
Always start with DRY_RUN=true, and keep REQUIRED_WEBHOOKS as the one place that lists what your app actually needs. The script only ever adds a missing subscription. It never edits or deletes one that is already there, so it cannot make a working integration worse.
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 creates subscriptions for topic and endpoint pairs that are actually missing.
View this code on GitHub Full runnable folder with tests in the shopify-fixes repo.
"""Detect and recreate Shopify webhook subscriptions that Shopify auto-deleted.
Shopify removes a webhook subscription on its own after delivery keeps failing,
for example your endpoint was down for days or kept returning errors. The app
never hears about the removal. It just quietly stops receiving that topic, and
the gap is invisible until someone notices an order or a fulfillment never
triggered the expected side effect.
This job compares the webhook subscriptions you require (topic and endpoint
URI) against what Shopify actually has registered with webhookSubscriptions,
and recreates the ones that are missing with webhookSubscriptionCreate. It
never deletes or edits a subscription that already exists, it only fills gaps.
Run on a schedule. Safe to run again and again.
"""
import json
import os
import logging
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("recreate_missing_webhooks")
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"
# A JSON array of {"topic": "...", "uri": "..."} pairs your app depends on.
# Example: [{"topic": "ORDERS_PAID", "uri": "https://app.example.com/webhooks/orders-paid"}]
REQUIRED_WEBHOOKS = json.loads(os.environ.get("REQUIRED_WEBHOOKS", "[]"))
SUBSCRIPTIONS_QUERY = """
query($cursor: String) {
webhookSubscriptions(first: 50, after: $cursor) {
pageInfo { hasNextPage endCursor }
nodes { id topic uri format createdAt }
}
}"""
CREATE_MUTATION = """
mutation($topic: WebhookSubscriptionTopic!, $uri: String!) {
webhookSubscriptionCreate(topic: $topic, webhookSubscription: { uri: $uri, format: JSON }) {
webhookSubscription { id topic uri }
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 missing_subscriptions(existing, required):
"""Pure decision function. No I/O.
existing: list of dicts with at least "topic" and "uri", read from Shopify.
required: list of dicts with "topic" and "uri", the app's declared needs.
Returns the entries from required that have no matching existing
subscription on the same topic and uri. Comparison is case-sensitive on
uri and normalizes topic to uppercase, since Shopify always returns the
topic enum in upper snake case.
"""
live_pairs = {
((item.get("topic") or "").upper(), item.get("uri"))
for item in existing
}
gaps = []
for need in required:
key = (need.get("topic", "").upper(), need.get("uri"))
if key in live_pairs:
continue
gaps.append(need)
return gaps
def existing_subscriptions():
cursor = None
while True:
data = gql(SUBSCRIPTIONS_QUERY, {"cursor": cursor})["webhookSubscriptions"]
for node in data["nodes"]:
yield node
if not data["pageInfo"]["hasNextPage"]:
return
cursor = data["pageInfo"]["endCursor"]
def create_subscription(topic, uri):
result = gql(CREATE_MUTATION, {"topic": topic, "uri": uri})["webhookSubscriptionCreate"]
if result["userErrors"]:
raise RuntimeError(result["userErrors"])
return result["webhookSubscription"]
def run():
if not REQUIRED_WEBHOOKS:
log.warning("REQUIRED_WEBHOOKS is empty. Nothing to check. Set it to a JSON list of {topic, uri}.")
return
current = list(existing_subscriptions())
gaps = missing_subscriptions(current, REQUIRED_WEBHOOKS)
for gap in gaps:
log.warning("Missing webhook subscription for %s at %s. %s",
gap["topic"], gap["uri"], "would recreate" if DRY_RUN else "recreating")
if not DRY_RUN:
create_subscription(gap["topic"], gap["uri"])
log.info("Done. %d subscription(s) %s out of %d required.",
len(gaps), "to recreate" if DRY_RUN else "recreated", len(REQUIRED_WEBHOOKS))
if __name__ == "__main__":
run()
/**
* Detect and recreate Shopify webhook subscriptions that Shopify auto-deleted.
*
* Shopify removes a webhook subscription on its own after delivery keeps
* failing, for example your endpoint was down for days or kept returning
* errors. The app never hears about the removal. It just quietly stops
* receiving that topic, and the gap is invisible until someone notices an
* order or a fulfillment never triggered the expected side effect.
*
* This job compares the webhook subscriptions you require (topic and endpoint
* uri) against what Shopify actually has registered with webhookSubscriptions,
* and recreates the ones that are missing with webhookSubscriptionCreate. It
* never deletes or edits a subscription that already exists, it only fills
* gaps. 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";
// A JSON array of {"topic": "...", "uri": "..."} pairs your app depends on.
const REQUIRED_WEBHOOKS = JSON.parse(process.env.REQUIRED_WEBHOOKS || "[]");
const SUBSCRIPTIONS_QUERY = `
query($cursor: String) {
webhookSubscriptions(first: 50, after: $cursor) {
pageInfo { hasNextPage endCursor }
nodes { id topic uri format createdAt }
}
}`;
const CREATE_MUTATION = `
mutation($topic: WebhookSubscriptionTopic!, $uri: String!) {
webhookSubscriptionCreate(topic: $topic, webhookSubscription: { uri: $uri, format: JSON }) {
webhookSubscription { id topic uri }
userErrors { field message }
}
}`;
/**
* Pure decision function. No I/O.
*
* existing: array of {topic, uri, ...} read from Shopify.
* required: array of {topic, uri}, the app's declared needs.
* Returns the entries from required that have no matching existing
* subscription on the same topic and uri. Topic comparison is
* case-insensitive since Shopify always returns the enum in upper snake case.
*/
export function missingSubscriptions(existing, required) {
const livePairs = new Set(
existing.map((item) => `${(item.topic || "").toUpperCase()}::${item.uri}`)
);
return required.filter((need) => {
const key = `${(need.topic || "").toUpperCase()}::${need.uri}`;
return !livePairs.has(key);
});
}
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;
}
async function* existingSubscriptions() {
let cursor = null;
while (true) {
const data = (await gql(SUBSCRIPTIONS_QUERY, { cursor })).webhookSubscriptions;
for (const node of data.nodes) yield node;
if (!data.pageInfo.hasNextPage) return;
cursor = data.pageInfo.endCursor;
}
}
async function createSubscription(topic, uri) {
const result = (await gql(CREATE_MUTATION, { topic, uri })).webhookSubscriptionCreate;
if (result.userErrors.length) throw new Error(JSON.stringify(result.userErrors));
return result.webhookSubscription;
}
export async function run() {
if (!REQUIRED_WEBHOOKS.length) {
console.warn("REQUIRED_WEBHOOKS is empty. Nothing to check. Set it to a JSON list of {topic, uri}.");
return;
}
const current = [];
for await (const node of existingSubscriptions()) current.push(node);
const gaps = missingSubscriptions(current, REQUIRED_WEBHOOKS);
for (const gap of gaps) {
console.warn(`Missing webhook subscription for ${gap.topic} at ${gap.uri}. ${DRY_RUN ? "would recreate" : "recreating"}`);
if (!DRY_RUN) await createSubscription(gap.topic, gap.uri);
}
console.log(`Done. ${gaps.length} subscription(s) ${DRY_RUN ? "to recreate" : "recreated"} out of ${REQUIRED_WEBHOOKS.length} required.`);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
The gap decision is the part most worth testing, because it decides whether the script recreates a webhook subscription or leaves it alone. Because we kept missing_subscriptions pure, the test needs no network and no Shopify account. It just feeds in plain objects and checks the answer.
from recreate_missing_webhooks import missing_subscriptions
def existing(topic, uri):
return {"topic": topic, "uri": uri, "id": "gid://shopify/WebhookSubscription/1", "format": "JSON"}
def required(topic, uri):
return {"topic": topic, "uri": uri}
def test_no_gap_when_everything_registered():
current = [existing("ORDERS_PAID", "https://app.example.com/hooks/orders-paid")]
need = [required("ORDERS_PAID", "https://app.example.com/hooks/orders-paid")]
assert missing_subscriptions(current, need) == []
def test_gap_when_topic_missing_entirely():
current = []
need = [required("ORDERS_PAID", "https://app.example.com/hooks/orders-paid")]
assert missing_subscriptions(current, need) == need
def test_gap_when_uri_does_not_match():
current = [existing("ORDERS_PAID", "https://old.example.com/hooks/orders-paid")]
need = [required("ORDERS_PAID", "https://app.example.com/hooks/orders-paid")]
assert missing_subscriptions(current, need) == need
def test_only_missing_ones_are_returned():
current = [existing("ORDERS_PAID", "https://app.example.com/hooks/orders-paid")]
need = [
required("ORDERS_PAID", "https://app.example.com/hooks/orders-paid"),
required("FULFILLMENTS_CREATE", "https://app.example.com/hooks/fulfillments-create"),
]
assert missing_subscriptions(current, need) == [need[1]]
import { test } from "node:test";
import assert from "node:assert/strict";
import { missingSubscriptions } from "./recreate-missing-webhooks.js";
const existing = (topic, uri) => ({ topic, uri, id: "gid://shopify/WebhookSubscription/1", format: "JSON" });
const required = (topic, uri) => ({ topic, uri });
test("no gap when everything is registered", () => {
const current = [existing("ORDERS_PAID", "https://app.example.com/hooks/orders-paid")];
const need = [required("ORDERS_PAID", "https://app.example.com/hooks/orders-paid")];
assert.deepEqual(missingSubscriptions(current, need), []);
});
test("gap when topic is missing entirely", () => {
const current = [];
const need = [required("ORDERS_PAID", "https://app.example.com/hooks/orders-paid")];
assert.deepEqual(missingSubscriptions(current, need), need);
});
test("gap when uri does not match", () => {
const current = [existing("ORDERS_PAID", "https://old.example.com/hooks/orders-paid")];
const need = [required("ORDERS_PAID", "https://app.example.com/hooks/orders-paid")];
assert.deepEqual(missingSubscriptions(current, need), need);
});
Case studies
A thirty-minute deploy cost a week of missing fulfillment syncs
A fulfillment app's webhook endpoint returned 500s for about half an hour during a bad deploy. Shopify retried through that window, then quietly removed the fulfillments/create subscription. Nobody noticed until a warehouse manager asked why a batch of orders never printed a shipping label.
Once the team added the gap check on an hourly schedule, the same kind of outage now gets caught and repaired automatically well before anyone in the warehouse notices anything is wrong.
An expired TLS certificate silently cut off order events
A small integration's certificate expired over a weekend. Every delivery attempt failed at the connection level, and by Monday the orders/paid subscription no longer existed. Orders kept coming in and paying, but the internal accounting sync stopped dead with no error anywhere in the app's own logs.
The team fixed the certificate, then ran the script in dry run and saw exactly one missing subscription. Turning off dry run recreated it in one call, and the sync picked back up without anyone touching the Shopify admin.
After this runs on a schedule, a webhook subscription that Shopify silently removes gets rebuilt before anyone downstream notices a gap. The required topics live in one place, the check never touches a subscription that is already working, and the dry run output gives you a clear list to confirm before anything is created. No more finding out about a dead subscription from a confused customer.
FAQ
Why did Shopify delete my webhook subscription?
Shopify removes a webhook subscription automatically once your endpoint fails to accept deliveries for too long, for example it returned errors or timed out on many attempts in a row. Shopify does not send a notice when this happens, so the app keeps running while quietly missing every event on that topic.
How do I know if a webhook subscription is missing?
Compare the topics and endpoint URIs your app expects against what the webhookSubscriptions query actually returns for your shop. If a topic and URI pair you rely on is not in that list, Shopify is not sending you those events, whether it was auto-deleted, never created, or removed by mistake.
Is it safe to recreate webhook subscriptions with a script?
Yes, when the script only creates subscriptions that are missing and never edits or deletes ones that already exist. Comparing by topic and endpoint URI before writing anything means it cannot create a duplicate of a working subscription, and running it in dry run first lets you check the list before it calls webhookSubscriptionCreate.
Related field notes
Citations
On the problem:
- Shopify.dev: Webhooks overview, including delivery retries and automatic removal of failing subscriptions. shopify.dev/docs/apps/build/webhooks
- Shopify.dev: troubleshooting webhook delivery failures and endpoint health. shopify.dev/docs/apps/build/webhooks/troubleshooting
- Shopify Community: webhook subscriptions disappearing after repeated delivery failures. community.shopify.com webhooks and events
On the solution:
- Shopify Admin GraphQL: the
webhookSubscriptionsquery. shopify.dev/docs/api/admin-graphql/latest/queries/webhookSubscriptions - Shopify Admin GraphQL: the
webhookSubscriptionCreatemutation. shopify.dev/docs/api/admin-graphql/latest/mutations/webhookSubscriptionCreate - Shopify Admin GraphQL: the
WebhookSubscriptionobject and itstopicandurifields. shopify.dev/docs/api/admin-graphql/latest/objects/WebhookSubscription
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 fix a webhook you thought was still running?
If this saved you from a silently broken integration, 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