Repair Order status and webhooks

The same Stripe webhook event ran twice and left double notes on the order

Support flags an order with two identical payment notes, or a customer says they got two "your order shipped" emails, or stock dropped by four when only two were bought. Nothing is actually broken with the payment. The same Stripe event simply got applied to the order more than once. Here is why that happens and a small event ledger that keeps every webhook handler safely idempotent.

Python and Node.js Runs inside your webhook handler Safe by default (dry run)
A rack of electronic equipment in a dark room
Photo by Tyler on Unsplash
The short answer

Stripe resends a webhook whenever it does not get a fast, successful response, and the exact same event can also come back from a dashboard resend or a replayed queue. Every Stripe event has a stable, unique id. Keep a small ledger of event ids you have already applied to each order, stored right on the order's own meta data, and skip any id you have already seen before doing any work. Full code, tests, and a dry run guard are below.

The problem in plain words

Stripe does not promise to send a webhook exactly once. It promises to send it at least once. If your store is slow to answer, returns a timeout, or briefly errors while handling the event, Stripe assumes the delivery failed and tries again later. A merchant can also click "Resend" on an event in the Stripe dashboard, or a queue worker can replay a batch of events after an outage.

None of that is a Stripe bug. It is the webhook contract working as designed. The bug is on the receiving end when the handler treats every delivery as brand new work instead of asking "have I already done this?" first. The payment itself is fine. It is the order note, the stock decrement, or the confirmation email that gets applied a second time.

Stripe event evt_1 (first delivery) Handler applies it note + stock + email Slow response or brief error, no fast 2xx Same event evt_1 (retry) Handler applies it again, no memory Order now has two notes, double stock hit
The retry carries the exact same event id, but the handler has no memory of the first delivery, so it does the work all over again.

Why it happens

Stripe's own webhook docs are direct about this: your endpoint can receive the same event more than once, and your code must be able to handle that safely. A few common ways stores end up with a non-idempotent handler:

This is a known and expected part of using webhooks, not a rare edge case. Stripe explicitly documents retry behavior and recommends deduplicating on event id. See the citations at the end for the exact pages.

The key insight

Every Stripe event has a stable id like evt_1Nx... that does not change across retries or resends. A handler does not need to guess whether a delivery is new. It only needs to remember which event ids it has already finished for a given order, and skip any id already on that list.

The fix, as a flow

We do not change how Stripe delivers events. We add one cheap check at the top of the handler. Before doing any real work for an event, look up the order it points to and read a small ledger of event ids already applied to that order, which lives in the order's own meta data. If the incoming event id is already in that ledger, skip it and return success right away. If it is new, do the work, then append the id to the ledger.

Webhook event arrives with id Load order's event id ledger id already in ledger? yes, skip Return success no, new Apply the event note, stock, email Append id to ledger
The ledger check runs before any real work, so a repeated delivery costs one cheap read and nothing else.

Build it step by step

1

Get access to both systems

You need a Stripe secret key and a WooCommerce REST API key pair (a consumer key and a consumer secret) with read and write access to orders. Create the WooCommerce key under WooCommerce, Settings, Advanced, REST API. Keep every value in environment variables, never in the file.

setup (shell)
pip install stripe requests

export STRIPE_SECRET_KEY="sk_live_..."
export WOO_STORE_URL="https://yourstore.com"
export WOO_CONSUMER_KEY="ck_..."
export WOO_CONSUMER_SECRET="cs_..."
export LOOKBACK_HOURS="24"
export DRY_RUN="true"   # start safe, change to false to write
setup (shell)
npm install stripe

export STRIPE_SECRET_KEY="sk_live_..."
export WOO_STORE_URL="https://yourstore.com"
export WOO_CONSUMER_KEY="ck_..."
export WOO_CONSUMER_SECRET="cs_..."
export LOOKBACK_HOURS="24"
export DRY_RUN="true"   // start safe, change to false to write
2

