Skip to content

Diagnostic Webhooks & Events

ORDER_UPDATED webhook skipped on metadata change

Your app subscribes to ORDER_UPDATED and it fires reliably for real order changes, price edits, status moves, address updates. Then someone calls updateMetadata to stamp an order with a CRM id or a fulfillment reference, and nothing arrives at your endpoint. The order was legitimately modified. Saleor just decided that particular change does not count as an order update. Here is why that split exists and a script that finds every app still exposed to it.

Python and Node.js Saleor GraphQL API Report only (no auto writes)
A network with wires connected
Photo by Albert Stoynov on Unsplash
The short answer

Saleor intentionally separates a full object update from a metadata-only update. Mutations like updateMetadata and updatePrivateMetadata on an Order only mark the metadata fields dirty, and Saleor's webhook dispatch logic checks for substantive order-field changes before firing ORDER_UPDATED, a check a metadata-only write never satisfies. Saleor instead emits a distinct ORDER_METADATA_UPDATED async event for exactly this case, so an app that only ever subscribed to ORDER_UPDATED never learns the order changed, even though it genuinely did. Run a small Python or Node.js script that reads each webhook's subscribed events, reads recently metadata-touched orders, and reports every webhook missing ORDER_METADATA_UPDATED and every order whose metadata write has no matching delivery. Full code, tests, and a dry run guarded repair are below.

The problem in plain words

When you build an app on Saleor that keeps its own records in step with an order, the natural move is to subscribe to ORDER_UPDATED and treat any delivery as a signal to re-read the order. For price changes, line edits, address changes, and status moves, that works exactly as expected.

Then someone tags an order with your CRM reference, or a fulfillment partner id, using updateMetadata, and your endpoint stays completely silent. The order really did change, Saleor really did save it, but no ORDER_UPDATED delivery was ever created. If your app's whole sync model assumes ORDER_UPDATED means "something on this order changed," a metadata write is a blind spot you will not notice until the two systems have already drifted apart.

updateMetadata runs only metadata is dirty Dispatch checks for substantive field change check fails, by design ORDER_UPDATED never fires App never told Meanwhile ORDER_METADATA_UPDATED fires on its own, unseen by an app that never subscribed to it.
The order object genuinely changed, but Saleor's dispatch check only fires ORDER_UPDATED for substantive field changes. Metadata gets its own event, and an app that never subscribed to it hears nothing.

Why it happens

Nothing errors anywhere in this flow. The mutation succeeds, the metadata is saved, the Saleor dashboard shows the new key and value. The only sign something is wrong is that your app, which relies on ORDER_UPDATED to know when to re-read an order, never re-reads it.

The key insight

You cannot fix this by retrying ORDER_UPDATED deliveries, there was never a delivery to retry, and it is documented, by-design behavior, not a bug Saleor will patch. The only reliable signal is your webhook's own subscription list. Read asyncEvents or parse subscriptionQuery for the event names an app actually asked for, and if ORDER_METADATA_UPDATED is missing while ORDER_UPDATED is present, that webhook is structurally blind to every metadata-only write it will ever receive.

The fix, as a flow

The script pulls every webhook's subscription config, then pulls recently metadata-touched orders. For each order it checks whether the responsible webhook's subscription even includes ORDER_METADATA_UPDATED. If it does not, that is a misconfiguration to report and, only when a human turns off dry run, repair by updating the subscription. If it does include the event, the script checks the delivery log for an actual delivery after the metadata write, since a correct subscription can still suffer a real delivery failure. Order-level gaps are always report only, since replaying a webhook is not something Saleor's API can do.

Scheduled job runs on a timer Read webhooks and metadata-touched orders Classify each order subscription vs delivery Missing event? yes no, check delivery log Report, repair if enabled webhookUpdate, dry run first
Only a missing subscription is auto-repaired, and only when DRY_RUN is off. A real delivery failure on a correctly subscribed webhook is always report only, for a human to reconcile.

Build it step by step

1

Get an app token with read and webhook management access

Create an app in Saleor Dashboard under Configuration, Apps, and give it permission to read orders and manage webhooks, or exchange staff credentials with tokenCreate. Keep the API URL and token in environment variables, never in the file.

setup (shell)
pip install requests

