Repair Sources to PaymentMethods and SCA
Attach the payment method
A customer has a saved card in WooCommerce, the checkout even shows it, but the next renewal or off session charge fails with a Stripe error about the PaymentMethod. Stripe created the PaymentMethod when the card was added, but it was never attached to the Customer object. Here is why that gap opens up and a small script that finds every affected customer and attaches the PaymentMethod before it costs you a missed renewal.
WooCommerce saved a Stripe PaymentMethod id on the customer, but the PaymentMethod was never attached to a Stripe Customer, so Stripe refuses the off session charge. Run a small Python or Node.js repair script on a schedule that reads each order or customer's saved PaymentMethod id from meta _stripe_intent_id or the order transaction_id, checks whether that PaymentMethod is attached to the right Stripe Customer, and attaches it when it is loose. Full code, tests, and a dry run guard are below.
The problem in plain words
When a shopper adds a card at checkout, Stripe creates a PaymentMethod object for that card right away. WooCommerce is happy the moment it gets a PaymentMethod id back, so it saves that id on the order and on the customer, and the checkout page starts showing "card ending in 4242" like everything is fine.
But a PaymentMethod on its own is just a token for a card. It only becomes chargeable off session once it is attached to a Stripe Customer object. If that attach call never runs, or it runs against the wrong Customer, or it silently fails, the PaymentMethod sits unattached. Stripe will happily let you attach it later, but it will not let you charge it later, and the first time anyone finds out is when a renewal or a re-order fails.
Why it happens
Stripe's own docs are explicit that a PaymentMethod must be attached to a Customer before it can be reused for an off session payment. A few common reasons that attach step gets skipped or fails silently:
- A custom checkout flow or a migration script created the PaymentMethod with the Payment Element but never called the attach step, because it assumed
setup_future_usagealone was enough. - The attach call ran against a stale or duplicate Stripe Customer id, so the PaymentMethod ended up attached to the wrong Customer, or attached to none if that id no longer existed. That duplicate customer case has its own guide.
- A SetupIntent was created and confirmed, but the confirmation webhook that finishes the attach was lost or errored, the same class of problem as a missed payment webhook.
- The store moved from the legacy Sources API to PaymentMethods, and the migration script backfilled the id on the WooCommerce side without also attaching the object on the Stripe side.
This is a known trap in the WooCommerce Stripe integration, and Stripe's support docs cover the exact "must be attached to a customer" requirement for off session usage. See the citations at the end for the exact references.
A PaymentMethod id saved in WordPress means nothing to Stripe until that PaymentMethod is attached to a Customer. Do not trust the id in WooCommerce. Always ask Stripe directly whether the PaymentMethod is attached, and to which Customer, before you assume it is chargeable.
The fix, as a flow
We do not touch checkout. We add a job that runs on a schedule, reads the saved PaymentMethod id off recent orders or customers, and asks Stripe for the real state of that PaymentMethod. If it is unattached, or somehow attached to a different Customer than the one WooCommerce expects, we attach it to the correct Stripe Customer and record what we did.
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 and customers. 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_DAYS="30"
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_DAYS="30"
export DRY_RUN="true" // start safe, change to false to write
Find the saved PaymentMethod id on each order
WooCommerce, or its Stripe gateway, saves the id on order meta _stripe_intent_id most of the time, since a PaymentIntent's payment_method field carries the token forward. Older orders sometimes only have a transaction_id that starts with pm_. Check both, and skip anything that is not a PaymentMethod id.
def payment_method_id_of(order):
"""The saved Stripe PaymentIntent id lives on meta _stripe_intent_id.
We use it to look up the PaymentIntent, then read payment_method off it.
Some older orders only have a pm_ id directly on transaction_id.
"""
tid = order.get("transaction_id") or ""
if tid.startswith("pm_"):
return tid
for meta in order.get("meta_data") or []:
if meta.get("key") == "_stripe_intent_id" and meta.get("value"):
return meta["value"] # a pi_... id, resolved to payment_method later
return None
export function paymentMethodIdOf(order) {
// The saved Stripe PaymentIntent id lives on meta _stripe_intent_id.
// We use it to look up the PaymentIntent, then read payment_method off it.
// Some older orders only have a pm_ id directly on transaction_id.
const tid = order.transaction_id || "";
if (tid.startsWith("pm_")) return tid;
for (const meta of order.meta_data || []) {
if (meta.key === "_stripe_intent_id" && meta.value) return meta.value; // a pi_... id
}
return null;
}
Load the order's customer and resolve the real PaymentMethod
Read the order from the WooCommerce REST API to get the customer_id, then read that WordPress customer's Stripe Customer id from meta _stripe_customer_id. On the Stripe side, if what we found in step 2 was a PaymentIntent id, retrieve it and take its payment_method. Then retrieve the PaymentMethod itself so we can see its current customer field.
import os, requests, stripe
from requests.auth import HTTPBasicAuth
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"])
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 stripe_customer_id_of(wc_customer_id):
r = requests.get(f"{WOO_URL}/wp-json/wc/v3/customers/{wc_customer_id}", auth=AUTH, timeout=30)
r.raise_for_status()
for meta in r.json().get("meta_data") or []:
if meta.get("key") == "_stripe_customer_id":
return meta.get("value")
return None
def resolve_payment_method(raw_id):
if raw_id.startswith("pi_"):
intent = stripe.PaymentIntent.retrieve(raw_id)
pm_id = intent.payment_method
return stripe.PaymentMethod.retrieve(pm_id) if pm_id else None
return stripe.PaymentMethod.retrieve(raw_id)
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");
async function woo(path) {
const res = await fetch(`${WOO_URL}/wp-json/wc/v3${path}`, {
headers: { "Content-Type": "application/json", Authorization: AUTH },
});
if (res.status === 404) return null;
if (!res.ok) throw new Error(`Woo ${path} returned ${res.status}`);
return res.json();
}
async function stripeCustomerIdOf(wcCustomerId) {
const customer = await woo(`/customers/${wcCustomerId}`);
for (const meta of customer?.meta_data || []) {
if (meta.key === "_stripe_customer_id") return meta.value;
}
return null;
}
async function resolvePaymentMethod(rawId) {
if (rawId.startsWith("pi_")) {
const intent = await stripe.paymentIntents.retrieve(rawId);
return intent.payment_method ? stripe.paymentMethods.retrieve(intent.payment_method) : null;
}
return stripe.paymentMethods.retrieve(rawId);
}
Decide, with one pure function
Keep the decision in its own function that takes the expected Stripe Customer id and the PaymentMethod object, and returns an action. This function does no I/O, so it is easy to test. The rule: if we have no PaymentMethod to check, skip it. If it is already attached to the expected customer, it is fine. If it is attached to a different customer, that is a conflict we should not touch automatically. Otherwise, attach it.
def decide(stripe_customer_id, payment_method):
if payment_method is None:
return ("skip", "no PaymentMethod found to check")
if not stripe_customer_id:
return ("skip", "customer has no Stripe Customer id on file")
current = payment_method.get("customer")
if current == stripe_customer_id:
return ("ok", "already attached to the right customer")
if current:
return ("conflict", f"attached to a different customer ({current})")
return ("attach", "unattached, safe to attach")
export function decide(stripeCustomerId, paymentMethod) {
if (!paymentMethod) return ["skip", "no PaymentMethod found to check"];
if (!stripeCustomerId) return ["skip", "customer has no Stripe Customer id on file"];
const current = paymentMethod.customer || null;
if (current === stripeCustomerId) return ["ok", "already attached to the right customer"];
if (current) return ["conflict", `attached to a different customer (${current})`];
return ["attach", "unattached, safe to attach"];
}
Attach it and leave a note
When the action is attach, call Stripe's attach endpoint with the PaymentMethod id and the Stripe Customer id. Then write an order note through the WooCommerce REST API so the shop manager can see the PaymentMethod was repaired and why. A conflict is logged, never auto attached, since moving a card to a different customer needs a human to confirm it is not fraud.
def attach_payment_method(payment_method_id, stripe_customer_id):
stripe.PaymentMethod.attach(payment_method_id, customer=stripe_customer_id)
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()
async function attachPaymentMethod(paymentMethodId, stripeCustomerId) {
await stripe.paymentMethods.attach(paymentMethodId, { customer: stripeCustomerId });
}
async function addNote(orderId, note) {
await woo(`/orders/${orderId}/notes`, {
method: "POST",
body: JSON.stringify({ note }),
});
}
Wire it together with a dry run guard
The loop ties every piece together. Notice the dry run guard. On the first few runs, leave DRY_RUN on so the script only reports what it would do. Read the output, trust it, then switch it off to let it write. Run it once a day, a few hours ahead of your subscription billing job.
Always start with DRY_RUN=true. Attaching a PaymentMethod does not move money, but it does change who can charge a saved card, so you want to see the plan before it acts. A conflict is never auto resolved, it is only reported.
The full code
Here is the complete repair script in one file for each language. It reads settings from the environment, logs what it does, respects the dry run flag, and never overwrites a PaymentMethod that is already attached to a different customer.
View this code on GitHub Full runnable folder with tests in the woocommerce-fixes repo.
"""Attach a WooCommerce customer's saved Stripe PaymentMethod to their Stripe
Customer when it exists but was never attached. Run on a schedule, ahead of
billing. Safe to run again and again.
"""
import os
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("attach_payment_method")
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_DAYS = int(os.environ.get("LOOKBACK_DAYS", "30"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
def payment_method_id_of(order):
"""The saved Stripe PaymentIntent id lives on meta _stripe_intent_id.
We use it to look up the PaymentIntent, then read payment_method off it.
Some older orders only have a pm_ id directly on transaction_id.
"""
tid = order.get("transaction_id") or ""
if tid.startswith("pm_"):
return tid
for meta in order.get("meta_data") or []:
if meta.get("key") == "_stripe_intent_id" and meta.get("value"):
return meta["value"]
return None
def stripe_customer_id_of(wc_customer_id):
if not wc_customer_id:
return None
r = requests.get(f"{WOO_URL}/wp-json/wc/v3/customers/{wc_customer_id}", auth=AUTH, timeout=30)
if r.status_code == 404:
return None
r.raise_for_status()
for meta in r.json().get("meta_data") or []:
if meta.get("key") == "_stripe_customer_id":
return meta.get("value")
return None
def resolve_payment_method(raw_id):
if raw_id is None:
return None
try:
if raw_id.startswith("pi_"):
intent = stripe.PaymentIntent.retrieve(raw_id)
pm_id = intent.get("payment_method")
return stripe.PaymentMethod.retrieve(pm_id) if pm_id else None
return stripe.PaymentMethod.retrieve(raw_id)
except stripe.error.InvalidRequestError:
return None
def decide(stripe_customer_id, payment_method):
if payment_method is None:
return ("skip", "no PaymentMethod found to check")
if not stripe_customer_id:
return ("skip", "customer has no Stripe Customer id on file")
current = payment_method.get("customer")
if current == stripe_customer_id:
return ("ok", "already attached to the right customer")
if current:
return ("conflict", f"attached to a different customer ({current})")
return ("attach", "unattached, safe to attach")
def attach_payment_method(payment_method_id, stripe_customer_id):
stripe.PaymentMethod.attach(payment_method_id, customer=stripe_customer_id)
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 recent_orders():
page = 1
after = f"{__import__('datetime').date.today() - __import__('datetime').timedelta(days=LOOKBACK_DAYS)}T00:00:00"
while True:
r = requests.get(
f"{WOO_URL}/wp-json/wc/v3/orders",
params={"after": after, "per_page": 50, "page": page},
auth=AUTH, timeout=30,
)
r.raise_for_status()
batch = r.json()
if not batch:
return
for order in batch:
yield order
page += 1
def run():
fixed = 0
for order in recent_orders():
raw_id = payment_method_id_of(order)
if raw_id is None:
continue
payment_method = resolve_payment_method(raw_id)
stripe_customer_id = stripe_customer_id_of(order.get("customer_id"))
action, reason = decide(stripe_customer_id, payment_method)
if action == "conflict":
log.warning("Order %s: %s. Needs a human to review.", order["id"], reason)
continue
if action in ("skip", "ok"):
continue
pm_id = payment_method["id"]
log.info("Order %s: %s. %s", order["id"], reason, "would attach" if DRY_RUN else "attaching")
if not DRY_RUN:
attach_payment_method(pm_id, stripe_customer_id)
add_note(order["id"], f"Attached Stripe PaymentMethod {pm_id} to Stripe "
f"Customer {stripe_customer_id}. It existed but was not "
f"attached, which would have blocked the next off session charge.")
fixed += 1
log.info("Done. %d PaymentMethod(s) %s.", fixed, "to attach" if DRY_RUN else "attached")
if __name__ == "__main__":
run()
/**
* Attach a WooCommerce customer's saved Stripe PaymentMethod to their Stripe
* Customer when it exists but was never attached. Run on a schedule, ahead
* of billing. Safe to run again and again.
*/
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_DAYS = Number(process.env.LOOKBACK_DAYS || 30);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
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();
}
export function paymentMethodIdOf(order) {
const tid = order.transaction_id || "";
if (tid.startsWith("pm_")) return tid;
for (const meta of order.meta_data || []) {
if (meta.key === "_stripe_intent_id" && meta.value) return meta.value;
}
return null;
}
async function stripeCustomerIdOf(wcCustomerId) {
if (!wcCustomerId) return null;
const customer = await woo(`/customers/${wcCustomerId}`);
for (const meta of customer?.meta_data || []) {
if (meta.key === "_stripe_customer_id") return meta.value;
}
return null;
}
async function resolvePaymentMethod(rawId) {
if (!rawId) return null;
try {
if (rawId.startsWith("pi_")) {
const intent = await stripe.paymentIntents.retrieve(rawId);
return intent.payment_method ? stripe.paymentMethods.retrieve(intent.payment_method) : null;
}
return await stripe.paymentMethods.retrieve(rawId);
} catch {
return null;
}
}
export function decide(stripeCustomerId, paymentMethod) {
if (!paymentMethod) return ["skip", "no PaymentMethod found to check"];
if (!stripeCustomerId) return ["skip", "customer has no Stripe Customer id on file"];
const current = paymentMethod.customer || null;
if (current === stripeCustomerId) return ["ok", "already attached to the right customer"];
if (current) return ["conflict", `attached to a different customer (${current})`];
return ["attach", "unattached, safe to attach"];
}
async function attachPaymentMethod(paymentMethodId, stripeCustomerId) {
await stripe.paymentMethods.attach(paymentMethodId, { customer: stripeCustomerId });
}
async function addNote(orderId, note) {
await woo(`/orders/${orderId}/notes`, { method: "POST", body: JSON.stringify({ note }) });
}
async function* recentOrders() {
const after = new Date(Date.now() - LOOKBACK_DAYS * 86400000).toISOString();
let page = 1;
while (true) {
const batch = await woo(`/orders?after=${after}&per_page=50&page=${page}`);
if (!batch || !batch.length) return;
for (const order of batch) yield order;
page++;
}
}
export async function run() {
let fixed = 0;
for await (const order of recentOrders()) {
const rawId = paymentMethodIdOf(order);
if (!rawId) continue;
const paymentMethod = await resolvePaymentMethod(rawId);
const stripeCustomerId = await stripeCustomerIdOf(order.customer_id);
const [action, reason] = decide(stripeCustomerId, paymentMethod);
if (action === "conflict") { console.warn(`Order ${order.id}: ${reason}. Needs a human to review.`); continue; }
if (action === "skip" || action === "ok") continue;
const pmId = paymentMethod.id;
console.log(`Order ${order.id}: ${reason}. ${DRY_RUN ? "would attach" : "attaching"}`);
if (!DRY_RUN) {
await attachPaymentMethod(pmId, stripeCustomerId);
await addNote(order.id, `Attached Stripe PaymentMethod ${pmId} to Stripe Customer ` +
`${stripeCustomerId}. It existed but was not attached, which ` +
`would have blocked the next off session charge.`);
}
fixed++;
}
console.log(`Done. ${fixed} PaymentMethod(s) ${DRY_RUN ? "to attach" : "attached"}.`);
}
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 a saved card gets linked to a customer. 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 attach_payment_method import decide, payment_method_id_of
def pm(**over):
base = {"id": "pm_1", "customer": None}
base.update(over)
return base
def test_attach_when_unattached():
assert decide("cus_1", pm())[0] == "attach"
def test_ok_when_already_attached_to_right_customer():
assert decide("cus_1", pm(customer="cus_1"))[0] == "ok"
def test_conflict_when_attached_to_other_customer():
assert decide("cus_1", pm(customer="cus_2"))[0] == "conflict"
def test_skip_when_no_payment_method():
assert decide("cus_1", None)[0] == "skip"
def test_skip_when_no_stripe_customer_id():
assert decide(None, pm())[0] == "skip"
def test_payment_method_id_prefers_transaction_id_pm():
order = {"transaction_id": "pm_555", "meta_data": []}
assert payment_method_id_of(order) == "pm_555"
def test_payment_method_id_falls_back_to_intent_meta():
order = {"transaction_id": "", "meta_data": [{"key": "_stripe_intent_id", "value": "pi_999"}]}
assert payment_method_id_of(order) == "pi_999"
import { test } from "node:test";
import assert from "node:assert/strict";
import { decide, paymentMethodIdOf } from "./attach-payment-method.js";
const pm = (over = {}) => ({ id: "pm_1", customer: null, ...over });
test("attach when unattached", () => {
assert.equal(decide("cus_1", pm())[0], "attach");
});
test("ok when already attached to right customer", () => {
assert.equal(decide("cus_1", pm({ customer: "cus_1" }))[0], "ok");
});
test("conflict when attached to other customer", () => {
assert.equal(decide("cus_1", pm({ customer: "cus_2" }))[0], "conflict");
});
test("skip when no payment method", () => {
assert.equal(decide("cus_1", null)[0], "skip");
});
test("skip when no stripe customer id", () => {
assert.equal(decide(null, pm())[0], "skip");
});
test("paymentMethodIdOf prefers transaction_id pm_", () => {
assert.equal(paymentMethodIdOf({ transaction_id: "pm_555", meta_data: [] }), "pm_555");
});
test("paymentMethodIdOf falls back to intent meta", () => {
assert.equal(
paymentMethodIdOf({ transaction_id: "", meta_data: [{ key: "_stripe_intent_id", value: "pi_999" }] }),
"pi_999"
);
});
Case studies
The Payment Element that forgot to attach
A store built a custom checkout page around the Stripe Payment Element and set setup_future_usage: off_session, assuming that alone would make the card reusable. It creates and confirms the PaymentMethod but Stripe does not attach it to a Customer on its own unless the integration calls attach explicitly.
Every card saved through that page for three weeks was unattached. The repair script found all of them in one run, attached each to its correct Stripe Customer, and the next subscription renewal cycle went through without a single decline from this cause.
The migration that moved ids but not attachments
During a move from the legacy Sources API to PaymentMethods, a script backfilled _stripe_intent_id on old orders so WooCommerce would show the new field format. It never called Stripe to actually attach those PaymentMethods to the matching Customer objects.
Running the repair script in dry run surfaced twelve unattached PaymentMethods, all pre migration orders. The team reviewed the list, ran it for real, and closed the gap before the next billing date.
After this runs on a schedule ahead of billing, a PaymentMethod that exists but is not attached no longer turns into a missed renewal. The worst case becomes a quiet attach the day before, instead of a failed charge and a support ticket the day of. Keep a conflict alert in place too, since that case always needs a human.
FAQ
Why does WooCommerce say a card is saved but the renewal still fails?
WooCommerce saved a Stripe PaymentMethod id on the customer, but the PaymentMethod itself was never attached to a Stripe Customer object. Stripe rejects an off session charge against a PaymentMethod with no customer attached, so the renewal fails even though the card looks saved in your store.
Is it safe to attach a PaymentMethod with a script?
Yes, when the script first confirms the PaymentMethod is not already attached to a different customer and that the WooCommerce customer has a matching Stripe Customer id. Attaching a PaymentMethod does not move money, it only links records, and the repair skips anything that looks wrong. Start in dry run mode to review the list before it writes.
How often should the repair script run?
Once a day is enough for most stores. Run it a few hours before your renewal or subscription billing job so any unattached PaymentMethod is fixed before Stripe tries to charge it.
Related field notes
Citations
On the problem:
- Stripe docs: a PaymentMethod must be attached to a Customer to be used for future, off session payments. docs.stripe.com/payments/payment-methods
- Stripe docs: saving payment details for future use and the attach requirement. docs.stripe.com/payments/save-and-reuse
- WooCommerce docs: how the Stripe gateway stores customer and payment method data. woocommerce.com/document/stripe
On the solution:
- Stripe API: attach a PaymentMethod to a Customer. docs.stripe.com/api/payment_methods/attach
- Stripe API: retrieve a PaymentMethod and read its customer field. docs.stripe.com/api/payment_methods/retrieve
- WooCommerce REST API: read and update customers and orders. 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 fix your unattached cards?
If this saved you a pile of failed renewals or a support ticket, 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