Diagnostic API and Integration
Admin or integration tokens expire and silently break automation
A script or cron job calls POST /rest/V1/integration/admin/token once, saves the bearer token in an env var, and runs fine for hours. Then every REST call starts coming back Unauthorized, and because a 401 is a plain HTTP status rather than a business exception, the automation often swallows it instead of alerting anyone. Here is why the admin token quietly expires, and a script that detects the failure, re-authenticates safely once, and reports the rest.
An admin token from POST /rest/V1/integration/admin/token is a session style bearer token with a default lifetime of only four hours, controlled by Stores, Configuration, Services, OAuth, Access Token Expiration, Admin Token Lifetime, and an hourly cron job purges expired tokens from the admin_bearer_token table. A script that hardcodes the token once works fine until it expires, then every call fails with a 401 that is easy to swallow silently. Run a small Python or Node.js script that probes the credential against a cheap endpoint like GET /V1/store/storeConfigs, classifies the failure with a pure decision function, re-authenticates exactly once on a genuine expiry, and stops and reports on anything else, including the risk of tripping the account lockout. The full code and a dry run guard are below.
The problem in plain words
Calling POST /rest/V1/integration/admin/token with a username and password is the fastest way to get a bearer token in every tutorial, so it is also the way most scripts get wired up first. The token works, the script runs, and nobody thinks about it again.
But that token was never meant to live forever. It behaves like a login session, and by default Magento gives it four hours before it expires, a value read from Stores, Configuration, Services, OAuth, Access Token Expiration, Admin Token Lifetime. On top of that, an hourly cron job actively purges expired rows from the admin_bearer_token table, so there is nothing left to fall back on even by accident. A cron job or script that cached the token in an environment variable or a config file keeps sending the same string, and once the clock runs out, every call gets rejected with HTTP 401 and a message like "The consumer isn't authorized to access %resources" or a generic Unauthorized reason. Because that is a plain HTTP status and not a business exception, it is easy for automation to log it and move on instead of raising an alert.
Why it happens
- The admin token endpoint,
POST /rest/V1/integration/admin/token, was designed for short lived, interactive use, not for a script that runs unattended for weeks. Its default expiry under Admin Token Lifetime is four hours. - Magento's own cron runs an hourly job that purges expired tokens from the
admin_bearer_tokenand related OAuth token tables, so there is no grace period and no stale row left to inspect after the fact. - Magento does not expose a "time remaining" field on the token itself, so a script cannot ask the API how much longer its own credential is good for. The only reliable signal is a live call that either succeeds or comes back 401.
- Because the failure surfaces as a generic HTTP status rather than a typed exception, many integrations catch it, log a line, and continue the next scheduled run as if nothing happened, so the automation looks alive while every real request is failing.
This is one of the most common Magento integration complaints once a script has been running quietly for a few weeks. See the citations at the end for the exact support thread and forum discussions.
The admin token was never meant to be a long lived credential, it is a login session with a clock attached. So the fix is not to hunt for a longer expiry setting or to refresh on a fixed timer that might drift from the real configuration. It is to treat every 401 as a signal to classify, not just retry: was this a normal expiry that is safe to refresh once, or a revoked or invalid credential that should stop the script and get a human's attention before it risks an account lockout.
The fix, as a flow
We do not touch the live checkout or admin session. We add a probe that calls a cheap, side effect free endpoint before the real work, reads the HTTP status and message, and classifies the result. A genuine expiry gets one automatic re-authentication and one retry. Anything else, including a second failure right after refreshing, stops the script and reports it instead of hammering the login endpoint.
Build it step by step
Set up credentials and pick your durable path
Get an admin token by calling POST {'{'}MAGENTO_URL{'}'}/rest/V1/integration/admin/token with your admin username and password, or better, create a long lived Integration under System, Extensions, Integrations with explicit API resource ACLs and activate it, since that token never expires on its own. Keep the base URL, admin credentials, and token in environment variables, never in the file.
pip install requests
export MAGENTO_URL="https://yourstore.example.com"
export MAGENTO_ADMIN_TOKEN="eyJraWQ..."
export MAGENTO_ADMIN_USER="automation-user"
export MAGENTO_ADMIN_PASS="change-me"
export ADMIN_TOKEN_LIFETIME_HOURS="4"
export DRY_RUN="true" # start safe, change to false to allow the re-auth call
// Node 18+ has fetch built in, no dependencies needed
export MAGENTO_URL="https://yourstore.example.com"
export MAGENTO_ADMIN_TOKEN="eyJraWQ..."
export MAGENTO_ADMIN_USER="automation-user"
export MAGENTO_ADMIN_PASS="change-me"
export ADMIN_TOKEN_LIFETIME_HOURS="4"
export DRY_RUN="true" // start safe, change to false to allow the re-auth call
Talk to the Magento REST API
Every call sends the cached token as a bearer header. A small helper wraps GET requests, returns the status code and parsed body instead of raising immediately, so the caller can inspect a 401 before deciding what to do about it.
import os, requests
MAGENTO_URL = os.environ["MAGENTO_URL"].rstrip("/")
def get_with_status(path, token, params=None):
r = requests.get(
f"{MAGENTO_URL}/rest/V1{path}",
params=params or {},
headers={"Authorization": f"Bearer {token}"},
timeout=30,
)
try:
body = r.json()
except ValueError:
body = {}
return r.status_code, body
const MAGENTO_URL = (process.env.MAGENTO_URL || "").replace(/\/$/, "");
async function getWithStatus(path, token, params = {}) {
const url = new URL(`${MAGENTO_URL}/rest/V1${path}`);
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
let body = {};
try {
body = await res.json();
} catch {
body = {};
}
return { status: res.status, body };
}
Probe with a cheap, side-effect-free endpoint
Call GET /V1/store/storeConfigs with the cached token. It reads nothing sensitive and writes nothing, so it is safe to call before every real batch of work. A 200 means the token is fine. A 401 means either it expired or it was revoked, and we need the classifier to tell those apart.
def probe_token(token):
status, body = get_with_status("/store/storeConfigs", token)
return status, body
async function probeToken(token) {
const { status, body } = await getWithStatus("/store/storeConfigs", token);
return { status, body };
}
Decide, with one pure function
Keep the classification in its own function, with no I/O, so it is easy to read and easy to test. It takes the HTTP status, the response body, when the token was issued, the current time, and the configured lifetime, and returns one of four outcomes. A 401 within the configured lifetime is treated as revoked or invalid, not expiry, so the script does not blindly refresh a credential that was deliberately cut off.
def classify_token_failure(http_status, response_body, token_issued_at, now, configured_lifetime_hours, retry_count=0, retry_threshold=1):
if http_status == 200:
return "OK"
if http_status == 401:
age_hours = (now - token_issued_at).total_seconds() / 3600
if retry_count >= retry_threshold:
return "LOCKOUT_RISK"
if age_hours >= configured_lifetime_hours:
return "EXPIRED_REAUTH"
return "REVOKED_OR_INVALID"
return "REVOKED_OR_INVALID"
export function classifyTokenFailure(httpStatus, responseBody, tokenIssuedAt, now, configuredLifetimeHours, retryCount = 0, retryThreshold = 1) {
if (httpStatus === 200) return "OK";
if (httpStatus === 401) {
const ageHours = (now - tokenIssuedAt) / 3600000;
if (retryCount >= retryThreshold) return "LOCKOUT_RISK";
if (ageHours >= configuredLifetimeHours) return "EXPIRED_REAUTH";
return "REVOKED_OR_INVALID";
}
return "REVOKED_OR_INVALID";
}
Re-authenticate exactly once on a genuine expiry
When the classifier says EXPIRED_REAUTH, call POST /V1/integration/admin/token with the admin username and password to mint a fresh token, replace the cached token, and retry the original request exactly once. Do not loop. If the re-auth call itself fails, or the retried request fails again, that is a sign of something worse than plain expiry.
def get_new_admin_token(username, password):
r = requests.post(
f"{MAGENTO_URL}/rest/V1/integration/admin/token",
json={"username": username, "password": password},
timeout=30,
)
if r.status_code != 200:
raise RuntimeError(f"Re-authentication failed with status {r.status_code}")
return r.json()
async function getNewAdminToken(username, password) {
const res = await fetch(`${MAGENTO_URL}/rest/V1/integration/admin/token`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
if (res.status !== 200) throw new Error(`Re-authentication failed with status ${res.status}`);
return res.json();
}
Wire it together with a dry run guard
The loop probes first, classifies the result, and only acts on EXPIRED_REAUTH. Notice the dry run guard. On the first few runs, leave DRY_RUN on so the script only reports what it would do. Anything classified REVOKED_OR_INVALID or LOCKOUT_RISK stops the run and logs the job name, timestamp, and 401 payload instead of retrying, since repeatedly hammering the token endpoint with bad credentials risks Magento's admin account lockout threshold.
Never loop on a 401. Re-authenticate at most once per run, only when the classifier says EXPIRED_REAUTH, and stop and report on anything else. The long term fix is a permanent Integration token created under System, Extensions, Integrations with explicit ACLs, since it removes the four hour clock entirely.
The full code
Here is the complete script in one file for each language. It reads settings from the environment, probes the cached token against a cheap endpoint, classifies any failure with a pure function, re-authenticates exactly once on a genuine expiry, and logs and stops on anything else.
"""Detect a silently expired or revoked Magento admin token and recover safely.
POST /rest/V1/integration/admin/token returns a session style bearer token with a
default four hour lifetime (Admin Token Lifetime under Stores, Configuration,
Services, OAuth, Access Token Expiration), and an hourly cron purges expired rows
from admin_bearer_token. A script that caches the token once works fine until it
expires, then every call fails with a plain HTTP 401 that is easy to swallow silently.
This script probes a cheap, side-effect-free endpoint, classifies the result with a
pure function, and re-authenticates exactly once on a genuine expiry. Anything else,
including a repeated failure right after refreshing, stops the run and reports it
instead of looping, since repeated bad logins risk tripping the admin account lockout.
"""
import os
import logging
import datetime
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("detect_token_expiry")
MAGENTO_URL = os.environ["MAGENTO_URL"].rstrip("/")
ADMIN_USER = os.environ.get("MAGENTO_ADMIN_USER", "")
ADMIN_PASS = os.environ.get("MAGENTO_ADMIN_PASS", "")
LIFETIME_HOURS = float(os.environ.get("ADMIN_TOKEN_LIFETIME_HOURS", "4"))
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
RETRY_THRESHOLD = 1
def classify_token_failure(http_status, response_body, token_issued_at, now, configured_lifetime_hours, retry_count=0, retry_threshold=RETRY_THRESHOLD):
if http_status == 200:
return "OK"
if http_status == 401:
age_hours = (now - token_issued_at).total_seconds() / 3600
if retry_count >= retry_threshold:
return "LOCKOUT_RISK"
if age_hours >= configured_lifetime_hours:
return "EXPIRED_REAUTH"
return "REVOKED_OR_INVALID"
return "REVOKED_OR_INVALID"
def get_with_status(path, token, params=None):
r = requests.get(
f"{MAGENTO_URL}/rest/V1{path}",
params=params or {},
headers={"Authorization": f"Bearer {token}"},
timeout=30,
)
try:
body = r.json()
except ValueError:
body = {}
return r.status_code, body
def probe_token(token):
return get_with_status("/store/storeConfigs", token)
def get_new_admin_token(username, password):
r = requests.post(
f"{MAGENTO_URL}/rest/V1/integration/admin/token",
json={"username": username, "password": password},
timeout=30,
)
if r.status_code != 200:
raise RuntimeError(f"Re-authentication failed with status {r.status_code}")
return r.json()
def run():
token = os.environ.get("MAGENTO_ADMIN_TOKEN", "")
token_issued_at = datetime.datetime.fromisoformat(
os.environ.get("TOKEN_ISSUED_AT", datetime.datetime.now(datetime.timezone.utc).isoformat())
)
retry_count = 0
while True:
now = datetime.datetime.now(datetime.timezone.utc)
status, body = probe_token(token)
outcome = classify_token_failure(status, body, token_issued_at, now, LIFETIME_HOURS, retry_count, RETRY_THRESHOLD)
if outcome == "OK":
log.info("Token is valid. Automation can proceed.")
return
if outcome == "EXPIRED_REAUTH":
log.warning("Token expired after its configured lifetime. %s",
"Would re-authenticate." if DRY_RUN else "Re-authenticating.")
if DRY_RUN:
return
new_token = get_new_admin_token(ADMIN_USER, ADMIN_PASS)
token = new_token if isinstance(new_token, str) else new_token.get("token", token)
token_issued_at = now
retry_count += 1
continue
if outcome == "REVOKED_OR_INVALID":
log.error("Job admin-token-expiry-breaks-automation: token rejected while still within its "
"configured lifetime at %s. 401 payload: %s. Flagging for manual review, not retrying.",
now.isoformat(), body)
return
log.error("Job admin-token-expiry-breaks-automation: repeated failure at %s after a refresh attempt. "
"Stopping to avoid the admin account lockout. 401 payload: %s", now.isoformat(), body)
return
if __name__ == "__main__":
run()
/**
* Detect a silently expired or revoked Magento admin token and recover safely.
*
* POST /rest/V1/integration/admin/token returns a session style bearer token with a
* default four hour lifetime (Admin Token Lifetime under Stores, Configuration,
* Services, OAuth, Access Token Expiration), and an hourly cron purges expired rows
* from admin_bearer_token. A script that caches the token once works fine until it
* expires, then every call fails with a plain HTTP 401 that is easy to swallow silently.
*
* This script probes a cheap, side-effect-free endpoint, classifies the result with a
* pure function, and re-authenticates exactly once on a genuine expiry. Anything else
* stops the run and reports it instead of looping, since repeated bad logins risk
* tripping the admin account lockout.
*
* Guide: https://www.allanninal.dev/magento/admin-token-expiry-breaks-automation/
*/
import { pathToFileURL } from "node:url";
const MAGENTO_URL = (process.env.MAGENTO_URL || "https://example.test").replace(/\/$/, "");
const ADMIN_USER = process.env.MAGENTO_ADMIN_USER || "";
const ADMIN_PASS = process.env.MAGENTO_ADMIN_PASS || "";
const LIFETIME_HOURS = Number(process.env.ADMIN_TOKEN_LIFETIME_HOURS || 4);
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const RETRY_THRESHOLD = 1;
export function classifyTokenFailure(httpStatus, responseBody, tokenIssuedAt, now, configuredLifetimeHours, retryCount = 0, retryThreshold = RETRY_THRESHOLD) {
if (httpStatus === 200) return "OK";
if (httpStatus === 401) {
const ageHours = (now - tokenIssuedAt) / 3600000;
if (retryCount >= retryThreshold) return "LOCKOUT_RISK";
if (ageHours >= configuredLifetimeHours) return "EXPIRED_REAUTH";
return "REVOKED_OR_INVALID";
}
return "REVOKED_OR_INVALID";
}
async function getWithStatus(path, token, params = {}) {
const url = new URL(`${MAGENTO_URL}/rest/V1${path}`);
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
let body = {};
try {
body = await res.json();
} catch {
body = {};
}
return { status: res.status, body };
}
async function probeToken(token) {
return getWithStatus("/store/storeConfigs", token);
}
async function getNewAdminToken(username, password) {
const res = await fetch(`${MAGENTO_URL}/rest/V1/integration/admin/token`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
if (res.status !== 200) throw new Error(`Re-authentication failed with status ${res.status}`);
return res.json();
}
export async function run() {
let token = process.env.MAGENTO_ADMIN_TOKEN || "";
let tokenIssuedAt = process.env.TOKEN_ISSUED_AT ? Date.parse(process.env.TOKEN_ISSUED_AT) : Date.now();
let retryCount = 0;
while (true) {
const now = Date.now();
const { status, body } = await probeToken(token);
const outcome = classifyTokenFailure(status, body, tokenIssuedAt, now, LIFETIME_HOURS, retryCount, RETRY_THRESHOLD);
if (outcome === "OK") {
console.log("Token is valid. Automation can proceed.");
return;
}
if (outcome === "EXPIRED_REAUTH") {
console.warn(`Token expired after its configured lifetime. ${DRY_RUN ? "Would re-authenticate." : "Re-authenticating."}`);
if (DRY_RUN) return;
const newToken = await getNewAdminToken(ADMIN_USER, ADMIN_PASS);
token = typeof newToken === "string" ? newToken : newToken.token || token;
tokenIssuedAt = now;
retryCount += 1;
continue;
}
if (outcome === "REVOKED_OR_INVALID") {
console.error(
`Job admin-token-expiry-breaks-automation: token rejected while still within its configured lifetime at ${new Date(now).toISOString()}. 401 payload: ${JSON.stringify(body)}. Flagging for manual review, not retrying.`
);
return;
}
console.error(
`Job admin-token-expiry-breaks-automation: repeated failure at ${new Date(now).toISOString()} after a refresh attempt. Stopping to avoid the admin account lockout. 401 payload: ${JSON.stringify(body)}`
);
return;
}
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((err) => { console.error(err); process.exit(1); });
}
Add a test
classify_token_failure is the part worth testing, because it decides whether the script refreshes quietly, stops and reports, or backs off entirely. It is pure decision logic with no I/O, so the test needs no network and no Magento store. It just feeds in plain values and checks the answer.
import datetime
from detect_token_expiry import classify_token_failure
ISSUED = datetime.datetime(2026, 7, 10, 8, 0, tzinfo=datetime.timezone.utc)
def test_ok_on_200():
now = ISSUED + datetime.timedelta(hours=1)
assert classify_token_failure(200, {}, ISSUED, now, 4) == "OK"
def test_expired_reauth_after_lifetime():
now = ISSUED + datetime.timedelta(hours=5)
assert classify_token_failure(401, {"message": "Unauthorized"}, ISSUED, now, 4) == "EXPIRED_REAUTH"
def test_revoked_when_within_lifetime():
now = ISSUED + datetime.timedelta(hours=1)
body = {"message": "The consumer isn't authorized to access %resources"}
assert classify_token_failure(401, body, ISSUED, now, 4) == "REVOKED_OR_INVALID"
def test_exactly_at_lifetime_is_expired():
now = ISSUED + datetime.timedelta(hours=4)
assert classify_token_failure(401, {}, ISSUED, now, 4) == "EXPIRED_REAUTH"
def test_lockout_risk_when_retry_threshold_hit():
now = ISSUED + datetime.timedelta(hours=5)
assert classify_token_failure(401, {}, ISSUED, now, 4, retry_count=1, retry_threshold=1) == "LOCKOUT_RISK"
def test_non_401_non_200_treated_as_revoked_or_invalid():
now = ISSUED + datetime.timedelta(hours=1)
assert classify_token_failure(500, {}, ISSUED, now, 4) == "REVOKED_OR_INVALID"
import { test } from "node:test";
import assert from "node:assert/strict";
import { classifyTokenFailure } from "./detect-token-expiry.js";
const ISSUED = Date.parse("2026-07-10T08:00:00Z");
const hoursLater = (h) => ISSUED + h * 3600000;
test("OK on 200", () => {
assert.equal(classifyTokenFailure(200, {}, ISSUED, hoursLater(1), 4), "OK");
});
test("EXPIRED_REAUTH after lifetime", () => {
const body = { message: "Unauthorized" };
assert.equal(classifyTokenFailure(401, body, ISSUED, hoursLater(5), 4), "EXPIRED_REAUTH");
});
test("REVOKED_OR_INVALID when within lifetime", () => {
const body = { message: "The consumer isn't authorized to access %resources" };
assert.equal(classifyTokenFailure(401, body, ISSUED, hoursLater(1), 4), "REVOKED_OR_INVALID");
});
test("exactly at lifetime is expired", () => {
assert.equal(classifyTokenFailure(401, {}, ISSUED, hoursLater(4), 4), "EXPIRED_REAUTH");
});
test("LOCKOUT_RISK when retry threshold hit", () => {
assert.equal(classifyTokenFailure(401, {}, ISSUED, hoursLater(5), 4, 1, 1), "LOCKOUT_RISK");
});
test("non 401 non 200 treated as revoked or invalid", () => {
assert.equal(classifyTokenFailure(500, {}, ISSUED, hoursLater(1), 4), "REVOKED_OR_INVALID");
});
Case studies
The sync job that quietly stopped syncing
A mid sized retailer had a nightly ERP sync that pulled orders through the Magento REST API using an admin token fetched once when the job was first written, months earlier, and cached in a deployment secret. The token had actually been rotated by an unrelated deploy weeks ago, so every night's sync had been failing with 401s, logged as warnings that nobody read.
Adding the probe and classifier caught the very next run: REVOKED_OR_INVALID fired immediately since the cached token was nowhere near a normal four hour age, which correctly pointed at a credential problem rather than plain expiry. That distinction sent the team straight to checking what had rotated the token, instead of chasing a retry loop that would never have fixed it.
An hourly job that ran into the four hour wall
A pricing integration polled Magento every hour using a token minted once at deploy time, and worked fine for the first several runs. On the fifth run, past the default four hour Admin Token Lifetime, cron had already purged the token, and the job started failing silently, its error handling only logged a generic "sync failed" line.
Once the classifier was in place, that failure came back as EXPIRED_REAUTH, the script refreshed the token exactly once, retried, and kept going without anyone touching it. The team then moved the integration onto a permanent Integration token so the refresh step became unnecessary going forward.
Run before every batch of work, this probe turns a silent, hours later failure into an immediate, specific outcome: automatic recovery for a normal expiry, or a clear stop-and-report the moment a credential is genuinely revoked or invalid. Nobody has to guess why a job "just stopped working" three weeks in. And once the durable Integration token is in place, the whole detection loop becomes a safety net rather than something you rely on daily.
FAQ
Why does my Magento REST script start failing after a few hours?
An admin token from POST /rest/V1/integration/admin/token is a session style bearer token with a default lifetime of four hours, set under Stores, Configuration, Services, OAuth, Access Token Expiration, Admin Token Lifetime. An hourly cron job purges expired tokens from the admin_bearer_token table, so a script that hardcoded the token once keeps sending it and every call after expiry gets rejected with a 401, often silently if the automation does not check the status code.
What is the difference between an admin token and an integration token?
An admin token comes from POST /rest/V1/integration/admin/token using a username and password, and it expires after the configured Admin Token Lifetime, four hours by default. An integration token comes from an Integration created under System, Extensions, Integrations with explicit API resource ACLs, and once activated it does not expire on its own, it only stops working if someone revokes or deactivates it. Integrations are the durable choice for unattended automation.
Is it safe for a script to automatically get a new admin token on a 401?
Yes, once, and only for that specific failure. Classify the 401 first: if the token is older than the configured lifetime, it is a normal expiry and re-authenticating once with POST /rest/V1/integration/admin/token is safe. If the token is still within its lifetime and still gets a 401, that means the credentials or the integration were revoked, and the script should stop and report instead of retrying, because repeated bad login attempts risk tripping Magento's admin account lockout.
Related field notes
Citations
On the problem:
- Finale Inventory support: access token expiring frequently on a Magento connection. support.finaleinventory.com Access-token-expiring-frequently-on-Magento-connection
- Magento Forums: limited access for OAuth Authentication. community.magento.com Limited-access-for-OAuth-Authentication
- Magento 2 GitHub issue: OAuth1.0 request token request failing, consumer key has expired. github.com/magento/magento2/issues/829
On the solution:
- Adobe Commerce developer docs: Token-Based Authentication. developer.adobe.com/commerce/webapi/get-started/authentication/gs-authentication-token
- Adobe Commerce developer docs: Tutorial, Step 2, get the admin token. developer.adobe.com/commerce/webapi/rest/tutorials/orders/order-admin-token
- Adobe Commerce developer docs: Prerequisite Tasks, generate the admin token. developer.adobe.com/commerce/webapi/rest/tutorials/prerequisite-tasks
Stuck on a tricky one?
If you have a problem in Magento integrations, cron, indexing, or order and inventory sync 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 save your automation from a silent 401?
If this saved you hours of chasing a job that "just stopped working," 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