Skip to main content
digital accessibility compliance for state and local government
Scan a site

List every open critical issue across all my agency’s clients

Last updated 28 October 2026 · API version v1

One script, run once, that reports every open critical issue across every client an agency manages — with the output it printed against the sandbox.

The question this answers

An agency runs twenty-two government websites and the account director has decided every client needs a monthly accessibility report. Doing that by hand twenty-two times is the problem; doing it in one script is what an agency key is for. It reaches every client the agency has not released, so one key and one script cover the whole portfolio.

The examples here use an agency’s test key, which reads the sandbox’s 3 fictional organisations as an agency reads its clients. Put the key in BARRIEROPS_API_KEY and every command below runs as shown.

The walk-through, one request at a time

1. List the clients your agency key can reach

bash: list the organisations the key reaches

curl https://api.barrierops.com/api/v1/organizations \
  -H "Authorization: Bearer $BARRIEROPS_API_KEY"

Lines are wrapped for printing. The original is one line.

json: the complete response

{
  "items": [
    {
      "id": "b7749447-87f3-5d35-beee-99e152f35574",
      "name": "City of Riverbend",
      "readOnly": true
    },
    {
      "id": "ba8d4e08-bff9-5c3b-bb26-cf0b019d840b",
      "name": "Riverbend Public Library",
      "readOnly": true
    },
    {
      "id": "76d68331-b591-5c16-95ea-41d27f95f95a",
      "name": "Riverbend Water District",
      "readOnly": true
    }
  ],
  "nextCursor": null,
  "total": 3
}

Lines are wrapped for printing. The original is one line.

2. List each client’s assets

With orgId, for each organisation. Here for City of Riverbend:

bash: list one client’s assets

curl "https://api.barrierops.com/api/v1/properties?orgId=b7749447-87f3-5d35-beee-99e152f35574" \
  -H "Authorization: Bearer $BARRIEROPS_API_KEY"

Lines are wrapped for printing. The original is one line.

json: the complete response

{
  "items": [
    {
      "id": "9ccf6432-ad06-5afe-8a30-52373a0346a4",
      "orgId": "b7749447-87f3-5d35-beee-99e152f35574",
      "name": "riverbend.example",
      "baseUrl": "https://www.riverbend.example",
      "kind": "website",
      "verificationState": "verified",
      "createdAt": "2026-07-01T15:40:00.000Z",
      "deletedAt": null
    }
  ],
  "nextCursor": null,
  "total": 1
}

Lines are wrapped for printing. The original is one line.

3. Filter each asset’s issues

Critical impact, open status, as many to a page as the API serves:

bash: list one asset’s open critical issues

curl "https://api.barrierops.com/api/v1/properties/9ccf6432-ad06-5afe-8a30-52373a0346a4/issues?impact=critical&status=open&limit=200" \
  -H "Authorization: Bearer $BARRIEROPS_API_KEY"

Lines are wrapped for printing. The original is one line.

json: the complete response

