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.
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.
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.
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.
Build it step by step
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.
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
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;
}
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.
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()
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;
}
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.
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)
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 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 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 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.
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
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
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.
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.
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:
- Stripe docs: webhook delivery, retries, and the three day window. docs.stripe.com/webhooks
- Stripe docs: process undelivered events after downtime. docs.stripe.com/webhooks/process-undelivered-events
- WooCommerce docs: how Stripe webhooks keep orders in sync. woocommerce.com/document/stripe
On the solution:
- Stripe API: list events with a delivery_success filter. docs.stripe.com/api/events/list
- Stripe docs: idempotency and safely reprocessing events. docs.stripe.com/webhooks
- 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.
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