The Accessibility Corpus API
Overview
The accessibility library holds five million foreground/background pairs and draws the contrast map — the territory of colour a given background actually leaves you. This guide is how to drive both from your own code, end to end: getting a key, the six endpoints, and three complete tasks worth doing with them.
One thing is worth knowing before you start. About 88% of the corpus fails WCAG AA for body text. That is not a defect in the data; it is what random colour is like. Every endpoint here is built around that fact, which is why the browse response tells you how far it had to look, not just what it found.
1. What this API is for
+
POST /v1/accessibility/check answers one question: does this pair pass?
That is useful once you have already chosen two colours. It cannot help you choose
them, and it cannot tell you whether the background you picked leaves you any room at
all.
These six endpoints answer the questions that come before and after that one.
- What does this background leave me? —
/v1/accessibility/map - Give me colours that already work on it —
/v1/accessibility/picks - Does my existing palette survive on it, and what do I change? —
/v1/accessibility/audit - Show me real pairs at a given standard —
/v1/accessibility/corpus - What is colour actually like, statistically? —
/v1/accessibility/corpus/stats - Pin one pair down so somebody else can check it —
/v1/accessibility/corpus/{record_id}
They run the same two modules the library page runs in your browser, so the API and the page cannot disagree about a colour.
2. Getting a key and making your first call
+
Create a key in your dashboard under
API keys. Send it as a bearer token on every request. The base URL is
https://api.auricartisan.com.
curl https://api.auricartisan.com/v1/accessibility/corpus/stats \
-H "Authorization: Bearer $AURIC_API_KEY"
That returns the shape of the whole corpus:
{
"sample": 200000,
"mean_contrast_ratio": 2.579,
"share_failing_aa_body": 88.07,
"share_at_least": { "3": 26.46, "4.5": 11.93, "7": 3.59, "10": 0.88 },
"distribution": [ { "from": 1, "to": 2, "share": 51.64 }, ... ]
}
Read that second line before anything else. Half of all colour pairs sit between 1:1 and 2:1 — text you genuinely cannot read. Only one pair in eight clears AA for body text. If you are generating colour and checking it afterwards, you are throwing away most of what you generate.
Every endpoint below also works without a key against a local development server
started with AURIC_API_OPEN_MODE=1. Nothing in this guide needs an account
to try.
3. The corpus is a seed, not a database
+Nothing is stored. The five million records are generated from four numbers — a seed of 9001, a count, a batch size of 50,000 and a stride — by an xorshift32 generator that consumes six bytes per record. Record 91 is not looked up; it is computed.
That has two consequences worth building on.
A citation is checkable. If a report says acc_2j, anyone
can recover the same two colours from the seed without being given your data, or
trusting that you copied them correctly.
Paging costs arithmetic, not I/O. There is no table to scan and no round trip per page, so a filter can genuinely examine a hundred thousand records inside one request.
4. Browsing and paging
+GET /v1/accessibility/corpus?level=AAA&limit=5
{
"count": 5,
"scanned": 49,
"hit_rate": 0.102041,
"cursor": 0,
"next_cursor": 49,
"exhausted": false,
"truncated": false,
"records": [
{
"record_id": "acc_7",
"index": 7,
"foreground": "#5cf6f8",
"background": "#171d96",
"contrast_ratio": 9.68,
"wcag21_level": "AAA",
"auric_grade": "A",
"recommendation": "body-text-WCAG3"
}
]
}
scanned is the honest part. Those five AAA pairs cost forty-nine records
looked at, and the API says so rather than presenting five results as if they were
typical. Filters apply to the whole corpus, not to the page — there is no
“filtered the twenty-four rows we happened to load” behaviour here.
Paging
Pass next_cursor back as cursor. It is a corpus index, so it
is stable and shareable.
GET /v1/accessibility/corpus?level=AAA&limit=5&cursor=49
The two flags that matter
-
exhausted— you reached the end of the corpus. Only then isnext_cursornull. -
truncated— the scan budget ran out before the page filled. This is not “no more results”. A rare filter returns an empty page with a cursor; keep going.
Raise max_scan (default 100,000, maximum 1,000,000) when a filter is rare.
Looking for 18:1 pairs? They are roughly one in 180,000, so a default scan may
legitimately come back empty.
Filters
min_ratio,max_ratio— a contrast window, 1–21.level—AA,AAA,fail,any.grade— Auric SD grade,A+throughF.polarity—BoW(dark on light) orWoB.full=true— the complete record, including all ten vision-deficiency simulations.
Leave full off unless you need it. Without it a record that will be
discarded is never scored against ten vision models, which is most of what a scan
would otherwise spend its time on.
5. Citing a pair in a report
+GET /v1/accessibility/corpus/acc_2j
Accepts the record id or a bare index — /corpus/91 resolves the same record.
{
"record": { "record_id": "acc_2j", "foreground": "#d8d147", "background": "#cbd9cc", ... },
"cite": "acc_2j (#D8D147 on #CBD9CC, 1.09:1) — corpus seed 9001, index 91",
"recompute": "https://api.auricartisan.com/v1/accessibility/corpus/acc_2j"
}
Put cite in your audit document. It carries the seed and the index, which
is everything a reader needs to reproduce the record themselves — the reason for
defining the corpus by a seed in the first place.
6. The contrast map
+This is the endpoint that answers a question a pair checker cannot: given this background, what colour is available to me at all?
POST /v1/accessibility/map
{ "ground": "#1D2A3E", "targets": [4.5, 7], "resolution": 72 }
{
"ground": "#1d2a3e",
"polarity": "light-on-dark",
"note": "On a dark ground the usable colour lies ABOVE each line.",
"contours": [
{
"target": 4.5,
"territory_pct": 34.15,
"hues_reachable": 72,
"hues_unreachable": 0,
"line": [ { "hue": 2.5, "lightness": 0.6811, "hex": "#ff478d" }, ... ]
},
{ "target": 7, ... }
]
}
For each hue, lightness is the point at which the most saturated colour of
that hue just reaches the target against your background. On a dark ground everything
above the line works; on a light ground, everything below it.
territory_pct is the number to compare backgrounds with.
It is the share of the colour slice that clears the target. A background at 34% leaves
you three times the room of one at 11%, and that is a decision you can make before you
have chosen a single foreground colour.
A null lightness is a real answer, not a gap: that hue never reaches the
target on this ground at any lightness. hues_unreachable counts them.
7. Picks: colours that already work
+POST /v1/accessibility/picks
{ "ground": "#1D2A3E", "target": 4.5, "count": 12 }
{
"count": 12,
"dropped": 0,
"note": "Every hue reaches 4.5:1 on this ground.",
"picks": [ { "hex": "#ff929c", "contrast_ratio": 6.79, "hue": 15, "lightness": 0.7787 }, ... ]
}
Twelve hues around the circle, each taken to its boundary and then stepped past it by
margin so rounding cannot land a pick just under the target.
The count you send is how many were tried; the count that comes back is how many you get.
dropped counts hues that could not make it. Ask for AAA on mid-grey and
most will drop — the set only ever contains colours that pass, so it shortens
rather than lying.
8. Auditing a palette
+POST /v1/accessibility/audit
{ "ground": "#1D2A3E", "colors": ["#D3AF37", "#4A5568", "#F7FAFC"], "target": 4.5 }
{
"verdict": "fail",
"passing": 2,
"failing": 1,
"results": [
{ "input": "#d3af37", "contrast_ratio": 6.85, "passes": true },
{
"input": "#4a5568", "contrast_ratio": 1.92, "passes": false,
"fix": { "hex": "#909db2", "contrast_ratio": 5.25, "hue_kept": 261, "chroma_kept": 0.0343 },
"fix_note": "Same hue and saturation, lightness moved onto the contour."
}
]
}
The fix moves lightness and keeps both hue and saturation, so a corrected brand colour
still reads as the brand. #4A5568 is a desaturated slate and comes back
#909DB2 — a lighter desaturated slate, not a saturated blue.
Where a hue at that saturation cannot reach the target on that ground at all,
fix is null and fix_note says why. It will not
hand you a colour that does not work.
9. Three complete tasks
+Choose a background that leaves you room
Score your candidate backgrounds by territory before committing to one. This is the highest-leverage call in the API: a background is a decision you make once and live with everywhere.
const candidates = ["#1D2A3E", "#0B0B0C", "#2B3A55", "#F7FAFC"];
for (const ground of candidates) {
const r = await fetch("https://api.auricartisan.com/v1/accessibility/map", {
method: "POST",
headers: { "content-type": "application/json", authorization: `Bearer ${KEY}` },
body: JSON.stringify({ ground, targets: [4.5], resolution: 36 }),
}).then((r) => r.json());
const aa = r.contours[0];
console.log(ground, aa.territory_pct + "% usable", aa.hues_unreachable + " hues impossible");
}
Fix a brand palette without losing the brand
const audit = await fetch("https://api.auricartisan.com/v1/accessibility/audit", {
method: "POST",
headers: { "content-type": "application/json", authorization: `Bearer ${KEY}` },
body: JSON.stringify({ ground: "#1D2A3E", colors: brand, target: 4.5 }),
}).then((r) => r.json());
const patched = audit.results.map((r) => r.passes ? r.input : (r.fix?.hex ?? null));
const impossible = audit.results.filter((r) => !r.passes && !r.fix);
if (impossible.length) console.warn("need a different ground for:", impossible.map((r) => r.input));
Build a fixture set for your own test suite
Because records are derived from a seed, the same query returns the same pairs forever. That makes the corpus a good source of test fixtures — and every fixture carries an id your failure messages can cite.
async function sample(level, n) {
const out = [];
let cursor = 0;
while (out.length < n) {
const url = `https://api.auricartisan.com/v1/accessibility/corpus`
+ `?level=${level}&limit=50&cursor=${cursor}&max_scan=200000`;
const page = await fetch(url, { headers: { authorization: `Bearer ${KEY}` } }).then((r) => r.json());
out.push(...page.records);
if (page.exhausted) break; // the end — stop
cursor = page.next_cursor; // truncated pages still page on
}
return out.slice(0, n);
}
const failing = await sample("fail", 100); // 88% of the corpus, so this is fast
const strict = await sample("AAA", 100); // 3.5%, so this pages a few times
Note the loop breaks on exhausted, not on an empty page. An empty page
with truncated: true means “keep going”; treating it as the
end is the one mistake this API invites.
10. What each call costs
+Billing is in credits, weighted by the work a call actually does.
| Endpoint | Credits | Why |
|---|---|---|
GET /v1/accessibility/corpus/{id} | 1 | One record is one derivation. |
POST /v1/accessibility/audit | 2 | A bisection per failing colour. |
GET /v1/accessibility/corpus | 3 | Scans until the page fills. |
POST /v1/accessibility/picks | 4 | A contour lookup per hue. |
POST /v1/accessibility/map | 8 | About 54,000 colour conversions at 72 hues. |
GET /v1/accessibility/corpus/stats | 25 | Measures a 200,000-record prefix. |
Two cheap habits: drop resolution to 36 on the map when you only want
territory_pct, and cache stats — the same sample size
always returns the same numbers, so there is no reason to ask twice.
11. Errors, limits and pitfalls
+Validation failures are 400s that name the field:
{ "error": { "code": "INVALID_INPUT", "message": "level: must be one of AA, AAA, fail, any" } }
Limits
limit— at most 200 records per page.max_scan— at most 1,000,000 records examined per request.resolution— 12 to 360 hues on the map.colors— at most 64 per audit.targets— at most 6 contours per map call.
Three things that catch people out
-
An empty page is not the end. Stop on
exhausted, never onrecords.length === 0. -
The
countyou send on picks is what was tried. You getcountback anddroppeddiscarded; the sum is what you asked for, echoed asrequested. -
nullis an answer. A null lightness or a null fix means the thing is impossible on that ground, not that the API failed.
12. Endpoint reference
+| Method | Path | Purpose |
|---|---|---|
| GET | /v1/accessibility/corpus | Browse under a filter, with a cursor. |
| GET | /v1/accessibility/corpus/stats | Measured contrast distribution. |
| GET | /v1/accessibility/corpus/{record_id} | Resolve one record by id or index. |
| POST | /v1/accessibility/map | Usable territory on a background. |
| POST | /v1/accessibility/picks | Ready foregrounds that clear a target. |
| POST | /v1/accessibility/audit | Audit a palette, with a fix per failure. |
Full parameter tables, response schemas and a request builder are on the
API reference. The machine-readable forms are
GET /v1/openapi.yaml, GET /v1/catalog and the Postman
collection.
To see all of this working without writing any code, open the accessibility library — the map, the picks and the browsable index are the same engine this API exposes.