Skip to main content
Auric Artisan · Documentation

The Accessibility Corpus API

Date: August 28, 2026 For: Developers Category: Guide Author: Chirag Bansal
Back to Documentation API reference Open the library

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.

Table of contents

  1. 1. What this API is for
  2. 2. Getting a key and making your first call
  3. 3. The corpus is a seed, not a database
  4. 4. Browsing and paging
  5. 5. Citing a pair in a report
  6. 6. The contrast map
  7. 7. Picks: colours that already work
  8. 8. Auditing a palette
  9. 9. Three complete tasks
  10. 10. What each call costs
  11. 11. Errors, limits and pitfalls
  12. 12. Endpoint reference

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 is next_cursor null.
  • 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+ through F.
  • polarity — BoW (dark on light) or WoB.
  • 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.

EndpointCreditsWhy
GET /v1/accessibility/corpus/{id}1One record is one derivation.
POST /v1/accessibility/audit2A bisection per failing colour.
GET /v1/accessibility/corpus3Scans until the page fills.
POST /v1/accessibility/picks4A contour lookup per hue.
POST /v1/accessibility/map8About 54,000 colour conversions at 72 hues.
GET /v1/accessibility/corpus/stats25Measures 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

  1. An empty page is not the end. Stop on exhausted, never on records.length === 0.
  2. The count you send on picks is what was tried. You get count back and dropped discarded; the sum is what you asked for, echoed as requested.
  3. null is 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

+
MethodPathPurpose
GET/v1/accessibility/corpusBrowse under a filter, with a cursor.
GET/v1/accessibility/corpus/statsMeasured contrast distribution.
GET/v1/accessibility/corpus/{record_id}Resolve one record by id or index.
POST/v1/accessibility/mapUsable territory on a background.
POST/v1/accessibility/picksReady foregrounds that clear a target.
POST/v1/accessibility/auditAudit 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.