Find the order id and load its current ledger

The order id normally lives in the event's metadata.order_id, the same field the checkout writes onto the PaymentIntent. We read the order over the REST API, then pull the ledger of already applied event ids from its meta data. A missing order is worth logging, since it can point to a different problem.

step2.py
import os, requests
from requests.auth import HTTPBasicAuth

WOO_URL = os.environ["WOO_STORE_URL"].rstrip("/")
AUTH = HTTPBasicAuth(os.environ["WOO_CONSUMER_KEY"], os.environ["WOO_CONSUMER_SECRET"])
LEDGER_META_KEY = "_processed_webhook_event_ids"

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 ledger_of(order):
    for meta in order.get("meta_data") or []:
        if meta.get("key") == LEDGER_META_KEY and isinstance(meta.get("value"), list):
            return list(meta["value"])
    return []
step2.js
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 LEDGER_META_KEY = "_processed_webhook_event_ids";

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();
}

function ledgerOf(order) {
  for (const meta of order.meta_data || []) {
    if (meta.key === LEDGER_META_KEY && Array.isArray(meta.value)) return [...meta.value];
  }
  return [];
}
3

Decide, with one pure function

Keep the decision in its own function that takes the order, the event, and the current ledger, and returns an action. A pure function like this is easy to read and easy to test, which we do later. The rule is simple. If the event type is not one we act on, ignore it. If the order is missing, flag it as an orphan. If the event id is already in the ledger, skip it. Otherwise, apply it.

decide.py
APPLIED_EVENT_TYPES = {"payment_intent.succeeded", "charge.succeeded"}

def decide(order, event, ledger):
    if event.get("type") not in APPLIED_EVENT_TYPES:
        return ("ignore", "event type is not handled here")
    if order is None:
        return ("orphan", "order not found for this event")
    if event.get("id") in ledger:
        return ("skip", "event id already applied to this order")
    return ("apply", "new event for this order")
decide.js
const APPLIED_EVENT_TYPES = new Set(["payment_intent.succeeded", "charge.succeeded"]);

export function decide(order, event, ledger) {
  if (!APPLIED_EVENT_TYPES.has(event.type)) return ["ignore", "event type is not handled here"];
  if (!order) return ["orphan", "order not found for this event"];
  if (ledger.includes(event.id)) return ["skip", "event id already applied to this order"];
  return ["apply", "new event for this order"];
}
4

Apply the event once, then record it

When the action is apply, do the real work, an order note in this example, and then append the event id to the ledger and cap its length so the meta value never grows without bound. Only the most recent ids matter for deduplication, so trimming old ones is safe.

apply.py
MAX_LEDGER_SIZE = 50

def next_ledger(ledger, event_id):
    updated = ledger + [event_id]
    return updated[-MAX_LEDGER_SIZE:]

def apply_event(order, event):
    order_id = order["id"]
    requests.post(
        f"{WOO_URL}/wp-json/wc/v3/orders/{order_id}/notes",
        json={"note": f"Stripe event {event['id']} ({event['type']}) applied. "
                      f"Recorded in the webhook event ledger so a retry cannot double it up."},
        auth=AUTH, timeout=30,
    ).raise_for_status()
    ledger = next_ledger(ledger_of(order), event["id"])
    requests.put(
        f"{WOO_URL}/wp-json/wc/v3/orders/{order_id}",
        json={"meta_data": [{"key": LEDGER_META_KEY, "value": ledger}]},
        auth=AUTH, timeout=30,
    ).raise_for_status()
apply.js
const MAX_LEDGER_SIZE = 50;

export function nextLedger(ledger, eventId) {
  const updated = [...ledger, eventId];
  return updated.slice(-MAX_LEDGER_SIZE);
}

