Reconciler Order status and webhooks

Replay the Stripe webhook events WooCommerce missed during downtime

Your store went down, maybe for maintenance or an outage, for longer than Stripe keeps retrying. When it came back, a batch of orders were out of sync because the events that would have updated them were never delivered. This guide pulls the undelivered events from Stripe and reapplies them, so your orders catch up to where they should be.

Python and Node.js Catch up after downtime Idempotent by design
A rack of servers in a server room
Photo by Kevin Ache on Unsplash
The short answer

Stripe retries a failed webhook for about three days, then gives up. If your store is down longer, those events never arrive. List events with delivery_success set to false over the downtime window, reapply each one to WooCommerce, such as completing an order for a succeeded payment, and track the event IDs so nothing runs twice. The full code is below.

The problem in plain words

WooCommerce relies on Stripe webhooks to keep orders current. When your store cannot receive a webhook, Stripe retries it for a while, but only for about three days. If the store is down for longer than that, Stripe stops trying and the event is gone for good.

Every order that one of those lost events would have touched is now stuck in the past. Paid orders sit on Pending, refunds do not show, and the longer the outage, the bigger the gap between Stripe and your store.

Store down several days Stripe retries 3 days then stops Events lost never delivered Orders drift out of sync
Past the retry window, the events are gone, and every order they would have updated is left behind.

Why it happens

Stripe documents that it retries webhook deliveries for up to about three days, then stops. It also provides a way to find events that were never delivered, so you can process them yourself. This is the intended recovery path after downtime. The citations at the end link the docs.

The key insight

Stripe still has every event, even the ones it could not deliver. You can list events with delivery_success set to false and replay them. As long as each action is idempotent, meaning applying it twice is the same as once, replaying is completely safe. Completing an order that is already paid changes nothing.

The fix, as a flow

We list undelivered events over the downtime window, work out the action each one implies, and apply it to WooCommerce. We keep a record of the event IDs we have processed so a rerun never applies the same event twice. The most common actions are completing an order for a succeeded payment and recording a refund.

Undelivered events from Stripe Read action and order id Apply to Woo idempotent Remember id no double apply
Reapply each missed event, and track its ID so a rerun is always safe.

Build it step by step

1

Read the action from an event with a pure function

Keep the mapping in one function. It takes an event and returns the action and the order it applies to, or nothing when the event type does not need handling. This is easy to read and easy to test.

extract.py
def extract_action(event):
    obj = event.get("data", {}).get("object", {})
    order_id = (obj.get("metadata") or {}).get("order_id")
    if not order_id:
        return None
    if event["type"] == "payment_intent.succeeded":
        return ("complete", order_id, obj)
    if event["type"] == "charge.refunded":
        return ("refund", order_id, obj)
    return None
extract.js
export function extractAction(event) {
  const obj = (event.data && event.data.object) || {};
  const orderId = (obj.metadata || {}).order_id;
  if (!orderId) return null;
  if (event.type === "payment_intent.succeeded") return { action: "complete", orderId, obj };
  if (event.type === "charge.refunded") return { action: "refund", orderId, obj };
  return null;
}
2

List the events Stripe could not deliver

Ask Stripe for events over the downtime window with delivery_success set to false. Those are the ones your store never received. Keep only the types we know how to reapply.

step2.py
import time, stripe

def undelivered_events(lookback_hours):
    since = int(time.time()) - lookback_hours * 3600
    return stripe.Event.list(
        limit=100, created={"gte": since}, delivery_success=False,
        types=["payment_intent.succeeded", "charge.refunded"],
    ).auto_paging_iter()
step2.js
export async function* undeliveredEvents(stripe, lookbackHours) {
  const since = Math.floor(Date.now() / 1000) - lookbackHours * 3600;
  const params = { limit: 100, created: { gte: since }, delivery_success: false,
                   types: ["payment_intent.succeeded", "charge.refunded"] };
  for await (const event of stripe.events.list(params)) yield event;
}
3

Apply each event, once

For a succeeded payment, complete the order if it is not already paid. For a refund, record what is missing. Keep a set of the event IDs you have handled so a rerun skips anything already applied. Because completing a paid order is a no-op, replaying is safe even without the ledger, but the ledger keeps the logs clean.

