Skip to content

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.

Read-only key Python and Node.js Tests included
An online store on a laptop
Photo by charlesdeluvio on Unsplash
The short answer

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.

Router rebootsreader losesnetworkReader goesofflineactions refusedNoPaymentIntentnothing createdNo failedchargesno alert firesFound on Mondayby the missingtakings
Nothing fails, because nothing starts. The outage is an absence of records rather than a list of them.

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.

GET /v1/terminal/readerslast_seen_at against a millisecondclockChecked in minutes agoonlineOnline but stale 6hunusable whatever status saysstatus offlinepower cycle, check the networkAction failedreachable but wedgedTimestamp in secondsrefuse to judge, fix the units
status lags reality and last_seen_at is in milliseconds, so both the ordering and the units decide whether this check is worth running.

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.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Read only, it never writes. One of 114 Stripe fixes, free and open source.
stripe_terminal_readers.py
"""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())
stripe-terminal-readers.mjs
/**
 * 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.

test_stripe_terminal_readers.py
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"
stripe-terminal-readers.test.mjs
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

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.

Stuck on a tricky one?

If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.