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.

Python and Node.js Admin GraphQL API Safe by default (dry run)
A rack of servers with wires attached
Photo by Yuriy Vertikov on Unsplash
The short answer

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.

Endpoint fails down or erroring Shopify retries for a while, then stops deleted, no notice Subscription gone removed silently Events stop arriving
The endpoint recovers, but the subscription that used to call it is already gone, and nothing tells you that.

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:

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.

The key insight

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.

Scheduled job runs on a timer List subscriptions webhookSubscriptions Compare to required topic and endpoint uri Pair missing? yes no, leave it webhookSubscriptionCreate gap is filled
The job only creates a subscription when the required topic and endpoint pair has no match in what Shopify actually has. Everything already working is left alone.

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

setup (shell)
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
setup (shell)
// 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
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 subscriptions and to run the create 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 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.

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

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.

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

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.

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

Wire it together with a dry run guard

The loop ties every piece together. Notice the dry run guard. On the first few runs, leave DRY_RUN on so the script only reports which 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.

Run it safe

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.

recreate_missing_webhooks.py
"""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()
recreate-missing-webhooks.js
/**
 * 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.

test_webhook_gap_detection.py
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]]
webhook-gap-detection.test.js
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

Deploy outage

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.

Expired certificate

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.

What good looks like

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:

  1. Shopify.dev: Webhooks overview, including delivery retries and automatic removal of failing subscriptions. shopify.dev/docs/apps/build/webhooks
  2. Shopify.dev: troubleshooting webhook delivery failures and endpoint health. shopify.dev/docs/apps/build/webhooks/troubleshooting
  3. Shopify Community: webhook subscriptions disappearing after repeated delivery failures. community.shopify.com webhooks and events

On the solution:

  1. Shopify Admin GraphQL: the webhookSubscriptions query. shopify.dev/docs/api/admin-graphql/latest/queries/webhookSubscriptions
  2. Shopify Admin GraphQL: the webhookSubscriptionCreate mutation. shopify.dev/docs/api/admin-graphql/latest/mutations/webhookSubscriptionCreate
  3. Shopify Admin GraphQL: the WebhookSubscription object and its topic and uri fields. 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.

Contact me on LinkedIn

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

Back to all Shopify field notes