apply.py
def apply_event(action, order_id, obj):
    order = get_order(order_id)
    if not order:
        return
    if action == "complete" and order["status"] not in PAID_STATUSES:
        charge_id = obj.get("latest_charge") or obj["id"]
        put_order(order_id, {"status": "processing", "transaction_id": charge_id})
        add_note(order_id, "Replayed a missed Stripe payment event. Marked processing.")
    elif action == "refund":
        # record any refund the order is missing (api_refund false, never refunds twice)
        sync_refund(order, obj)
apply.js
async function applyEvent(action, orderId, obj) {
  const order = await woo(`/orders/${orderId}`);
  if (!order) return;
  if (action === "complete" && !PAID_STATUSES.has(order.status)) {
    const chargeId = obj.latest_charge || obj.id;
    await woo(`/orders/${orderId}`, { method: "PUT", body: JSON.stringify({ status: "processing", transaction_id: chargeId }) });
    await woo(`/orders/${orderId}/notes`, { method: "POST", body: JSON.stringify({ note: "Replayed a missed Stripe payment event. Marked processing." }) });
  } else if (action === "refund") {
    await syncRefund(order, obj);
  }
}
Set the window to the outage

Set the lookback to cover your downtime, plus a margin. Too short a window misses events; too long a window is just slower, since already-applied events are no-ops. Always run with DRY_RUN=true first and read what it would reapply.

The full code

The complete catch-up reads undelivered events over your window, reapplies the ones it understands, and tracks event IDs so a rerun is always safe. It leans on the same order completion and refund recording used by the other reconcilers.

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

replay_events.py
"""Replay Stripe webhook events WooCommerce missed during downtime.
Idempotent. Run once after an outage, or on a schedule as a safety net.

Guide: https://www.allanninal.dev/woocommerce/replay-missed-stripe-webhook-events/
"""
import os
import time
import logging
import stripe
import requests
from requests.auth import HTTPBasicAuth

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("replay_events")

