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.
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.
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:
- The handler does its slow work, like calling out to a shipping API, before returning a 200, so Stripe times out waiting and retries a delivery that actually succeeded.
- A deploy or a database hiccup makes the store return a 500 for one delivery, and Stripe's retry schedule kicks in over the next several hours.
- A developer manually clicks "Resend" in the Stripe dashboard while debugging a different order, and the handler runs for real again.
- A queue or worker crashes mid job and replays its whole backlog of events on restart, including ones it already finished.
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.
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.
Build it step by step
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.
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
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
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.
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 []
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 [];
}
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.
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")
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"];
}
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.
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()
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 }] }),
});
}
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.
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.
"""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()
/**
* 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.
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"
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
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.
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.
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:
- Stripe docs: webhooks can be delivered more than once, and endpoints should handle duplicate events safely. docs.stripe.com/webhooks
- Stripe docs: retry behavior for webhook endpoints that do not return a fast, successful response. docs.stripe.com/webhooks
- WooCommerce docs: Stripe order statuses and how updates are driven by webhook events. woocommerce.com/document/stripe
On the solution:
- Stripe docs: the Event object and its stable, unique id used to detect and skip duplicates. docs.stripe.com/api/events/object
- Stripe docs: best practices for designing an idempotent webhook handler. docs.stripe.com/webhooks/best-practices
- 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.
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