export SALEOR_API_URL="https://store.saleor.cloud/graphql/"
export SALEOR_AUTH_TOKEN="your-app-or-staff-token"
export DRY_RUN="true"   # start safe, this script never writes without it off
setup (shell)
// Node 18+ has fetch built in, no dependencies needed

export SALEOR_API_URL="https://store.saleor.cloud/graphql/"
export SALEOR_AUTH_TOKEN="your-app-or-staff-token"
export DRY_RUN="true"   // start safe, this script never writes without it off
2

Talk to the Saleor GraphQL API

Saleor is one GraphQL endpoint. Every call is a POST with a JSON body of {query, variables} and an Authorization: Bearer <token> header. A small helper sends a query and returns the data, raising if Saleor reports errors.

step2.py
import os, requests

API_URL = os.environ["SALEOR_API_URL"]
TOKEN = os.environ["SALEOR_AUTH_TOKEN"]

def gql(query, variables=None):
    r = requests.post(
        API_URL,
        json={"query": query, "variables": variables or {}},
        headers={"Authorization": f"Bearer {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 API_URL = process.env.SALEOR_API_URL;
const TOKEN = process.env.SALEOR_AUTH_TOKEN;

async function gql(query, variables = {}) {
  const res = await fetch(API_URL, {
    method: "POST",
    headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json" },
    body: JSON.stringify({ query, variables }),
  });
  if (!res.ok) throw new Error(`Saleor ${res.status}`);
  const body = await res.json();
  if (body.errors) throw new Error(JSON.stringify(body.errors));
  return body.data;
}
3

List webhooks and recently metadata-touched orders

Read every webhook's asyncEvents, targetUrl, and recent eventDeliveries, so you know exactly what each app subscribed to and what actually arrived. Separately, list orders sorted by lastModifiedAt with their metadata, since those are the candidates whose metadata write may or may not have been seen.

step3.py
WEBHOOKS_QUERY = """
query {
  webhooks(first: 100) {
    edges {
      node {
        id
        name
        isActive
        asyncEvents
        targetUrl
        subscriptionQuery
        eventDeliveries(first: 50, sortBy: { field: CREATED_AT, direction: DESC }) {
          edges { node { id createdAt status eventType payload } }
        }
      }
    }
  }
}"""

RECENT_ORDERS_QUERY = """
query($cursor: String) {
  orders(first: 100, after: $cursor, sortBy: { field: LAST_MODIFIED_AT, direction: DESC }) {
    pageInfo { hasNextPage endCursor }
    edges { node { id number lastModifiedAt metadata { key value } privateMetadata { key value } } }
  }
}"""

def list_webhooks():
    data = gql(WEBHOOKS_QUERY)["webhooks"]
    return [edge["node"] for edge in data["edges"]]


def recently_touched_orders():
    cursor = None
    orders = []
    while True:
        data = gql(RECENT_ORDERS_QUERY, {"cursor": cursor})["orders"]
        orders.extend(edge["node"] for edge in data["edges"])
        if not data["pageInfo"]["hasNextPage"]:
            return orders
        cursor = data["pageInfo"]["endCursor"]
step3.js
const WEBHOOKS_QUERY = `
query {
  webhooks(first: 100) {
    edges {
      node {
        id
        name
        isActive
        asyncEvents
        targetUrl
        subscriptionQuery
        eventDeliveries(first: 50, sortBy: { field: CREATED_AT, direction: DESC }) {
          edges { node { id createdAt status eventType payload } }
        }
      }
    }
  }
}`;

const RECENT_ORDERS_QUERY = `
query($cursor: String) {
  orders(first: 100, after: $cursor, sortBy: { field: LAST_MODIFIED_AT, direction: DESC }) {
    pageInfo { hasNextPage endCursor }
    edges { node { id number lastModifiedAt metadata { key value } privateMetadata { key value } } }
  }
}`;

async function listWebhooks() {
  const data = (await gql(WEBHOOKS_QUERY)).webhooks;
  return data.edges.map((edge) => edge.node);
}

async function recentlyTouchedOrders() {
  let cursor = null;
  const orders = [];
  while (true) {
    const data = (await gql(RECENT_ORDERS_QUERY, { cursor })).orders;
    orders.push(...data.edges.map((edge) => edge.node));
    if (!data.pageInfo.hasNextPage) return orders;
    cursor = data.pageInfo.endCursor;
  }
}
4

Decide, with one pure function

Keep the decision in its own function that takes the order's last metadata write time, the deliveries in that webhook's log filtered to this order and to ORDER_UPDATED or ORDER_METADATA_UPDATED, and the webhook's subscribed events. It returns one of three outcomes. If the subscription never included ORDER_METADATA_UPDATED, the whole webhook is misconfigured for this case, no delivery could ever have arrived. If it did subscribe correctly but no matching delivery exists at or after the write, that is a real delivery failure. Otherwise it is fine.

decide.py
def classify_metadata_webhook_gap(metadata_updated_at, deliveries, subscribed_events):
    """
    metadata_updated_at: ISO8601 timestamp of the last metadata/private-metadata write on the order.
    deliveries: list of {"eventType": str, "createdAt": str} rows from Webhook.eventDeliveries,
                already filtered to this order's id and eventType in {"ORDER_UPDATED", "ORDER_METADATA_UPDATED"}.
    subscribed_events: the app's webhook.asyncEvents (or parsed subscriptionQuery event names).

    Returns one of:
      "MISCONFIGURED_SUBSCRIPTION"  -> app only subscribed to ORDER_UPDATED, never to ORDER_METADATA_UPDATED
      "DELIVERY_MISSING"            -> subscribed correctly but no delivery exists at/after the write
      "OK"                          -> a matching ORDER_METADATA_UPDATED delivery exists at/after the write
    """
    if "ORDER_METADATA_UPDATED" not in subscribed_events:
        return "MISCONFIGURED_SUBSCRIPTION"
    has_matching_delivery = any(
        d["eventType"] == "ORDER_METADATA_UPDATED" and d["createdAt"] >= metadata_updated_at
        for d in deliveries
    )
    return "OK" if has_matching_delivery else "DELIVERY_MISSING"
decide.js
export function classifyMetadataWebhookGap(metadataUpdatedAt, deliveries, subscribedEvents) {
  if (!subscribedEvents.has("ORDER_METADATA_UPDATED")) {
    return "MISCONFIGURED_SUBSCRIPTION";
  }
  const hasMatchingDelivery = deliveries.some(
    (d) => d.eventType === "ORDER_METADATA_UPDATED" && d.createdAt >= metadataUpdatedAt
  );
  return hasMatchingDelivery ? "OK" : "DELIVERY_MISSING";
}
5

Report first, repair the subscription only behind a dry run

Under DRY_RUN=true, the default, the script only logs each misconfigured webhook, its current asyncEvents, and the missing entry, plus any order-level delivery gaps for manual reconciliation. When DRY_RUN=false, it calls webhookUpdate to add ORDER_METADATA_UPDATED to the subscription, then re-verifies by reading eventDeliveries again after a follow-up metadata write. Order-level delivery gaps are never auto-repaired, Saleor has no mutation to replay a past delivery, so those stay report-only for the integration owner to resync by re-polling the order.

Run it safe

Never treat a missing ORDER_METADATA_UPDATED delivery as something to retroactively replay, Saleor exposes no mutation for that. Always dry run the subscription repair first and log the webhook id, name, and current events before any write, and leave every order-level gap as a report for a human to resync downstream state.

The full code

Here is the complete script in one file for each language. It reads settings from the environment, lists webhooks and recently metadata-touched orders, classifies every gap, reports everything, and only repairs a misconfigured subscription when a human turns off dry run.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Dry run by default. One of 51 Saleor fixes, free and open source.
detect_metadata_webhook_gap.py
"""Find Saleor webhooks and orders where a metadata write on an Order was
never seen because the app only subscribed to ORDER_UPDATED.

updateMetadata and updatePrivateMetadata only mark metadata fields dirty.
Saleor's webhook dispatch logic checks for substantive order-field changes
before firing ORDER_UPDATED, and a metadata-only write never satisfies that
check (saleor/saleor#10166). Saleor fires a separate ORDER_METADATA_UPDATED
event for exactly this case instead, so an app subscribed only to
ORDER_UPDATED never learns the order changed.

This script never re-fires a webhook, Saleor exposes no such mutation. Under
DRY_RUN=true (the default) it only reports misconfigured subscriptions and
order-level delivery gaps. When DRY_RUN=false it repairs the subscription
itself with webhookUpdate and re-verifies with a follow-up eventDeliveries
read. Order-level gaps stay report-only for manual reconciliation. 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("detect_metadata_webhook_gap")

API_URL = os.environ["SALEOR_API_URL"]
TOKEN = os.environ["SALEOR_AUTH_TOKEN"]
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"

WEBHOOKS_QUERY = """
query {
  webhooks(first: 100) {
    edges {
      node {
        id
        name
        isActive
        asyncEvents
        targetUrl
        subscriptionQuery
        eventDeliveries(first: 50, sortBy: { field: CREATED_AT, direction: DESC }) {
          edges { node { id createdAt status eventType payload } }
        }
      }
    }
  }
}"""

RECENT_ORDERS_QUERY = """
query($cursor: String) {
  orders(first: 100, after: $cursor, sortBy: { field: LAST_MODIFIED_AT, direction: DESC }) {
    pageInfo { hasNextPage endCursor }
    edges { node { id number lastModifiedAt metadata { key value } privateMetadata { key value } } }
  }
}"""

WEBHOOK_UPDATE = """
mutation($id: ID!, $asyncEvents: [WebhookEventTypeAsyncEnum!], $subscriptionQuery: String) {
  webhookUpdate(id: $id, input: { asyncEvents: $asyncEvents, subscriptionQuery: $subscriptionQuery }) {
    webhook { id asyncEvents }
    errors { field message code }
  }
}"""


def gql(query, variables=None):
    r = requests.post(
        API_URL,
        json={"query": query, "variables": variables or {}},
        headers={"Authorization": f"Bearer {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 classify_metadata_webhook_gap(metadata_updated_at, deliveries, subscribed_events):
    """
    metadata_updated_at: ISO8601 timestamp of the last metadata/private-metadata write on the order.
    deliveries: list of {"eventType": str, "createdAt": str} rows from Webhook.eventDeliveries,
                already filtered to this order's id and eventType in {"ORDER_UPDATED", "ORDER_METADATA_UPDATED"}.
    subscribed_events: the app's webhook.asyncEvents (or parsed subscriptionQuery event names).

    Returns one of:
      "MISCONFIGURED_SUBSCRIPTION"  -> app only subscribed to ORDER_UPDATED, never to ORDER_METADATA_UPDATED
      "DELIVERY_MISSING"            -> subscribed correctly but no delivery exists at/after the write
      "OK"                          -> a matching ORDER_METADATA_UPDATED delivery exists at/after the write
    """
    if "ORDER_METADATA_UPDATED" not in subscribed_events:
        return "MISCONFIGURED_SUBSCRIPTION"
    has_matching_delivery = any(
        d["eventType"] == "ORDER_METADATA_UPDATED" and d["createdAt"] >= metadata_updated_at
        for d in deliveries
    )
    return "OK" if has_matching_delivery else "DELIVERY_MISSING"


def list_webhooks():
    data = gql(WEBHOOKS_QUERY)["webhooks"]
    return [edge["node"] for edge in data["edges"]]


def recently_touched_orders():
    cursor = None
    orders = []
    while True:
        data = gql(RECENT_ORDERS_QUERY, {"cursor": cursor})["orders"]
        orders.extend(edge["node"] for edge in data["edges"])
        if not data["pageInfo"]["hasNextPage"]:
            return orders
        cursor = data["pageInfo"]["endCursor"]


def has_metadata(order):
    return bool(order.get("metadata")) or bool(order.get("privateMetadata"))


def repair_subscription(webhook):
    events = sorted(set(webhook["asyncEvents"]) | {"ORDER_METADATA_UPDATED"})
    log.info("Would add ORDER_METADATA_UPDATED to webhook %s (%s)", webhook["id"], webhook["name"])
    if DRY_RUN:
        return
    result = gql(WEBHOOK_UPDATE, {
        "id": webhook["id"],
        "asyncEvents": events,
        "subscriptionQuery": webhook.get("subscriptionQuery"),
    })["webhookUpdate"]
    if result["errors"]:
        raise RuntimeError(result["errors"])
    log.info("Repaired webhook %s. asyncEvents now: %s", webhook["id"], result["webhook"]["asyncEvents"])


def run():
    webhooks = list_webhooks()
    orders = recently_touched_orders()

    misconfigured = []
    delivery_missing = []

    for webhook in webhooks:
        subscribed = set(webhook.get("asyncEvents") or [])
        if "ORDER_UPDATED" not in subscribed:
            continue  # this webhook does not even claim to track order updates

        deliveries = [
            {"eventType": e["node"]["eventType"], "createdAt": e["node"]["createdAt"]}
            for e in webhook["eventDeliveries"]["edges"]
            if e["node"]["eventType"] in ("ORDER_UPDATED", "ORDER_METADATA_UPDATED")
        ]

        for order in orders:
            if not has_metadata(order):
                continue
            metadata_updated_at = order["lastModifiedAt"]
            outcome = classify_metadata_webhook_gap(metadata_updated_at, deliveries, subscribed)
            if outcome == "MISCONFIGURED_SUBSCRIPTION":
                misconfigured.append(webhook)
                break  # one report per webhook is enough, it applies to every order
            if outcome == "DELIVERY_MISSING":
                delivery_missing.append((webhook, order))

    for webhook in misconfigured:
        log.warning(
            "MISCONFIGURED_SUBSCRIPTION webhook=%s name=%s asyncEvents=%s missing=ORDER_METADATA_UPDATED",
            webhook["id"], webhook["name"], webhook["asyncEvents"],
        )
        repair_subscription(webhook)

    for webhook, order in delivery_missing:
        log.warning(
            "DELIVERY_MISSING webhook=%s order=%s lastModifiedAt=%s. Manual reconciliation needed.",
            webhook["id"], order["number"], order["lastModifiedAt"],
        )

    log.info(
        "Done. %d webhook(s) %s, %d order-level delivery gap(s) reported.",
        len(misconfigured), "repaired" if not DRY_RUN else "to repair", len(delivery_missing),
    )
    return misconfigured, delivery_missing


if __name__ == "__main__":
    run()
detect-metadata-webhook-gap.js
/**
 * Find Saleor webhooks and orders where a metadata write on an Order was
 * never seen because the app only subscribed to ORDER_UPDATED.
 *
 * updateMetadata and updatePrivateMetadata only mark metadata fields dirty.
 * Saleor's webhook dispatch logic checks for substantive order-field changes
 * before firing ORDER_UPDATED, and a metadata-only write never satisfies that
 * check (saleor/saleor#10166). Saleor fires a separate ORDER_METADATA_UPDATED
 * event for exactly this case instead, so an app subscribed only to
 * ORDER_UPDATED never learns the order changed.
 *
 * This script never re-fires a webhook, Saleor exposes no such mutation.
 * Under DRY_RUN=true (the default) it only reports misconfigured
 * subscriptions and order-level delivery gaps. When DRY_RUN=false it repairs
 * the subscription itself with webhookUpdate and re-verifies with a
 * follow-up eventDeliveries read. Order-level gaps stay report-only. Run on
 * a schedule.
 *
 * Guide: https://www.allanninal.dev/saleor/order-updated-webhook-skipped-on-metadata-change/
 */
import { pathToFileURL } from "node:url";

const API_URL = process.env.SALEOR_API_URL || "https://store.saleor.cloud/graphql/";
const TOKEN = process.env.SALEOR_AUTH_TOKEN || "dummy-token";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

export function classifyMetadataWebhookGap(metadataUpdatedAt, deliveries, subscribedEvents) {
  if (!subscribedEvents.has("ORDER_METADATA_UPDATED")) {
    return "MISCONFIGURED_SUBSCRIPTION";
  }
  const hasMatchingDelivery = deliveries.some(
    (d) => d.eventType === "ORDER_METADATA_UPDATED" && d.createdAt >= metadataUpdatedAt
  );
  return hasMatchingDelivery ? "OK" : "DELIVERY_MISSING";
}

async function gql(query, variables = {}) {
  const res = await fetch(API_URL, {
    method: "POST",
    headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json" },
    body: JSON.stringify({ query, variables }),
  });
  if (!res.ok) throw new Error(`Saleor ${res.status}`);
  const body = await res.json();
  if (body.errors) throw new Error(JSON.stringify(body.errors));
  return body.data;
}

const WEBHOOKS_QUERY = `
query {
  webhooks(first: 100) {
    edges {
      node {
        id
        name
        isActive
        asyncEvents
        targetUrl
        subscriptionQuery
        eventDeliveries(first: 50, sortBy: { field: CREATED_AT, direction: DESC }) {
          edges { node { id createdAt status eventType payload } }
        }
      }
    }
  }
}`;

const RECENT_ORDERS_QUERY = `
query($cursor: String) {
  orders(first: 100, after: $cursor, sortBy: { field: LAST_MODIFIED_AT, direction: DESC }) {
    pageInfo { hasNextPage endCursor }
    edges { node { id number lastModifiedAt metadata { key value } privateMetadata { key value } } }
  }
}`;

const WEBHOOK_UPDATE = `
mutation($id: ID!, $asyncEvents: [WebhookEventTypeAsyncEnum!], $subscriptionQuery: String) {
  webhookUpdate(id: $id, input: { asyncEvents: $asyncEvents, subscriptionQuery: $subscriptionQuery }) {
    webhook { id asyncEvents }
    errors { field message code }
  }
}`;

async function listWebhooks() {
  const data = (await gql(WEBHOOKS_QUERY)).webhooks;
  return data.edges.map((edge) => edge.node);
}

async function recentlyTouchedOrders() {
  let cursor = null;
  const orders = [];
  while (true) {
    const data = (await gql(RECENT_ORDERS_QUERY, { cursor })).orders;
    orders.push(...data.edges.map((edge) => edge.node));
    if (!data.pageInfo.hasNextPage) return orders;
    cursor = data.pageInfo.endCursor;
  }
}

function hasMetadata(order) {
  return Boolean(order.metadata && order.metadata.length) || Boolean(order.privateMetadata && order.privateMetadata.length);
}

async function repairSubscription(webhook) {
  const events = Array.from(new Set([...(webhook.asyncEvents || []), "ORDER_METADATA_UPDATED"])).sort();
  console.log(`Would add ORDER_METADATA_UPDATED to webhook ${webhook.id} (${webhook.name})`);
  if (DRY_RUN) return;
  const result = (await gql(WEBHOOK_UPDATE, {
    id: webhook.id,
    asyncEvents: events,
    subscriptionQuery: webhook.subscriptionQuery ?? null,
  })).webhookUpdate;
  if (result.errors.length) throw new Error(JSON.stringify(result.errors));
  console.log(`Repaired webhook ${webhook.id}. asyncEvents now: ${result.webhook.asyncEvents}`);
}

export async function run() {
  const webhooks = await listWebhooks();
  const orders = await recentlyTouchedOrders();

  const misconfigured = [];
  const deliveryMissing = [];

  for (const webhook of webhooks) {
    const subscribed = new Set(webhook.asyncEvents || []);
    if (!subscribed.has("ORDER_UPDATED")) continue;

    const deliveries = webhook.eventDeliveries.edges
      .map((e) => e.node)
      .filter((n) => n.eventType === "ORDER_UPDATED" || n.eventType === "ORDER_METADATA_UPDATED")
      .map((n) => ({ eventType: n.eventType, createdAt: n.createdAt }));

    for (const order of orders) {
      if (!hasMetadata(order)) continue;
      const metadataUpdatedAt = order.lastModifiedAt;
      const outcome = classifyMetadataWebhookGap(metadataUpdatedAt, deliveries, subscribed);
      if (outcome === "MISCONFIGURED_SUBSCRIPTION") {
        misconfigured.push(webhook);
        break;
      }
      if (outcome === "DELIVERY_MISSING") {
        deliveryMissing.push({ webhook, order });
      }
    }
  }

  for (const webhook of misconfigured) {
    console.warn(
      `MISCONFIGURED_SUBSCRIPTION webhook=${webhook.id} name=${webhook.name} asyncEvents=${webhook.asyncEvents} missing=ORDER_METADATA_UPDATED`
    );
    await repairSubscription(webhook);
  }

  for (const { webhook, order } of deliveryMissing) {
    console.warn(
      `DELIVERY_MISSING webhook=${webhook.id} order=${order.number} lastModifiedAt=${order.lastModifiedAt}. Manual reconciliation needed.`
    );
  }

  console.log(
    `Done. ${misconfigured.length} webhook(s) ${DRY_RUN ? "to repair" : "repaired"}, ${deliveryMissing.length} order-level delivery gap(s) reported.`
  );
  return { misconfigured, deliveryMissing };
}

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 whether a webhook is genuinely misconfigured or just suffered one missed delivery. Because classify_metadata_webhook_gap is pure, the test needs no network and no Saleor account. It just feeds in plain values and checks the answer.

test_metadata_webhook_gap.py
from detect_metadata_webhook_gap import classify_metadata_webhook_gap

WRITE_AT = "2026-07-10T12:00:00Z"


def test_misconfigured_when_not_subscribed_to_metadata_event():
    result = classify_metadata_webhook_gap(WRITE_AT, [], {"ORDER_UPDATED"})
    assert result == "MISCONFIGURED_SUBSCRIPTION"


def test_misconfigured_even_with_unrelated_deliveries():
    deliveries = [{"eventType": "ORDER_UPDATED", "createdAt": "2026-07-10T13:00:00Z"}]
    result = classify_metadata_webhook_gap(WRITE_AT, deliveries, {"ORDER_UPDATED"})
    assert result == "MISCONFIGURED_SUBSCRIPTION"


def test_delivery_missing_when_subscribed_but_no_matching_delivery():
    result = classify_metadata_webhook_gap(WRITE_AT, [], {"ORDER_UPDATED", "ORDER_METADATA_UPDATED"})
    assert result == "DELIVERY_MISSING"


def test_delivery_missing_when_only_delivery_is_before_the_write():
    deliveries = [{"eventType": "ORDER_METADATA_UPDATED", "createdAt": "2026-07-10T11:00:00Z"}]
    result = classify_metadata_webhook_gap(WRITE_AT, deliveries, {"ORDER_UPDATED", "ORDER_METADATA_UPDATED"})
    assert result == "DELIVERY_MISSING"


def test_ok_when_matching_delivery_exists_after_the_write():
    deliveries = [{"eventType": "ORDER_METADATA_UPDATED", "createdAt": "2026-07-10T12:00:01Z"}]
    result = classify_metadata_webhook_gap(WRITE_AT, deliveries, {"ORDER_UPDATED", "ORDER_METADATA_UPDATED"})
    assert result == "OK"


def test_ok_when_delivery_exactly_at_the_write_time():
    deliveries = [{"eventType": "ORDER_METADATA_UPDATED", "createdAt": WRITE_AT}]
    result = classify_metadata_webhook_gap(WRITE_AT, deliveries, {"ORDER_UPDATED", "ORDER_METADATA_UPDATED"})
    assert result == "OK"
metadata-gap.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { classifyMetadataWebhookGap } from "./detect-metadata-webhook-gap.js";

const WRITE_AT = "2026-07-10T12:00:00Z";

test("misconfigured when not subscribed to metadata event", () => {
  const result = classifyMetadataWebhookGap(WRITE_AT, [], new Set(["ORDER_UPDATED"]));
  assert.equal(result, "MISCONFIGURED_SUBSCRIPTION");
});

test("misconfigured even with unrelated deliveries", () => {
  const deliveries = [{ eventType: "ORDER_UPDATED", createdAt: "2026-07-10T13:00:00Z" }];
  const result = classifyMetadataWebhookGap(WRITE_AT, deliveries, new Set(["ORDER_UPDATED"]));
  assert.equal(result, "MISCONFIGURED_SUBSCRIPTION");
});

test("delivery missing when subscribed but no matching delivery", () => {
  const result = classifyMetadataWebhookGap(WRITE_AT, [], new Set(["ORDER_UPDATED", "ORDER_METADATA_UPDATED"]));
  assert.equal(result, "DELIVERY_MISSING");
});

test("delivery missing when only delivery is before the write", () => {
  const deliveries = [{ eventType: "ORDER_METADATA_UPDATED", createdAt: "2026-07-10T11:00:00Z" }];
  const result = classifyMetadataWebhookGap(WRITE_AT, deliveries, new Set(["ORDER_UPDATED", "ORDER_METADATA_UPDATED"]));
  assert.equal(result, "DELIVERY_MISSING");
});

test("ok when matching delivery exists after the write", () => {
  const deliveries = [{ eventType: "ORDER_METADATA_UPDATED", createdAt: "2026-07-10T12:00:01Z" }];
  const result = classifyMetadataWebhookGap(WRITE_AT, deliveries, new Set(["ORDER_UPDATED", "ORDER_METADATA_UPDATED"]));
  assert.equal(result, "OK");
});

test("ok when delivery is exactly at the write time", () => {
  const deliveries = [{ eventType: "ORDER_METADATA_UPDATED", createdAt: WRITE_AT }];
  const result = classifyMetadataWebhookGap(WRITE_AT, deliveries, new Set(["ORDER_UPDATED", "ORDER_METADATA_UPDATED"]));
  assert.equal(result, "OK");
});

Case studies

CRM sync

A support tool never saw its own reference id come back

A helpdesk app wrote its ticket id into an order's metadata with updateMetadata, then relied on ORDER_UPDATED to know when a staff member later cleared or changed that reference. The app worked for months on real order edits, and nobody noticed the metadata path was silent, until a support case fell out of sync because a cleared reference never reached the helpdesk.

Running the checker found the webhook subscribed only to ORDER_UPDATED. Adding ORDER_METADATA_UPDATED to the subscription in dry run first, then for real, closed the gap without touching a single order.

Fulfillment partner

A 3PL id written to metadata never triggered a resync

A fulfillment integration stamped orders with a 3PL shipment id using updatePrivateMetadata right after handing the order off, expecting its own ORDER_UPDATED listener to pick up the change and mirror the id internally. It never did, and the team spent a week assuming their webhook endpoint was flaky before finding the event simply never fires for metadata.

The scheduled check flagged the subscription as misconfigured on the first run, and cross-checking recent orders against the delivery log confirmed there had never been a single ORDER_METADATA_UPDATED delivery, not a broken endpoint, a missing subscription.

What good looks like

After this runs on a schedule, a metadata write that Saleor was always going to announce through a different event gets caught the same day the subscription gap exists, instead of surfacing weeks later as a silently stale CRM record or fulfillment reference. The team gets the exact webhook id, its current subscribed events, and the missing entry, and any historical order-level gap stays a deliberate manual reconciliation, never a guess at replaying something Saleor cannot replay.

FAQ

Why does ORDER_UPDATED not fire when I only change an order's metadata?

Saleor's webhook dispatch logic checks for substantive order field changes before firing ORDER_UPDATED, and a metadata-only write never satisfies that check. Saleor treats metadata as a separate concern from the order itself, so updateMetadata and updatePrivateMetadata emit a dedicated ORDER_METADATA_UPDATED event instead, by design, not as a bug.

How do I detect an app that is missing the metadata webhook?

Pull each webhook's asyncEvents or parse its subscriptionQuery for the event names it actually subscribes to. Separately pull orders sorted by lastModifiedAt with their metadata, and compare each order's last metadata write time against that webhook's eventDeliveries filtered to ORDER_UPDATED and ORDER_METADATA_UPDATED. A webhook whose subscription lists ORDER_UPDATED but omits ORDER_METADATA_UPDATED is misconfigured for this case, and an order with a metadata write but no matching delivery in the correct subscription is a separate delivery failure.

Can I make Saleor retroactively fire ORDER_UPDATED for past metadata changes?

No. Saleor has no mutation that replays a past async event, and this separation between ORDER_UPDATED and ORDER_METADATA_UPDATED is documented, intentional behavior rather than a bug to patch. The fix is a one-time config change, adding ORDER_METADATA_UPDATED to the webhook's subscription, and any historical gap is a manual reconciliation you report to the integration owner, since Saleor's API does not expose a way to replay a delivery.

Related field notes

Citations

On the problem:

  1. Update order's metadata & private metadata do not trigger ORDER_UPDATED async webhook event. github.com/saleor/saleor/issues/10166
  2. Bug: Stock update webhook is not triggered. github.com/saleor/saleor/issues/11637
  3. Saleor Commerce Documentation: Webhooks Troubleshooting. docs.saleor.io/developer/extending/webhooks/troubleshooting

On the solution:

  1. Saleor Commerce Documentation: OrderMetadataUpdated Object. docs.saleor.io/api-reference/orders/objects/order-metadata-updated
  2. Saleor Commerce Documentation: Webhook Object. docs.saleor.io/api-reference/webhooks/objects/webhook
  3. Saleor Commerce Documentation: WebhookEventTypeEnum Enum. docs.saleor.io/api-reference/webhooks/enums/webhook-event-type-enum
  4. Saleor Commerce Documentation: Webhooks Overview. docs.saleor.io/developer/extending/webhooks/overview

Stuck on a tricky one?

If you have a problem in Saleor checkout, stock, channels, 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 missing subscription for you?

If this saved you from chasing a phantom webhook bug, 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 Saleor field notes