stripe.api_key = os.environ["STRIPE_SECRET_KEY"]
WOO_URL = os.environ["WOO_STORE_URL"].rstrip("/")
AUTH = HTTPBasicAuth(os.environ["WOO_CONSUMER_KEY"], os.environ["WOO_CONSUMER_SECRET"])
LOOKBACK_HOURS = int(os.environ.get("LOOKBACK_HOURS", "120"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"

PAID_STATUSES = {"processing", "completed"}


def extract_action(event):
    obj = event.get("data", {}).get("object", {})
    order_id = (obj.get("metadata") or {}).get("order_id")
    if not order_id:
        return None
    if event["type"] == "payment_intent.succeeded":
        return ("complete", order_id, obj)
    if event["type"] == "charge.refunded":
        return ("refund", order_id, obj)
    return None


def undelivered_events(lookback_hours):
    since = int(time.time()) - lookback_hours * 3600
    return stripe.Event.list(
        limit=100, created={"gte": since}, delivery_success=False,
        types=["payment_intent.succeeded", "charge.refunded"],
    ).auto_paging_iter()


def get_order(order_id):
    r = requests.get(f"{WOO_URL}/wp-json/wc/v3/orders/{order_id}", auth=AUTH, timeout=30)
    if r.status_code == 404:
        return None
    r.raise_for_status()
    return r.json()


def put_order(order_id, body):
    requests.put(f"{WOO_URL}/wp-json/wc/v3/orders/{order_id}", json=body, auth=AUTH, timeout=30).raise_for_status()


def add_note(order_id, note):
    requests.post(f"{WOO_URL}/wp-json/wc/v3/orders/{order_id}/notes",
                  json={"note": note}, auth=AUTH, timeout=30).raise_for_status()


def sync_refund(order, charge):
    order_total_minor = round(float(order["total"]) * 100)
    stripe_refunded = charge.get("amount_refunded", 0)
    wc_refunded = sum(round(abs(float(r["total"])) * 100) for r in order.get("refunds", []))
    missing = stripe_refunded - wc_refunded
    if missing > 1:
        requests.post(f"{WOO_URL}/wp-json/wc/v3/orders/{order['id']}/refunds",
                      json={"amount": f"{missing / 100:.2f}", "api_refund": False,
                            "reason": "Replayed a missed Stripe refund event."},
                      auth=AUTH, timeout=30).raise_for_status()
        if stripe_refunded >= order_total_minor and order["status"] != "refunded":
            put_order(order["id"], {"status": "refunded"})


def apply_event(action, order_id, obj):
    order = get_order(order_id)
    if not order:
        return False
    if action == "complete" and order["status"] not in PAID_STATUSES:
        charge_id = obj.get("latest_charge") or obj["id"]
        put_order(order_id, {"status": "processing", "transaction_id": charge_id})
        add_note(order_id, "Replayed a missed Stripe payment event. Marked processing.")
        return True
    if action == "refund":
        sync_refund(order, obj)
        return True
    return False


def run():
    seen = set()
    applied = 0
    for event in undelivered_events(LOOKBACK_HOURS):
        if event["id"] in seen:
            continue
        seen.add(event["id"])
        parsed = extract_action(event)
        if not parsed:
            continue
        action, order_id, obj = parsed
        log.info("Event %s -> %s order %s. %s", event["id"], action, order_id, "dry run" if DRY_RUN else "applying")
        if not DRY_RUN and apply_event(action, order_id, obj):
            applied += 1
    log.info("Done. %d event(s) %s.", applied, "found" if DRY_RUN else "reapplied")


if __name__ == "__main__":
    run()
replay-events.js
/**
 * Replay Stripe webhook events WooCommerce missed during downtime.
 * Idempotent. Run once after an outage, or on a schedule as a safety net.
 *
 * Guide: https://www.allanninal.dev/woocommerce/replay-missed-stripe-webhook-events/
 */
import Stripe from "stripe";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const WOO_URL = process.env.WOO_STORE_URL.replace(/\/$/, "");
const AUTH = "Basic " + Buffer.from(
  `${process.env.WOO_CONSUMER_KEY}:${process.env.WOO_CONSUMER_SECRET}`
).toString("base64");
const LOOKBACK_HOURS = Number(process.env.LOOKBACK_HOURS || 120);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";

const PAID_STATUSES = new Set(["processing", "completed"]);

export function extractAction(event) {
  const obj = (event.data && event.data.object) || {};
  const orderId = (obj.metadata || {}).order_id;
  if (!orderId) return null;
  if (event.type === "payment_intent.succeeded") return { action: "complete", orderId, obj };
  if (event.type === "charge.refunded") return { action: "refund", orderId, obj };
  return null;
}

async function woo(path, options = {}) {
  const res = await fetch(`${WOO_URL}/wp-json/wc/v3${path}`, {
    ...options,
    headers: { "Content-Type": "application/json", Authorization: AUTH, ...(options.headers || {}) },
  });
  if (res.status === 404) return null;
  if (!res.ok) throw new Error(`Woo ${path} returned ${res.status}`);
  return res.json();
}

async function* undeliveredEvents(lookbackHours) {
  const since = Math.floor(Date.now() / 1000) - lookbackHours * 3600;
  const params = { limit: 100, created: { gte: since }, delivery_success: false,
                   types: ["payment_intent.succeeded", "charge.refunded"] };
  for await (const event of stripe.events.list(params)) yield event;
}

async function syncRefund(order, charge) {
  const orderTotalMinor = Math.round(parseFloat(order.total) * 100);
  const stripeRefunded = charge.amount_refunded || 0;
  const wcRefunded = (order.refunds || []).reduce((s, r) => s + Math.round(Math.abs(parseFloat(r.total)) * 100), 0);
  const missing = stripeRefunded - wcRefunded;
  if (missing > 1) {
    await woo(`/orders/${order.id}/refunds`, { method: "POST", body: JSON.stringify({ amount: (missing / 100).toFixed(2), api_refund: false, reason: "Replayed a missed Stripe refund event." }) });
    if (stripeRefunded >= orderTotalMinor && order.status !== "refunded") {
      await woo(`/orders/${order.id}`, { method: "PUT", body: JSON.stringify({ status: "refunded" }) });
    }
  }
}

async function applyEvent(action, orderId, obj) {
  const order = await woo(`/orders/${orderId}`);
  if (!order) return false;
  if (action === "complete" && !PAID_STATUSES.has(order.status)) {
    const chargeId = obj.latest_charge || obj.id;
    await woo(`/orders/${orderId}`, { method: "PUT", body: JSON.stringify({ status: "processing", transaction_id: chargeId }) });
    await woo(`/orders/${orderId}/notes`, { method: "POST", body: JSON.stringify({ note: "Replayed a missed Stripe payment event. Marked processing." }) });
    return true;
  }
  if (action === "refund") { await syncRefund(order, obj); return true; }
  return false;
}

async function run() {
  const seen = new Set();
  let applied = 0;
  for await (const event of undeliveredEvents(LOOKBACK_HOURS)) {
    if (seen.has(event.id)) continue;
    seen.add(event.id);
    const parsed = extractAction(event);
    if (!parsed) continue;
    const { action, orderId, obj } = parsed;
    console.log(`Event ${event.id} -> ${action} order ${orderId}. ${DRY_RUN ? "dry run" : "applying"}`);
    if (!DRY_RUN && await applyEvent(action, orderId, obj)) applied++;
  }
  console.log(`Done. ${applied} event(s) ${DRY_RUN ? "found" : "reapplied"}.`);
}

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

Add a test

The function that reads the action from an event is the piece to test, since it decides what each event does. It is pure, so the test just passes in event objects.

test_extract.py
from replay_events import extract_action


def event(t, order_id="42"):
    return {"id": "evt_1", "type": t,
            "data": {"object": {"id": "pi_1", "metadata": {"order_id": order_id}}}}


def test_complete_from_succeeded():
    assert extract_action(event("payment_intent.succeeded"))[0] == "complete"


def test_refund_from_charge_refunded():
    assert extract_action(event("charge.refunded"))[0] == "refund"


def test_none_for_unknown_type():
    assert extract_action(event("customer.created")) is None


def test_none_without_order_id():
    e = {"id": "evt_2", "type": "payment_intent.succeeded", "data": {"object": {"metadata": {}}}}
    assert extract_action(e) is None
extract.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { extractAction } from "./replay-events.js";

const event = (t, orderId = "42") => ({
  id: "evt_1", type: t, data: { object: { id: "pi_1", metadata: { order_id: orderId } } },
});

test("complete from succeeded", () => {
  assert.equal(extractAction(event("payment_intent.succeeded")).action, "complete");
});

test("refund from charge refunded", () => {
  assert.equal(extractAction(event("charge.refunded")).action, "refund");
});

test("none for unknown type", () => {
  assert.equal(extractAction(event("customer.created")), null);
});

test("none without order id", () => {
  assert.equal(extractAction({ id: "evt_2", type: "payment_intent.succeeded", data: { object: { metadata: {} } } }), null);
});

Case studies

Long outage

The five day maintenance window

A store moved hosts and was offline for five days. Stripe stopped retrying after three, so two days of payment and refund events never arrived. Dozens of paid orders sat on Pending when the site returned.

The catch-up listed the undelivered events over the outage window and reapplied them. The paid orders moved to Processing and the refunds recorded, all in one run.

Safety net

The nightly guard against gaps

A store that had been burned once added the catch-up to a nightly schedule. Even if a webhook failed quietly during a short blip, the next run picked up any undelivered event.

Because every action is idempotent, the nightly run is harmless when there is nothing to do, and a reliable rescue when there is.

What good looks like

After a run, the orders that downtime left behind are caught up to match Stripe. Kept on a schedule, this becomes a safety net that closes any gap a missed webhook would otherwise leave. Pair it with the reconciler for paid orders stuck on Pending for full coverage.

FAQ

What happens to Stripe webhooks when my store is down?

Stripe retries a failed webhook for about three days. If your store is down longer than that, Stripe stops retrying and those events are never delivered, so the orders they would have updated drift out of sync.

How do I recover events I missed?

List events from Stripe with delivery_success set to false over the downtime window, then reapply each one to WooCommerce, such as completing an order for a succeeded payment. Track the event IDs so nothing is applied twice.

Is it safe to reapply events?

Yes, when each action is idempotent. Completing an order that is already paid is a no-op, so replaying a duplicate event does no harm, and a processed-event ledger keeps it clean.

Related field notes

Citations

On the problem:

  1. Stripe docs: webhook delivery, retries, and the three day window. docs.stripe.com/webhooks
  2. Stripe docs: process undelivered events after downtime. docs.stripe.com/webhooks/process-undelivered-events
  3. WooCommerce docs: how Stripe webhooks keep orders in sync. woocommerce.com/document/stripe

On the solution:

  1. Stripe API: list events with a delivery_success filter. docs.stripe.com/api/events/list
  2. Stripe docs: idempotency and safely reprocessing events. docs.stripe.com/webhooks
  3. WooCommerce REST API: update an order and add an order note. woocommerce.github.io/woocommerce-rest-api-docs

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 catch your orders up?

If this recovered the events your store missed during downtime, 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