async function applyEvent(order, event) {
  await woo(`/orders/${order.id}/notes`, {
    method: "POST",
    body: JSON.stringify({
      note: `Stripe event ${event.id} (${event.type}) applied. ` +
            `Recorded in the webhook event ledger so a retry cannot double it up.`,
    }),
  });
  const ledger = nextLedger(ledgerOf(order), event.id);
  await woo(`/orders/${order.id}`, {
    method: "PUT",
    body: JSON.stringify({ meta_data: [{ key: LEDGER_META_KEY, value: ledger }] }),
  });
}
5

Wire it together with a dry run guard

The loop ties every piece together. It can run inside your live webhook handler for real-time protection, or as a scheduled replay that walks recent Stripe events and repairs anything the live handler missed. Notice the dry run guard. On the first few runs, leave DRY_RUN on so the script only reports what it would do.

Run it safe

Always start with DRY_RUN=true. The ledger writes to real order meta data, so you want to see its plan before it acts. Once the report looks right for a day, turn it off.

The full code

Here is the complete event ledger 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 a repeated event id is always skipped, never reapplied.

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

dedupe_webhook_events.py
"""Stop a Stripe webhook event from being applied to a WooCommerce order twice.

Stripe retries a webhook delivery whenever it does not get a fast 2xx response,
and the same event id can also be redelivered after a Stripe dashboard resend or
a queue replay. If the handler is not idempotent, the same event.id ends up
applying its note, stock change, or email a second (or third) time on the order.

This keeps a small ledger of event ids already applied to each order, read from
and written to the order's own meta data (no separate database needed). Before
doing any work for an incoming event, it checks the ledger. Read only by
default. Run this as the body of your webhook handler, or replay it against
recent events on a schedule to catch anything the live handler missed.
"""
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("dedupe_webhook_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", "24"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"

LEDGER_META_KEY = "_processed_webhook_event_ids"
MAX_LEDGER_SIZE = 50
APPLIED_EVENT_TYPES = {"payment_intent.succeeded", "charge.succeeded"}


def intent_id_of(order):
    """The saved Stripe PaymentIntent id, from meta _stripe_intent_id or transaction_id."""
    for meta in order.get("meta_data") or []:
        if meta.get("key") == "_stripe_intent_id" and meta.get("value"):
            return meta["value"]
    tid = order.get("transaction_id")
    return tid if tid and tid.startswith("pi_") else None


def ledger_of(order):
    """The list of Stripe event ids already applied to this order."""
    for meta in order.get("meta_data") or []:
        if meta.get("key") == LEDGER_META_KEY and isinstance(meta.get("value"), list):
            return list(meta["value"])
    return []


def decide(order, event, ledger):
    """Pure decision: should this webhook event be applied to this order?

    order   -- the WooCommerce order dict (or None if it could not be found)
    event   -- a dict with at least "id" and "type" from Stripe
    ledger  -- the list of event ids already recorded as applied to this order

    Returns a tuple of (action, reason). action is one of:
      "apply"  -- event is new for this order, go ahead and act on it
      "skip"   -- event id is already in the ledger, do nothing
      "ignore" -- event type is not one this handler acts on
      "orphan" -- order could not be found for this event
    """
    if event.get("type") not in APPLIED_EVENT_TYPES:
        return ("ignore", "event type is not handled here")
    if order is None:
        return ("orphan", "order not found for this event")
    if event.get("id") in ledger:
        return ("skip", "event id already applied to this order")
    return ("apply", "new event for this order")


def next_ledger(ledger, event_id):
    """Pure helper: the ledger after recording event_id, capped to MAX_LEDGER_SIZE."""
    updated = ledger + [event_id]
    return updated[-MAX_LEDGER_SIZE:]


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 recent_events(lookback_hours):
    since = int(time.time()) - lookback_hours * 3600
    for event in stripe.Event.list(
        limit=100,
        created={"gte": since},
        types=list(APPLIED_EVENT_TYPES),
    ).auto_paging_iter():
        yield event


def order_id_of_event(event):
    intent = event.get("data", {}).get("object", {}) or {}
    return intent.get("metadata", {}).get("order_id")


def apply_event(order, event):
    """Do the work a webhook would do, then record the event id in the ledger."""
    order_id = order["id"]
    requests.post(
        f"{WOO_URL}/wp-json/wc/v3/orders/{order_id}/notes",
        json={"note": f"Stripe event {event['id']} ({event['type']}) applied. "
                      f"Recorded in the webhook event ledger so a retry cannot double it up."},
        auth=AUTH, timeout=30,
    ).raise_for_status()
    ledger = next_ledger(ledger_of(order), event["id"])
    requests.put(
        f"{WOO_URL}/wp-json/wc/v3/orders/{order_id}",
        json={"meta_data": [{"key": LEDGER_META_KEY, "value": ledger}]},
        auth=AUTH, timeout=30,
    ).raise_for_status()


def run():
    applied = 0
    skipped = 0
    for event in recent_events(LOOKBACK_HOURS):
        order_id = order_id_of_event(event)
        order = get_order(order_id) if order_id else None
        ledger = ledger_of(order) if order else []
        action, reason = decide(order, event, ledger)
        if action == "orphan":
            log.warning("Event %s points to order %s which is missing", event["id"], order_id)
            continue
        if action == "ignore":
            continue
        if action == "skip":
            log.info("Event %s: %s", event["id"], reason)
            skipped += 1
            continue
        log.info("Event %s on order %s: %s. %s", event["id"], order_id, reason,
                  "would apply" if DRY_RUN else "applying")
        if not DRY_RUN:
            apply_event(order, event)
        applied += 1
    log.info("Done. %d event(s) %s, %d duplicate(s) skipped.",
              applied, "to apply" if DRY_RUN else "applied", skipped)


if __name__ == "__main__":
    run()
dedupe-webhook-events.js
/**
 * Stop a Stripe webhook event from being applied to a WooCommerce order twice.
 *
 * Stripe retries a webhook delivery whenever it does not get a fast 2xx response,
 * and the same event id can also be redelivered after a Stripe dashboard resend or
 * a queue replay. If the handler is not idempotent, the same event.id ends up
 * applying its note, stock change, or email a second (or third) time on the order.
 *
 * This keeps a small ledger of event ids already applied to each order, read from
 * and written to the order's own meta data (no separate database needed). Before
 * doing any work for an incoming event, it checks the ledger. Read only by
 * default. Run this as the body of your webhook handler, or replay it against
 * recent events on a schedule to catch anything the live handler missed.
 */
import Stripe from "stripe";
import { pathToFileURL } from "node:url";

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

const LEDGER_META_KEY = "_processed_webhook_event_ids";
const MAX_LEDGER_SIZE = 50;
const APPLIED_EVENT_TYPES = new Set(["payment_intent.succeeded", "charge.succeeded"]);

export function intentIdOf(order) {
  for (const meta of order.meta_data || []) {
    if (meta.key === "_stripe_intent_id" && meta.value) return meta.value;
  }
  const tid = order.transaction_id;
  return tid && tid.startsWith("pi_") ? tid : null;
}

export function ledgerOf(order) {
  for (const meta of order.meta_data || []) {
    if (meta.key === LEDGER_META_KEY && Array.isArray(meta.value)) return [...meta.value];
  }
  return [];
}

/**
 * Pure decision: should this webhook event be applied to this order?
 *
 * order  - the WooCommerce order object (or null if it could not be found)
 * event  - an object with at least { id, type } from Stripe
 * ledger - the array of event ids already recorded as applied to this order
 *
 * Returns [action, reason]. action is one of:
 *   "apply"  - event is new for this order, go ahead and act on it
 *   "skip"   - event id is already in the ledger, do nothing
 *   "ignore" - event type is not one this handler acts on
 *   "orphan" - order could not be found for this event
 */
export function decide(order, event, ledger) {
  if (!APPLIED_EVENT_TYPES.has(event.type)) return ["ignore", "event type is not handled here"];
  if (!order) return ["orphan", "order not found for this event"];
  if (ledger.includes(event.id)) return ["skip", "event id already applied to this order"];
  return ["apply", "new event for this order"];
}

/** Pure helper: the ledger after recording eventId, capped to MAX_LEDGER_SIZE. */
export function nextLedger(ledger, eventId) {
  const updated = [...ledger, eventId];
  return updated.slice(-MAX_LEDGER_SIZE);
}

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* recentEvents(lookbackHours) {
  const since = Math.floor(Date.now() / 1000) - lookbackHours * 3600;
  for await (const event of stripe.events.list({
    limit: 100,
    created: { gte: since },
    types: [...APPLIED_EVENT_TYPES],
  })) {
    yield event;
  }
}

function orderIdOfEvent(event) {
  const intent = event.data?.object || {};
  return intent.metadata?.order_id;
}

async function applyEvent(order, event) {
  await woo(`/orders/${order.id}/notes`, {
    method: "POST",
    body: JSON.stringify({
      note: `Stripe event ${event.id} (${event.type}) applied. ` +
            `Recorded in the webhook event ledger so a retry cannot double it up.`,
    }),
  });
  const ledger = nextLedger(ledgerOf(order), event.id);
  await woo(`/orders/${order.id}`, {
    method: "PUT",
    body: JSON.stringify({ meta_data: [{ key: LEDGER_META_KEY, value: ledger }] }),
  });
}

export async function run() {
  let applied = 0;
  let skipped = 0;
  for await (const event of recentEvents(LOOKBACK_HOURS)) {
    const orderId = orderIdOfEvent(event);
    const order = orderId ? await woo(`/orders/${orderId}`) : null;
    const ledger = order ? ledgerOf(order) : [];
    const [action, reason] = decide(order, event, ledger);
    if (action === "orphan") { console.warn(`Event ${event.id} points to missing order ${orderId}`); continue; }
    if (action === "ignore") continue;
    if (action === "skip") { console.log(`Event ${event.id}: ${reason}`); skipped++; continue; }
    console.log(`Event ${event.id} on order ${orderId}: ${reason}. ${DRY_RUN ? "would apply" : "applying"}`);
    if (!DRY_RUN) await applyEvent(order, event);
    applied++;
  }
  console.log(`Done. ${applied} event(s) ${DRY_RUN ? "to apply" : "applied"}, ${skipped} duplicate(s) skipped.`);
}

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 an event gets applied a second time. Because we kept decide pure, the test needs no network and no Stripe account. It just feeds in plain objects and checks the action.

test_duplicate_ledger_decide.py
from dedupe_webhook_events import decide, next_ledger, ledger_of, intent_id_of


def event(**over):
    base = {"id": "evt_1", "type": "payment_intent.succeeded",
            "data": {"object": {"metadata": {"order_id": "42"}}}}
    base.update(over)
    return base


def test_apply_when_event_is_new():
    order = {"id": 42, "status": "processing"}
    assert decide(order, event(), [])[0] == "apply"


def test_skip_when_event_id_already_in_ledger():
    order = {"id": 42, "status": "processing"}
    assert decide(order, event(), ["evt_1"])[0] == "skip"


def test_ignore_when_event_type_not_handled():
    order = {"id": 42, "status": "processing"}
    assert decide(order, event(type="charge.refunded"), [])[0] == "ignore"


def test_orphan_when_order_missing():
    assert decide(None, event(), [])[0] == "orphan"


def test_next_ledger_appends_event_id():
    assert next_ledger(["evt_1"], "evt_2") == ["evt_1", "evt_2"]


def test_next_ledger_caps_size():
    ledger = [f"evt_{i}" for i in range(50)]
    result = next_ledger(ledger, "evt_50")
    assert len(result) == 50
    assert result[-1] == "evt_50"
dedupe-webhook-events.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { decide, nextLedger } from "./dedupe-webhook-events.js";

const event = (over = {}) => ({
  id: "evt_1",
  type: "payment_intent.succeeded",
  data: { object: { metadata: { order_id: "42" } } },
  ...over,
});

test("apply when event is new", () => {
  assert.equal(decide({ id: 42, status: "processing" }, event(), [])[0], "apply");
});

test("skip when event id already in ledger", () => {
  assert.equal(decide({ id: 42, status: "processing" }, event(), ["evt_1"])[0], "skip");
});

test("ignore when event type not handled", () => {
  assert.equal(decide({ id: 42, status: "processing" }, event({ type: "charge.refunded" }), [])[0], "ignore");
});

test("orphan when order missing", () => {
  assert.equal(decide(null, event(), [])[0], "orphan");
});

test("nextLedger appends event id", () => {
  assert.deepEqual(nextLedger(["evt_1"], "evt_2"), ["evt_1", "evt_2"]);
});

test("nextLedger caps size", () => {
  const ledger = Array.from({ length: 50 }, (_, i) => `evt_${i}`);
  const result = nextLedger(ledger, "evt_50");
  assert.equal(result.length, 50);
  assert.equal(result[result.length - 1], "evt_50");
});

Case studies

Slow shipping call

The handler that timed itself out

A store's webhook handler called a slow shipping label API before returning a response. When that call took more than a few seconds, Stripe assumed the delivery failed and retried. The handler ran again, called the shipping API a second time, and the order ended up with two labels and two "shipped" emails.

Moving the slow work off the handler and adding the event ledger fixed both problems: the handler now answers Stripe fast, and even if a retry does arrive, the ledger stops it from repeating the shipping call.

Dashboard resend

The debugging session that doubled real orders

While chasing an unrelated bug, a developer resent a batch of Stripe events from the dashboard to see the payloads again. A dozen of those were events that had already been applied days earlier, and every one of them added a duplicate note and an extra stock decrement.

After adding the ledger, a second resend of the same batch produced a clean run: every event was recognized as already applied and skipped, with nothing written to the orders.

What good looks like

With the ledger in place, a retried delivery, a dashboard resend, or a queue replay all become no-ops. The event is recognized, logged as a skip, and nothing on the order changes twice. Keep the check running even after you fix whatever was causing the retries, since Stripe's at-least-once delivery is permanent, not a bug to patch away.

FAQ

Why did the same Stripe webhook event run twice on my WooCommerce order?

Stripe resends a webhook whenever it does not get a fast 2xx response, and the same event id can also be redelivered after a dashboard resend or a queue replay. If the handler is not idempotent, the second delivery repeats its note, stock change, or email on the order.

Is it safe to just ignore repeated webhook deliveries?

Yes. A webhook event id from Stripe is unique and stable across redeliveries. Recording every event id you have already applied to an order and skipping any id you have seen before is the standard, safe way to make a handler idempotent.

Where should the ledger of processed event ids live?

For most stores, a small list stored in the order's own meta data is enough, since it travels with the order and needs no separate database. Cap the list to a reasonable size, since only recent events matter for deduplication.

Related field notes

Citations

On the problem:

  1. Stripe docs: webhooks can be delivered more than once, and endpoints should handle duplicate events safely. docs.stripe.com/webhooks
  2. Stripe docs: retry behavior for webhook endpoints that do not return a fast, successful response. docs.stripe.com/webhooks
  3. WooCommerce docs: Stripe order statuses and how updates are driven by webhook events. woocommerce.com/document/stripe

On the solution:

  1. Stripe docs: the Event object and its stable, unique id used to detect and skip duplicates. docs.stripe.com/api/events/object
  2. Stripe docs: best practices for designing an idempotent webhook handler. docs.stripe.com/webhooks/best-practices
  3. WooCommerce REST API: update an order's meta data 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 stop your duplicate notes?

If this saved you a pile of confused support tickets or a double stock count, 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 WooCommerce and Stripe field notes