Diagnostic Order status and webhooks

The Stripe webhook is never delivered, so no order updates

Orders come in, payments succeed on Stripe, and nothing moves in WooCommerce. When every order behaves this way, the usual cause is the webhook itself. The endpoint is wrong, it is disabled, or it does not listen for the events WooCommerce needs. This guide checks all three with a short script and shows you exactly where the wiring is broken.

Python and Node.js Read only diagnostic No writes to your store
Close-up of server cooling fans in a vibrant data center.
Photo by Winston Chen on Unsplash
The short answer

If no order ever updates, the webhook is almost always the problem. Use the Stripe API to list your webhook endpoints and confirm one is enabled, points at your store URL, and subscribes to the events WooCommerce needs. Then list recent events and look for delivery attempts that did not return a 2xx. The script below reports each of these so you know what to fix.

The problem in plain words

A webhook is a message Stripe sends to your store after a payment. WooCommerce uses it to move the order to Processing. For that message to arrive, you must create a webhook endpoint in Stripe that points at your store and listens for the right events. If any part of that is wrong, the message never lands and orders stay on Pending.

There are three common ways this breaks. The endpoint URL is wrong or points at an old domain. The endpoint exists but is disabled. Or the endpoint is enabled but does not subscribe to the events WooCommerce cares about, so the important ones are never sent.

Stripe event payment succeeded Endpoint problem wrong url, disabled, or missing events WooCommerce never notified Pending forever
If the endpoint is not exactly right, the event is created on Stripe but never reaches WooCommerce.

Why it happens

The WooCommerce docs are direct about this. The plugin updates orders automatically only when webhooks are set up correctly. A store move to a new domain, a staging clone that copied the wrong endpoint, or an endpoint created with only a couple of events selected will all leave orders stranded. The citations at the end link the plugin docs and the setup guide.

The key insight

You do not have to guess. Stripe can tell you every webhook endpoint on the account, whether it is enabled, what events it listens for, and whether recent deliveries succeeded. A read only script turns a vague no orders are updating into a precise here is the misconfigured endpoint.

The fix, as a flow

We ask Stripe for the list of webhook endpoints and check each one against three rules. Is it enabled, does it point at your store domain, and does it listen for the events WooCommerce needs. Then we look at recent events for failed deliveries. The output is a short report you act on by hand, since fixing the endpoint is a one time change in the dashboard.

List endpoints from Stripe Check the three enabled, url, events Recent events failed deliveries Report what to fix
A read only pass over your endpoints and recent events points straight at the broken piece.

Build it step by step

1

Decide what a healthy endpoint looks like

Keep the rules in one pure function. An endpoint is healthy when it is enabled, its URL is on your store domain, and its events cover the ones WooCommerce needs. An endpoint that listens for all events, shown as a single star, also passes.

check.py
from urllib.parse import urlparse

REQUIRED = {"payment_intent.succeeded", "payment_intent.payment_failed",
            "charge.succeeded", "charge.refunded"}

def endpoint_health(endpoint, store_host):
    events = set(endpoint.get("enabled_events", []))
    covers = "*" in events or REQUIRED.issubset(events)
    host_ok = urlparse(endpoint.get("url", "")).netloc == store_host
    return {
        "id": endpoint["id"],
        "enabled": endpoint["status"] == "enabled",
        "points_at_store": host_ok,
        "covers_events": covers,
        "missing_events": sorted(REQUIRED - events) if not covers else [],
    }
check.js
const REQUIRED = ["payment_intent.succeeded", "payment_intent.payment_failed",
                  "charge.succeeded", "charge.refunded"];

export function endpointHealth(endpoint, storeHost) {
  const events = new Set(endpoint.enabled_events || []);
  const covers = events.has("*") || REQUIRED.every((e) => events.has(e));
  const hostOk = new URL(endpoint.url || "http://none").host === storeHost;
  return {
    id: endpoint.id,
    enabled: endpoint.status === "enabled",
    pointsAtStore: hostOk,
    coversEvents: covers,
    missingEvents: covers ? [] : REQUIRED.filter((e) => !events.has(e)),
  };
}
2

Ask Stripe for the endpoints and check each one

