Diagnostic Stripe
terminal readers sit offline and take no payments
A shop's card takings were zero all weekend. There is nothing in the Dashboard to look at: no declines, no failed PaymentIntents, no errors. That is the tell. A reader that is offline does not fail payments, it never starts them, so the evidence of the outage is an absence of records rather than a list of them.
Page GET /v1/terminal/readers?limit=100 and check two things independently. status == "offline" is the obvious one. last_seen_at is the honest one: a reader that has not checked in for six hours is not usable, whatever status currently claims.
Watch the units. last_seen_at is in milliseconds, unlike almost every other timestamp in the Stripe API, so the obvious subtraction against a seconds clock is wrong by a factor of 1000 and reports every reader as decades stale. Also read action.status and action.failure_code for readers stuck on a failed action, and group device_sw_version per device_type to catch firmware drift.
The problem in plain words
Terminal is the one part of a Stripe integration where the failure is physical. A router reboots overnight, a cleaner unplugs a dock, a guest network drops its lease, and the reader stops talking to Stripe. It cannot accept a process_payment_intent action, so nothing gets as far as creating a PaymentIntent.
That is what makes it invisible to every dashboard you already have. Payment failure alerts fire on failed payments. Decline-rate alerts fire on declines. A dead reader produces neither, because it produces nothing at all: the customer is told the machine is not working, they pay cash or leave, and your metrics record a slow day.
Staff report it as "the machine isn't doing anything", which is accurate and unhelpful, and it usually reaches you a day or two after it started because a Saturday is allowed to be slow. By then the only way to establish when it began is last_seen_at.
Why it happens
status lags reality, so it is not a liveness check. Stripe's own guidance is not to use it for blocking flows. A reader that lost its network moments ago can still read as online; the field that actually moves is last_seen_at, and comparing it against now is the only check that does not have to trust a cached state.
last_seen_at is milliseconds, and everything around it is seconds. created is seconds. Event timestamps are seconds. This one is not. A check written by analogy divides or subtracts on the wrong scale and either flags every reader in the fleet or none of them, and both of those look like a broken script rather than a units bug.
Nothing fails, so nothing alerts. This is worth stating plainly because it changes what you have to build. There is no error to catch, no webhook to subscribe to for "a reader stopped existing", and no failed object to count. The only way to know is to ask, on a schedule, and to compare a timestamp.
Firmware drift accumulates in the same silence. Readers update during a configured window while powered and connected. One that is habitually unplugged at night never gets its window, so it falls behind the rest of the fleet, and the one reader running a version from eight months ago is usually also the one with the most mysterious faults.
The fix, as a flow
The script trusts last_seen_at rather than status, and refuses to judge a reader whose timestamp arrived in the wrong units, because that mistake flags an entire fleet as decades stale.
How to fix it
List every reader and read last_seen_at, not just status
GET /v1/terminal/readers?limit=100, optionally narrowed with &location={tml_id}. Compute the age from last_seen_at in milliseconds against a millisecond clock. Report a reader that claims to be online but has not checked in for hours as its own state, because that is the case a status-only check gets wrong.
Sanity-check the units before trusting the number
A last_seen_at that looks like a seconds timestamp is either a units bug in your own code or a value you should not act on. Refusing to classify it beats reporting a fleet that has been dead since 1973, which is how this check gets ignored the first time it fires.
Look for readers stuck on a failed action
action.status == "failed" with a failure_code is a reader that is reachable but wedged, which needs a different response from one that is unreachable. It is also the only case here where the API has an error message to show you.
Group firmware by device type
Tally device_sw_version within each device_type and report the readers that are not on the version the rest of their kind are running. Leave those powered and connected through the configured update window rather than chasing the update by hand.
Power-cycle, then re-verify against the API
Restart the reader, confirm the location's network allows Stripe's endpoints, then read GET /v1/terminal/readers/{tmr_id} and check for status: "online" and a fresh last_seen_at. Retire hardware that is genuinely dead with DELETE /v1/terminal/readers/{tmr_id} so it stops occupying the alert every morning.
How to check it worked
Re-run the script after the reader is back on the network. Every reader should be online with a recent check-in, and on the same firmware as the rest of its device type.
python3 stripe_terminal_readers.py --stale-hours 6
# clear 7 reader(s) online, newest check-in 0.1h, firmware consistent
The full code
One paginated GET and no writes — a restricted key with read access to Terminal is enough, and is what you should give it. Two pure functions: one classifies a reader from its status, its check-in age and its last action, including a guard against the millisecond mistake that makes this check useless, and one finds the firmware outliers within each device type.
"""Report Stripe Terminal readers that are offline, stale, wedged or behind on firmware.
Read only. One paginated GET and no writes: give this a RESTRICTED key with read
access to Terminal. The repair is printed, never performed, because this script
holds a credential to a live payments account.
"""
import argparse
import collections
import logging
import os
import sys
import time
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("stripe_terminal_readers")
API = "https://api.stripe.com/v1"
# last_seen_at is in MILLISECONDS, unlike almost every other Stripe timestamp.
# Anything below this is a seconds value that has been passed in by mistake:
# 1e11 ms is 1973, and 1e11 seconds is the year 5138, so no real reading is
# ambiguous. Refusing to judge beats reporting a fleet as decades stale.
MS_FLOOR = 100_000_000_000
STALE_HOURS = 6.0
def reader_state(status, last_seen_at_ms, now_ms, action_status=None,
failure_code=None, stale_hours=STALE_HOURS):
"""Classify one Terminal reader. Pure, so the units guard can be tested offline.
`last_seen_at_ms` and `now_ms` are both milliseconds. Returns (state, detail).
"""
if last_seen_at_ms is not None and last_seen_at_ms < MS_FLOOR:
return ("unknown",
"last_seen_at is %d, which is a seconds timestamp; this reader "
"cannot be judged until the units are right" % last_seen_at_ms)
age_h = (None if last_seen_at_ms is None
else (now_ms - last_seen_at_ms) / 3_600_000.0)
if status == "offline":
seen = "never seen" if age_h is None else "last seen %.1f hour(s) ago" % age_h
return ("offline", "status offline, %s; it will not take a payment" % seen)
if age_h is None:
return ("unknown", "no last_seen_at, so liveness cannot be confirmed")
if age_h >= stale_hours:
return ("stale",
"status %s but no check-in for %.1f hour(s); status lags reality, "
"so treat this as unusable" % (status, age_h))
if action_status == "failed":
return ("action_failed",
"reachable but wedged on a failed action: %s"
% (failure_code or "no failure_code on the action"))
if status == "online":
return ("online", "checked in %.1f hour(s) ago" % age_h)
return ("unknown", "unrecognised status %r" % (status,))
def firmware_outliers(readers):
"""Readers not on the version most of their own device_type is running. Pure.
`readers` is a list of dicts with id, device_type and device_sw_version. A
device type with a single reader has no majority and is skipped rather than
reported as an outlier against itself.
"""
by_type = collections.defaultdict(list)
for r in readers:
by_type[r.get("device_type")].append(r)
out = []
for device_type, group in sorted(by_type.items(), key=lambda kv: str(kv[0])):
versions = collections.Counter(r.get("device_sw_version") for r in group
if r.get("device_sw_version"))
if len(group) < 2 or not versions:
continue
majority, _ = versions.most_common(1)[0]
for r in group:
v = r.get("device_sw_version")
if v and v != majority:
out.append((r.get("id"), device_type, v, majority))
return out
def get(session, path, params):
r = session.get(API + path, params=params, timeout=30)
if r.status_code == 401:
raise SystemExit("401 from Stripe: the key is wrong, or is for the other mode")
if r.status_code == 403:
raise SystemExit("403 from Stripe: the restricted key has no read access to "
"Terminal")
r.raise_for_status()
return r.json()
def page_all(session, path, params, cap=2000):
out = []
params = dict(params)
while True:
page = get(session, path, params)
data = page.get("data", [])
out.extend(data)
if not data or not page.get("has_more") or len(out) >= cap:
return out
params["starting_after"] = data[-1]["id"]
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--location", help="only check readers at this location id")
ap.add_argument("--stale-hours", type=float, default=STALE_HOURS,
help="check-in age past which a reader is unusable")
args = ap.parse_args()
key = os.environ.get("STRIPE_API_KEY")
if not key:
log.error("set STRIPE_API_KEY (use a restricted, read-only key)")
return 2
s = requests.Session()
s.headers.update({"Authorization": "Bearer " + key})
params = {"limit": 100}
if args.location:
params["location"] = args.location
readers = page_all(s, "/terminal/readers", params)
now_ms = int(time.time() * 1000)
bad = 0
freshest = None
for r in readers:
action = r.get("action") or {}
state, detail = reader_state(r.get("status"), r.get("last_seen_at"), now_ms,
action.get("status"), action.get("failure_code"),
args.stale_hours)
seen = r.get("last_seen_at")
if seen and seen >= MS_FLOOR and (freshest is None or seen > freshest):
freshest = seen
if state != "online":
bad += 1
log.warning(" %-13s %s %s %s", state, r.get("id"),
r.get("label") or r.get("device_type"), detail)
drift = firmware_outliers(readers)
for rid, device_type, version, majority in drift:
log.warning(" %-13s %s %s on %s, the rest of the fleet is on %s",
"firmware", rid, device_type, version, majority)
if not bad and not drift:
age = 0.0 if freshest is None else (now_ms - freshest) / 3_600_000.0
log.info("%-11s %d reader(s) online, newest check-in %.1fh, firmware consistent",
"clear", len(readers), age)
return 0
log.warning("%-11s %d of %d reader(s) not usable, %d on odd firmware",
"offline", bad, len(readers), len(drift))
log.warning(" power-cycle the reader and confirm the location's network reaches "
"Stripe, then re-check:")
log.warning(" GET %s/terminal/readers/<tmr_id> "
"(want status online AND a fresh last_seen_at)", API)
log.warning(" leave drifting readers powered and connected through their "
"configured update window")
log.warning(" retire dead hardware so it stops filling this report:")
log.warning(" DELETE %s/terminal/readers/<tmr_id>", API)
return 1
if __name__ == "__main__":
sys.exit(main())
/**
* Report Stripe Terminal readers that are offline, stale, wedged or behind on firmware.
*
* Read only. One paginated GET and no writes: give this a RESTRICTED key with
* read access to Terminal. The repair is printed, never performed.
*/
const API = 'https://api.stripe.com/v1';
// last_seen_at is in MILLISECONDS, unlike almost every other Stripe timestamp.
// Anything below this is a seconds value passed in by mistake.
export const MS_FLOOR = 100000000000;
export const STALE_HOURS = 6.0;
/**
* Classify one Terminal reader. Pure, so the units guard can be tested offline.
* `lastSeenAtMs` and `nowMs` are both milliseconds.
*/
export function readerState(status, lastSeenAtMs, nowMs, actionStatus = null,
failureCode = null, staleHours = STALE_HOURS) {
const missing = (v) => v === null || v === undefined;
if (!missing(lastSeenAtMs) && lastSeenAtMs < MS_FLOOR) {
return ['unknown',
`last_seen_at is ${lastSeenAtMs}, which is a seconds timestamp; this reader ` +
'cannot be judged until the units are right'];
}
const ageH = missing(lastSeenAtMs) ? null : (nowMs - lastSeenAtMs) / 3600000;
if (status === 'offline') {
const seen = ageH === null ? 'never seen' : `last seen ${ageH.toFixed(1)} hour(s) ago`;
return ['offline', `status offline, ${seen}; it will not take a payment`];
}
if (ageH === null) {
return ['unknown', 'no last_seen_at, so liveness cannot be confirmed'];
}
if (ageH >= staleHours) {
return ['stale',
`status ${status} but no check-in for ${ageH.toFixed(1)} hour(s); status lags ` +
'reality, so treat this as unusable'];
}
if (actionStatus === 'failed') {
return ['action_failed',
`reachable but wedged on a failed action: ${failureCode || 'no failure_code on the action'}`];
}
if (status === 'online') return ['online', `checked in ${ageH.toFixed(1)} hour(s) ago`];
return ['unknown', `unrecognised status ${JSON.stringify(status)}`];
}
/** Readers not on the version most of their own device_type is running. Pure. */
export function firmwareOutliers(readers) {
const byType = new Map();
for (const r of readers) {
if (!byType.has(r.device_type)) byType.set(r.device_type, []);
byType.get(r.device_type).push(r);
}
const out = [];
for (const deviceType of [...byType.keys()].sort((a, b) => String(a).localeCompare(String(b)))) {
const group = byType.get(deviceType);
const counts = new Map();
for (const r of group) {
if (r.device_sw_version) {
counts.set(r.device_sw_version, (counts.get(r.device_sw_version) ?? 0) + 1);
}
}
if (group.length < 2 || counts.size === 0) continue;
let majority = null;
let best = -1;
for (const [v, n] of counts) if (n > best) { majority = v; best = n; }
for (const r of group) {
if (r.device_sw_version && r.device_sw_version !== majority) {
out.push([r.id, deviceType, r.device_sw_version, majority]);
}
}
}
return out;
}
async function get(key, path, params) {
const url = new URL(API + path);
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
const res = await fetch(url, { headers: { Authorization: `Bearer ${key}` } });
if (res.status === 401) {
throw new Error('401 from Stripe: the key is wrong, or is for the other mode');
}
if (res.status === 403) {
throw new Error('403 from Stripe: the restricted key has no read access to Terminal');
}
if (!res.ok) throw new Error(`${res.status} from ${url.pathname}`);
return res.json();
}
async function pageAll(key, path, params, cap = 2000) {
const out = [];
const p = { ...params };
for (;;) {
const page = await get(key, path, p);
const data = page.data ?? [];
out.push(...data);
if (data.length === 0 || !page.has_more || out.length >= cap) return out;
p.starting_after = data[data.length - 1].id;
}
}
async function main() {
const key = process.env.STRIPE_API_KEY;
if (!key) {
console.error('set STRIPE_API_KEY (use a restricted, read-only key)');
process.exitCode = 2;
return;
}
const staleHours = Number(process.env.STALE_HOURS ?? STALE_HOURS);
const params = { limit: 100 };
if (process.env.LOCATION) params.location = process.env.LOCATION;
const readers = await pageAll(key, '/terminal/readers', params);
const nowMs = Date.now();
let bad = 0;
let freshest = null;
for (const r of readers) {
const action = r.action ?? {};
const [state, detail] = readerState(r.status, r.last_seen_at, nowMs,
action.status, action.failure_code, staleHours);
if (r.last_seen_at && r.last_seen_at >= MS_FLOOR
&& (freshest === null || r.last_seen_at > freshest)) {
freshest = r.last_seen_at;
}
if (state !== 'online') {
bad += 1;
console.warn(` ${state.padEnd(13)} ${r.id} ${r.label ?? r.device_type} ${detail}`);
}
}
const drift = firmwareOutliers(readers);
for (const [rid, deviceType, version, majority] of drift) {
console.warn(` firmware ${rid} ${deviceType} on ${version}, the rest of ` +
`the fleet is on ${majority}`);
}
if (!bad && drift.length === 0) {
const age = freshest === null ? 0 : (nowMs - freshest) / 3600000;
console.log(`clear ${readers.length} reader(s) online, newest check-in ` +
`${age.toFixed(1)}h, firmware consistent`);
return;
}
console.warn(`offline ${bad} of ${readers.length} reader(s) not usable, ` +
`${drift.length} on odd firmware`);
console.warn(' power-cycle the reader and confirm the location network reaches ' +
'Stripe, then re-check:');
console.warn(` GET ${API}/terminal/readers/<tmr_id> ` +
'(want status online AND a fresh last_seen_at)');
console.warn(' leave drifting readers powered and connected through their ' +
'configured update window');
console.warn(' retire dead hardware so it stops filling this report:');
console.warn(` DELETE ${API}/terminal/readers/<tmr_id>`);
process.exitCode = 1;
}
// Only run when invoked directly, so importing this module from the test file
// does not fire main() and fail the suite on the missing key.
if (import.meta.url === `file://${process.argv[1]}`) {
main().catch((err) => { console.error(err.message); process.exitCode = 2; });
}
Add a test
The first test is the one that matters most, and it is about units: a last_seen_at that arrived in seconds must not be quietly turned into a reader that has been missing for fifty years. After that it is the ordering — that a stale check-in outranks a cheerful online — and the firmware tally, which must not call a lone reader an outlier against itself.
from stripe_terminal_readers import firmware_outliers, reader_state
NOW_MS = 1_756_000_000_000
HOUR = 3_600_000
def test_a_seconds_timestamp_is_refused_not_believed():
# 1756000000 is a perfectly good seconds timestamp and a nonsense millisecond
# one. Reporting it as 50 years stale is how this check gets ignored.
state, detail = reader_state("online", NOW_MS // 1000, NOW_MS)
assert state == "unknown"
assert "seconds timestamp" in detail
def test_recent_check_in_on_an_online_reader_is_fine():
state, _ = reader_state("online", NOW_MS - HOUR, NOW_MS)
assert state == "online"
def test_stale_beats_a_cheerful_status():
# status lags reality, so six hours without a check-in is unusable even while
# the reader still claims to be online.
assert reader_state("online", NOW_MS - 5 * HOUR, NOW_MS)[0] == "online"
state, detail = reader_state("online", NOW_MS - 6 * HOUR, NOW_MS)
assert state == "stale"
assert "status lags reality" in detail
def test_offline_and_wedged_are_different_problems():
assert reader_state("offline", NOW_MS - HOUR, NOW_MS)[0] == "offline"
state, detail = reader_state("online", NOW_MS - HOUR, NOW_MS,
"failed", "reader_timeout")
assert state == "action_failed"
assert "reader_timeout" in detail
def test_firmware_outliers_need_a_majority_to_be_outliers_from():
fleet = [
{"id": "tmr_1", "device_type": "bbpos_wisepos_e", "device_sw_version": "2.24"},
{"id": "tmr_2", "device_type": "bbpos_wisepos_e", "device_sw_version": "2.24"},
{"id": "tmr_3", "device_type": "bbpos_wisepos_e", "device_sw_version": "2.11"},
{"id": "tmr_4", "device_type": "stripe_s700", "device_sw_version": "1.4"},
]
out = firmware_outliers(fleet)
assert [row[0] for row in out] == ["tmr_3"]
assert out[0][3] == "2.24"
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { firmwareOutliers, readerState } from './stripe-terminal-readers.mjs';
const NOW_MS = 1756000000000;
const HOUR = 3600000;
test('a seconds timestamp is refused, not believed', () => {
const [state, detail] = readerState('online', Math.floor(NOW_MS / 1000), NOW_MS);
assert.equal(state, 'unknown');
assert.match(detail, /seconds timestamp/);
});
test('recent check-in on an online reader is fine', () => {
assert.equal(readerState('online', NOW_MS - HOUR, NOW_MS)[0], 'online');
});
test('stale beats a cheerful status', () => {
assert.equal(readerState('online', NOW_MS - 5 * HOUR, NOW_MS)[0], 'online');
const [state, detail] = readerState('online', NOW_MS - 6 * HOUR, NOW_MS);
assert.equal(state, 'stale');
assert.match(detail, /status lags reality/);
});
test('offline and wedged are different problems', () => {
assert.equal(readerState('offline', NOW_MS - HOUR, NOW_MS)[0], 'offline');
const [state, detail] = readerState('online', NOW_MS - HOUR, NOW_MS,
'failed', 'reader_timeout');
assert.equal(state, 'action_failed');
assert.match(detail, /reader_timeout/);
});
test('firmware outliers need a majority to be outliers from', () => {
const fleet = [
{ id: 'tmr_1', device_type: 'bbpos_wisepos_e', device_sw_version: '2.24' },
{ id: 'tmr_2', device_type: 'bbpos_wisepos_e', device_sw_version: '2.24' },
{ id: 'tmr_3', device_type: 'bbpos_wisepos_e', device_sw_version: '2.11' },
{ id: 'tmr_4', device_type: 'stripe_s700', device_sw_version: '1.4' },
];
const out = firmwareOutliers(fleet);
assert.deepEqual(out.map((row) => row[0]), ['tmr_3']);
assert.equal(out[0][3], '2.24');
});
FAQ
Why is there nothing to investigate when a reader goes down?
Because an offline reader never starts a payment. It cannot accept a process_payment_intent action, so no PaymentIntent is created, no charge fails and no decline is recorded. Every alert you have is watching for failed objects, and there are none. The outage is an absence of records.
Is status enough to tell whether a reader is usable?
No, and Stripe says not to rely on it for blocking flows. It can lag the reader's real state. last_seen_at compared against now is the check that does not depend on a cached status, which is why this script treats a stale check-in as unusable even when status still says online.
Why is last_seen_at in milliseconds?
It just is, unlike created and almost every other timestamp in the API, which are seconds. That inconsistency is the single most common bug in a check like this: a subtraction on the wrong scale either flags the whole fleet or none of it. The script refuses to classify a value that looks like seconds rather than guessing.
What does a failed action on a reader mean?
The reader is reachable but wedged on the last thing it was asked to do; action.failure_code says which way. That is a different repair from an unreachable reader, because the network is fine and the device is answering, so start with the action rather than the router.
How do I stop firmware drifting?
Readers update during their configured window while powered and connected, so the reader that is switched off overnight is the one that falls behind. Group device_sw_version within each device_type, find the ones that are not on the same version as their peers, and leave those powered through the window.
Related field notes
- PaymentIntents left in requires_payment_method
- Payment failure events nobody subscribes to
- A report run fails after the 200 and no CSV lands
Sources
Every figure in this note is traced to one of these. Prices are list rates and change — check them for your own region before acting.
- The reader object — Stripe API reference
- Stripe Terminal — Stripe Docs
- Types of events — Stripe API reference
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.