{
  "items": [
    {
      "id": "274b937b-fb93-5927-9ba6-c81132eb1e9a",
      "orgId": "b7749447-87f3-5d35-beee-99e152f35574",
      "propertyId": "9ccf6432-ad06-5afe-8a30-52373a0346a4",
      "title": "Input buttons must have discernible text",
      "wcagSC": [
        "4.1.2"
      ],
      "impact": "critical",
      "status": "open",
      "outcome": "violation",
      "reviewState": "pending",
      "viewport": "desktop",
      "assignee": null,
      "occurrenceCount": 30,
      "firstSeenAt": "2026-10-07T15:14:00.000Z",
      "updatedAt": "2026-10-07T15:14:00.000Z"
    },
    {
      "id": "1ee558db-c390-5838-afd8-c25e3b05e69c",
      "orgId": "b7749447-87f3-5d35-beee-99e152f35574",
      "propertyId": "9ccf6432-ad06-5afe-8a30-52373a0346a4",
      "title": "ARIA attributes must conform to valid values",
      "wcagSC": [
        "4.1.2"
      ],
      "impact": "critical",
      "status": "open",
      "outcome": "violation",
      "reviewState": "pending",
      "viewport": "desktop",
      "assignee": null,
      "occurrenceCount": 300,
      "firstSeenAt": "2026-07-07T15:14:00.000Z",
      "updatedAt": "2026-10-07T15:14:00.000Z"
    },
    {
      "id": "5764b121-31ac-551f-8ac6-53c07c083fd2",
      "orgId": "b7749447-87f3-5d35-beee-99e152f35574",
      "propertyId": "9ccf6432-ad06-5afe-8a30-52373a0346a4",
      "title": "Images must have alternative text",
      "wcagSC": [
        "1.1.1"
      ],
      "impact": "critical",
      "status": "open",
      "outcome": "violation",
      "reviewState": "pending",
      "viewport": "desktop",
      "assignee": null,
      "occurrenceCount": 64,
      "firstSeenAt": "2026-07-07T15:14:00.000Z",
      "updatedAt": "2026-10-07T15:14:00.000Z"
    },
    {
      "id": "94c39f86-f871-50c0-837a-8a7b1d80f3f7",
      "orgId": "b7749447-87f3-5d35-beee-99e152f35574",
      "propertyId": "9ccf6432-ad06-5afe-8a30-52373a0346a4",
      "title": "Images must have alternative text",
      "wcagSC": [
        "1.1.1"
      ],
      "impact": "critical",
      "status": "open",
      "outcome": "violation",
      "reviewState": "pending",
      "viewport": "desktop",
      "assignee": null,
      "occurrenceCount": 15,
      "firstSeenAt": "2026-07-07T15:14:00.000Z",
      "updatedAt": "2026-10-07T15:14:00.000Z"
    },
    {
      "id": "a9e10f40-7697-5ad9-aa24-c67c9e432d05",
      "orgId": "b7749447-87f3-5d35-beee-99e152f35574",
      "propertyId": "9ccf6432-ad06-5afe-8a30-52373a0346a4",
      "title": "Buttons must have discernible text",
      "wcagSC": [
        "4.1.2"
      ],
      "impact": "critical",
      "status": "open",
      "outcome": "violation",
      "reviewState": "pending",
      "viewport": "desktop",
      "assignee": null,
      "occurrenceCount": 1,
      "firstSeenAt": "2026-07-07T15:14:00.000Z",
      "updatedAt": "2026-10-07T15:14:00.000Z"
    },
    {
      "id": "cf6e300f-3f90-5ee1-a1d5-b28cc03ce135",
      "orgId": "b7749447-87f3-5d35-beee-99e152f35574",
      "propertyId": "9ccf6432-ad06-5afe-8a30-52373a0346a4",
      "title": "Elements must only use supported ARIA attributes",
      "wcagSC": [
        "4.1.2"
      ],
      "impact": "critical",
      "status": "open",
      "outcome": "violation",
      "reviewState": "pending",
      "viewport": "desktop",
      "assignee": null,
      "occurrenceCount": 90,
      "firstSeenAt": "2026-07-07T15:14:00.000Z",
      "updatedAt": "2026-10-07T15:14:00.000Z"
    }
  ],
  "nextCursor": null,
  "total": 6
}

Lines are wrapped for printing. The original is one line.

4. Page until nextCursor is null, and add it up

Every list above can run to more than one page, so the script follows nextCursor on each of them, as pagination describes, and totals what it read.

The script

TypeScript

typescript: list open critical issues across clients

// Every open critical issue across the client organisations an agency key reaches.
// Node 22.18 or later runs this file as it is: node open-critical.ts
import { env } from 'node:process';

const BASE_URL = 'https://api.barrierops.com/api/v1';
const KEY = env['BARRIEROPS_API_KEY'];

if (KEY === undefined) {
  throw new Error('Set BARRIEROPS_API_KEY to your agency’s API key first.');
}

interface Page<Item> {
  items: Item[];
  nextCursor: string | null;
  total: number;
}

interface Organization {
  id: string;
  name: string;
}

interface Asset {
  id: string;
}

interface Issue {
  id: string;
}

async function get<Body>(path: string): Promise<Body> {
  const response = await fetch(`${BASE_URL}${path}`, {
    headers: { Authorization: `Bearer ${KEY}` },
  });
  if (!response.ok) {
    throw new Error(`${String(response.status)} from ${path}: ${await response.text()}`);
  }
  return (await response.json()) as Body;
}

/** Every item of a list: one page after another, until nextCursor is null. */
async function all<Item>(path: string, filters: Record<string, string> = {}): Promise<Item[]> {
  const items: Item[] = [];
  let cursor: string | null = null;
  do {
    const query = new URLSearchParams({ ...filters, limit: '200' });
    if (cursor !== null) {
      query.set('cursor', cursor);
    }
    const page: Page<Item> = await get(`${path}?${query.toString()}`);
    items.push(...page.items);
    cursor = page.nextCursor;
  } while (cursor !== null);
  return items;
}