List every webhook endpoint on the account and run the health check. Any endpoint that is disabled, points at the wrong domain, or misses events is worth flagging.

step2.py
import stripe

def check_endpoints(store_host):
    reports = []
    for endpoint in stripe.WebhookEndpoint.list(limit=100).auto_paging_iter():
        reports.append(endpoint_health(endpoint, store_host))
    return reports
step2.js
export async function checkEndpoints(stripe, storeHost) {
  const reports = [];
  for await (const endpoint of stripe.webhookEndpoints.list({ limit: 100 })) {
    reports.push(endpointHealth(endpoint, storeHost));
  }
  return reports;
}
3

Look for events that failed to deliver

A healthy looking endpoint can still be failing on the wire. Each Stripe event carries a pending_webhooks count. When it stays above zero, the event has not been delivered and accepted. Counting recent events with pending deliveries tells you the endpoint is not returning a success response.

step3.py
def undelivered_recent(limit=100):
    pending = 0
    total = 0
    for event in stripe.Event.list(limit=limit).auto_paging_iter():
        total += 1
        if event.get("pending_webhooks", 0) > 0:
            pending += 1
    return {"checked": total, "still_pending": pending}
step3.js
export async function undeliveredRecent(stripe, limit = 100) {
  let pending = 0, total = 0;
  for await (const event of stripe.events.list({ limit })) {
    total++;
    if ((event.pending_webhooks || 0) > 0) pending++;
  }
  return { checked: total, stillPending: pending };
}
This one only reads

This script never changes your store or your Stripe account. It reads and reports. Fixing the endpoint is a one time change you make in the Stripe dashboard using the WooCommerce Stripe webhook URL from your plugin settings.

The full code

The complete diagnostic lists your endpoints, checks the three rules, counts recent undelivered events, and prints a short report. Read it, fix the endpoint that is flagged, then send a test event from Stripe to confirm.

View this code on GitHub Full runnable folder with tests in the woocommerce-fixes repo.

check_webhook.py
"""Check whether the Stripe webhook that WooCommerce depends on is set up right.
Read only. It reports problems, it does not change anything.
"""
import os
from urllib.parse import urlparse
import stripe

stripe.api_key = os.environ["STRIPE_SECRET_KEY"]
STORE_HOST = urlparse(os.environ["WOO_STORE_URL"]).netloc

REQUIRED = {"payment_intent.succeeded", "payment_intent.payment_failed",
            "charge.succeeded", "charge.refunded"}


def endpoint_health(endpoint, store_host):
    events = set(endpoint.get("enabled_events", []))
    covers = "*" in events or REQUIRED.issubset(events)
    host_ok = urlparse(endpoint.get("url", "")).netloc == store_host
    return {
        "id": endpoint["id"],
        "url": endpoint.get("url", ""),
        "enabled": endpoint["status"] == "enabled",
        "points_at_store": host_ok,
        "covers_events": covers,
        "missing_events": sorted(REQUIRED - events) if not covers else [],
    }


def check_endpoints(store_host):
    return [endpoint_health(e, store_host)
            for e in stripe.WebhookEndpoint.list(limit=100).auto_paging_iter()]


def undelivered_recent(limit=100):
    pending = total = 0
    for event in stripe.Event.list(limit=limit).auto_paging_iter():
        total += 1
        if event.get("pending_webhooks", 0) > 0:
            pending += 1
    return {"checked": total, "still_pending": pending}


def run():
    reports = check_endpoints(STORE_HOST)
    healthy = [r for r in reports if r["enabled"] and r["points_at_store"] and r["covers_events"]]
    print(f"Found {len(reports)} endpoint(s). {len(healthy)} healthy for this store.")
    for r in reports:
        flags = []
        if not r["enabled"]:
            flags.append("disabled")
        if not r["points_at_store"]:
            flags.append("wrong domain")
        if not r["covers_events"]:
            flags.append("missing events: " + ", ".join(r["missing_events"]))
        status = "OK" if not flags else "PROBLEM: " + "; ".join(flags)
        print(f"  {r['id']}  {r['url']}  ->  {status}")
    if not healthy:
        print("No healthy endpoint points at this store. That is why orders do not update.")
    delivery = undelivered_recent()
    print(f"Recent events checked: {delivery['checked']}, still pending delivery: {delivery['still_pending']}")
    if delivery["still_pending"]:
        print("Events are not being accepted by your endpoint. Check for a firewall, CDN, or a server error.")


if __name__ == "__main__":
    run()
check-webhook.js
/**
 * Check whether the Stripe webhook that WooCommerce depends on is set up right.
 * Read only. It reports problems, it does not change anything.
 */
import Stripe from "stripe";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const STORE_HOST = new URL(process.env.WOO_STORE_URL).host;

const REQUIRED = ["payment_intent.succeeded", "payment_intent.payment_failed",
                  "charge.succeeded", "charge.refunded"];

function endpointHealth(endpoint, storeHost) {
  const events = new Set(endpoint.enabled_events || []);
  const covers = events.has("*") || REQUIRED.every((e) => events.has(e));
  let hostOk = false;
  try { hostOk = new URL(endpoint.url).host === storeHost; } catch (e) { hostOk = false; }
  return {
    id: endpoint.id,
    url: endpoint.url || "",
    enabled: endpoint.status === "enabled",
    pointsAtStore: hostOk,
    coversEvents: covers,
    missingEvents: covers ? [] : REQUIRED.filter((e) => !events.has(e)),
  };
}

async function checkEndpoints(storeHost) {
  const reports = [];
  for await (const endpoint of stripe.webhookEndpoints.list({ limit: 100 })) {
    reports.push(endpointHealth(endpoint, storeHost));
  }
  return reports;
}

async function undeliveredRecent(limit = 100) {
  let pending = 0, total = 0;
  for await (const event of stripe.events.list({ limit })) {
    total++;
    if ((event.pending_webhooks || 0) > 0) pending++;
  }
  return { checked: total, stillPending: pending };
}

async function run() {
  const reports = await checkEndpoints(STORE_HOST);
  const healthy = reports.filter((r) => r.enabled && r.pointsAtStore && r.coversEvents);
  console.log(`Found ${reports.length} endpoint(s). ${healthy.length} healthy for this store.`);
  for (const r of reports) {
    const flags = [];
    if (!r.enabled) flags.push("disabled");
    if (!r.pointsAtStore) flags.push("wrong domain");
    if (!r.coversEvents) flags.push("missing events: " + r.missingEvents.join(", "));
    console.log(`  ${r.id}  ${r.url}  ->  ${flags.length ? "PROBLEM: " + flags.join("; ") : "OK"}`);
  }
  if (!healthy.length) console.log("No healthy endpoint points at this store. That is why orders do not update.");
  const delivery = await undeliveredRecent();
  console.log(`Recent events checked: ${delivery.checked}, still pending delivery: ${delivery.stillPending}`);
  if (delivery.stillPending) {
    console.log("Events are not being accepted by your endpoint. Check for a firewall, CDN, or a server error.");
  }
}

run().catch((err) => { console.error(err); process.exit(1); });

Add a test

The health rule is the piece to test, since it decides what counts as a broken endpoint. It is pure, so the test just passes in endpoint objects and checks the result.

test_check.py
from check_webhook import endpoint_health

STORE = "shop.example.com"
ALL_EVENTS = ["payment_intent.succeeded", "payment_intent.payment_failed",
              "charge.succeeded", "charge.refunded"]


def ep(**over):
    base = {"id": "we_1", "status": "enabled",
            "url": "https://shop.example.com/?wc-ajax=wc_stripe",
            "enabled_events": ALL_EVENTS}
    base.update(over)
    return base


def test_healthy_endpoint_passes():
    r = endpoint_health(ep(), STORE)
    assert r["enabled"] and r["points_at_store"] and r["covers_events"]


def test_disabled_is_flagged():
    assert endpoint_health(ep(status="disabled"), STORE)["enabled"] is False


def test_wrong_domain_is_flagged():
    r = endpoint_health(ep(url="https://old-domain.com/?wc-ajax=wc_stripe"), STORE)
    assert r["points_at_store"] is False


def test_missing_events_are_listed():
    r = endpoint_health(ep(enabled_events=["charge.succeeded"]), STORE)
    assert "payment_intent.succeeded" in r["missing_events"]