const count = (n: number, noun: string): string => `${String(n)} ${noun}${n === 1 ? '' : 's'}`;

const rows: { name: string; open: number }[] = [];

for (const client of await all<Organization>('/organizations')) {
  let open = 0;
  for (const asset of await all<Asset>('/properties', { orgId: client.id })) {
    const issues = await all<Issue>(`/properties/${asset.id}/issues`, {
      impact: 'critical',
      status: 'open',
    });
    open += issues.length;
  }
  rows.push({ name: client.name, open });
}

const width = Math.max(0, ...rows.map((row) => row.name.length));
for (const row of rows) {
  console.log(`${row.name.padEnd(width)}  ${String(row.open)} open critical`);
}
const total = rows.reduce((sum, row) => sum + row.open, 0);
console.log(`${count(rows.length, 'client')} · ${count(total, 'open critical issue')}`);

Lines are wrapped for printing. The original is one line.

Python

python: list open critical issues across clients

# Every open critical issue across the client organisations an agency key reaches.
# Python 3.8 or later, standard library only: python3 open_critical.py
import json
import os
import urllib.parse
import urllib.request

BASE_URL = "https://api.barrierops.com/api/v1"
KEY = os.environ["BARRIEROPS_API_KEY"]


def get(path):
    request = urllib.request.Request(
        BASE_URL + path, headers={"Authorization": f"Bearer {KEY}"}
    )
    with urllib.request.urlopen(request) as response:
        return json.load(response)


def every(path, **filters):
    """Every item of a list: one page after another, until nextCursor is null."""
    items = []
    cursor = None
    while True:
        query = {**filters, "limit": 200}
        if cursor is not None:
            query["cursor"] = cursor
        page = get(f"{path}?{urllib.parse.urlencode(query)}")
        items.extend(page["items"])
        cursor = page["nextCursor"]
        if cursor is None:
            return items


def count(n, noun):
    return f"{n} {noun}{'' if n == 1 else 's'}"


rows = []

for client in every("/organizations"):
    open_critical = 0
    for asset in every("/properties", orgId=client["id"]):
        issues = every(
            f"/properties/{asset['id']}/issues", impact="critical", status="open"
        )
        open_critical += len(issues)
    rows.append((client["name"], open_critical))

width = max((len(name) for name, _ in rows), default=0)
for name, open_critical in rows:
    print(f"{name.ljust(width)}  {open_critical} open critical")
total = sum(open_critical for _, open_critical in rows)
print(f"{count(len(rows), 'client')} · {count(total, 'open critical issue')}")

Lines are wrapped for printing. The original is one line.

What it prints

Both programs print the same thing. Against the sandbox:

text: the output of either program

City of Riverbend         6 open critical
Riverbend Public Library  6 open critical
Riverbend Water District  6 open critical
3 clients · 18 open critical issues

Lines are wrapped for printing. The original is one line.

The sandbox’s organisations are copies of one fictional city, so their counts match; a real portfolio’s will not.

Three things to know

These are facts about how an issue is identified rather than about the API, and they are the three that most often surprise somebody in their second month.

The same defect at desktop and mobile is two issues
Viewport is part of the issue identity, so filter or group on viewport if you do not want both. A contrast failure found at 1280 × 800 and again at 390 × 844 is two rows, not one.
An occurrence count is not a page count you can add up
An issue’s occurrence count is not a page count you can add across assets. Two assets can share a template and they are still two issues, so summing occurrences across a portfolio counts a shared navigation once per asset.
Status says where the work is, never whether the site conforms
The six values below are a work state. None of them is a conformance claim, and there is no value meaning “this page passes” — a clean automated result is a statement about the test, not about the site.

The values you filter on

Impact is the engine’s assessment of how significantly a barrier affects access in the tested context. Status is where the work has got to. They are separate fields and a script that sorts by one is not ordering by the other.

impact — 4 values
ValueShown as
criticalCritical
seriousSerious
moderateModerate
minorMinor
status — 6 values
ValueShown as
openOpen
assignedAssigned
in_progressIn progress
ready_to_verifyReady to verify
verifiedVerified
deferredDeferred

There is no value meaning this page passes, and there will not be one. A status records what a person or a scan did; it does not record a conclusion about the site, and an automated result that finds nothing is a statement about the test rather than about the page.

What the Portfolio edition does covers the rest of the multi-client picture.

https://barrierops.com/docs/examples/open-critical-across-clients