def test_star_covers_everything():
    assert endpoint_health(ep(enabled_events=["*"]), STORE)["covers_events"] is True
check.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { endpointHealth } from "./check.js";

const STORE = "shop.example.com";
const ALL = ["payment_intent.succeeded", "payment_intent.payment_failed",
             "charge.succeeded", "charge.refunded"];

const ep = (over = {}) => ({
  id: "we_1", status: "enabled",
  url: "https://shop.example.com/?wc-ajax=wc_stripe",
  enabled_events: ALL, ...over,
});

test("healthy endpoint passes", () => {
  const r = endpointHealth(ep(), STORE);
  assert.ok(r.enabled && r.pointsAtStore && r.coversEvents);
});

test("disabled is flagged", () => {
  assert.equal(endpointHealth(ep({ status: "disabled" }), STORE).enabled, false);
});

test("wrong domain is flagged", () => {
  assert.equal(endpointHealth(ep({ url: "https://old.com/?wc-ajax=wc_stripe" }), STORE).pointsAtStore, false);
});

test("missing events are listed", () => {
  const r = endpointHealth(ep({ enabled_events: ["charge.succeeded"] }), STORE);
  assert.ok(r.missingEvents.includes("payment_intent.succeeded"));
});

test("star covers everything", () => {
  assert.equal(endpointHealth(ep({ enabled_events: ["*"] }), STORE).coversEvents, true);
});

Case studies

Domain move

The endpoint that pointed at the old site

A shop moved from a myshop.wpengine.com staging address to its real domain. Payments worked, but no order updated. The endpoint in Stripe still pointed at the old staging URL, so every event went nowhere.

The diagnostic printed the endpoint with wrong domain in one line. The team created a new endpoint on the real domain, and orders started updating within minutes.

Too few events

The endpoint that only listened for refunds

An endpoint was set up quickly with only charge.refunded selected. Refunds synced fine, which hid the problem, but new orders never moved because payment_intent.succeeded was never sent.

The report listed the missing events by name. Adding them, or switching the endpoint to all events, fixed new orders at once.

What good looks like

After the fix you should see one enabled endpoint on your store domain that listens for the needed events, and recent events showing zero still pending delivery. Send a test webhook from the Stripe dashboard to confirm the round trip end to end.

FAQ

How do I know if my Stripe webhook is set up correctly?

List your webhook endpoints with the Stripe API and confirm one is enabled, points at your store URL, and subscribes to the events WooCommerce needs, such as payment_intent.succeeded and charge.refunded. Then list recent events and check for delivery attempts that did not return a 2xx.

Which Stripe events does the WooCommerce Stripe plugin need?

At a minimum payment_intent.succeeded, payment_intent.payment_failed, charge.succeeded, and charge.refunded. Subscribing to all events also works and is the simplest way to stay correct.

Where is the WooCommerce Stripe webhook URL?

It is shown in WooCommerce, Settings, Payments, Stripe, under the webhook section. The endpoint you create in Stripe must point at that exact URL on your store domain.

Related field notes

Citations

On the problem:

  1. WooCommerce docs: automatic order status updates depend on webhooks being configured. woocommerce.com/document/stripe
  2. WooCommerce Stripe plugin issue: orders not updating when the webhook is misconfigured. github.com/woocommerce/woocommerce-gateway-stripe/issues/3342
  3. Guide: troubleshooting Stripe webhook delivery on WooCommerce. woohelpdesk.com

On the solution:

  1. Stripe API: list webhook endpoints and read enabled_events and status. docs.stripe.com/api/webhook_endpoints/list
  2. Stripe API: the Event object and pending_webhooks. docs.stripe.com/api/events/object
  3. Stripe docs: build and test a webhook endpoint. docs.stripe.com/webhooks

Stuck on a tricky one?

If you have a bug in WooCommerce, WooCommerce Subscriptions, or the WooCommerce Stripe gateway 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 find your broken webhook?

If this pointed you straight at the misconfigured endpoint, you can buy me a coffee. It keeps these field notes free and growing.

Buy me a coffee on Ko-fi

Back to all WooCommerce and Stripe field notes