WCAG check API: docs
Base address https://api.tradingcheckup.com. JSON in, JSON out. A scan takes about 20-90 seconds a page, so you start it, then ask for the result (or get a webhook).
Quick start (3 steps)
- Create a free key (sign in, one click). It starts with
tck_test_(example results) ortck_live_(real scans) and is shown once. - Save it as an environment variable:
export TC_API_KEY=tck_test_... - Start a scan, then ask for the result until
statusisdone:
# 1. start a scan (returns 202 with an id)
curl -s -X POST https://api.tradingcheckup.com/v1/wcag/scans \
-H "Authorization: Bearer $TC_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://www.example.com/", "pages": 1}'
# 2. ask for the result every 10 seconds until "status" is "done"
curl -s https://api.tradingcheckup.com/v1/wcag/scans/scan_XXXXXXXXXXXXXXXXXXXX \
-H "Authorization: Bearer $TC_API_KEY"A finished result looks like this (real output from our 2-page demo site, a made-up business; addresses changed to example.com):
{
"id": "scan_c1b9a6b19b2680b9ea03",
"object": "wcag_scan",
"status": "done",
"url": "https://www.example.com/",
"score": 52,
"counts": {
"definite": 5,
"needs_review": 0,
"fix_verified": 5,
"confirmed_by_second_check": 5
},
"owner_summary": [
"We checked 2 page(s) on desktop and phone. Accessibility score 52/100.",
"5 definite problem type(s), every one confirmed by a second, independent check; 5 serious.",
"Fix first: Button has no name. Some customers cannot use this control at all (blind or keyboard-only users), so enquiries, bookings or sign-ups can be lost here.",
"5 fix(es) were applied to a copy of the page and re-checked: the problem was gone and nothing new broke (Proven Fix)."
],
"pages": [
{
"url": "https://www.example.com/",
"status": "checked",
"title": "Home - Fixture Plumbing",
"note": null
},
{
"url": "https://www.example.com/about.html",
"status": "checked",
"title": "About us - Fixture Plumbing",
"note": null
}
],
"issues": [
{
"id": "iss_1",
"page": "https://www.example.com/",
"level": "definite",
"kind": "button-name",
"wcag": [
"4.1.2"
],
"impact": "critical",
"title": "Button has no name",
"who_is_affected": "Screen-reader and voice users hear \"button\" and cannot say or know which one.",
"explanation": "Button has no text, aria-label or title, so it is announced only as \"button\" (WCAG 4.1.2). It shows only an SVG icon (one rectangle).",
"business_impact": "Some customers cannot use this control at all (blind or keyboard-only users), so enquiries, bookings or sign-ups can be lost here.",
"who_fixes": "Your web developer (page template change)",
"effort": "About 30-60 minutes",
"selector": ".b1",
"html_snippet": "<button class=\"b1\"><svg width=\"20\" height=\"20\"><rect width=\"20\" height=\"20\"></rect></svg></button>",
"count": 1,
"also_at": [],
"second_check": {
"confirmed": true,
"method": "accessibility tree",
"detail": "Chrome's accessibility tree gives this button no name (visible, 36 x 29 px)"
},
"fix": {
"text": "Proven Fix (verified on a copy of this page): Add aria-label=\"Describe the action\" - (set `aria-label=\"Describe the action\"`) change `<button class=\"b1\">` to `<button class=\"b1\" aria-label=\"Describe the action\">`. We applied exactly this to a copy and re-ran the axe-core rules and our own page checks: the problem is gone on this element and no new problem appeared on the changed element(s). The words are a placeholder: this button shows only an SVG icon (one rectangle) and its code has no text, title or meaningful class/id, so the tool cannot tell its action. Click it (or find its click handler in the script) to see what it does, then write that action in 1-3 words, e.g. aria-label=\"Play video\" or aria-label=\"Close menu\"; better still, show that word on screen next to the icon.",
"verified_after_fix": true,
"status": "verified",
"code_after": [
"<button class=\"b1\" aria-label=\"Describe the action\"><svg width=\"20\" height=\"20\"><rect width=\"20\" height=\"20\"></rect></svg></button>"
],
"why_not_verified": null
}
},
{
"id": "iss_2",
"page": "https://www.example.com/",
"level": "definite",
"kind": "button-name",
"wcag": [
"4.1.2"
],
"impact": "critical",
"title": "Button has no name",
"who_is_affected": "Screen-reader and voice users hear \"button\" and cannot say or know which one.",
"explanation": "Button has no text, aria-label or title, so it is announced only as \"button\" (WCAG 4.1.2). It shows only an SVG icon (one circle).",
"business_impact": "Some customers cannot use this control at all (blind or keyboard-only users), so enquiries, bookings or sign-ups can be lost here.",
"who_fixes": "Your web developer (page template change)",
"effort": "About 30-60 minutes",
"selector": ".b2",
"html_snippet": "<button class=\"b2\"><svg width=\"20\" height=\"20\"><circle cx=\"10\" cy=\"10\" r=\"9\"></circle></svg></button>",
"count": 1,
"also_at": [],
"second_check": {
"confirmed": true,
"method": "accessibility tree",
"detail": "Chrome's accessibility tree gives this button no name (visible, 36 x 29 px)"
},
"fix": {
"text": "Proven Fix (verified on a copy of this page): Add aria-label=\"Describe the action\" - (set `aria-label=\"Describe the action\"`) change `<button class=\"b2\">` to `<button class=\"b2\" aria-label=\"Describe the action\">`. We applied exactly this to a copy and re-ran the axe-core rules and our own page checks: the problem is gone on this element and no new problem appeared on the changed element(s). The words are a placeholder: this button shows only an SVG icon (one circle) and its code has no text, title or meaningful class/id, so the tool cannot tell its action. Click it (or find its click handler in the script) to see what it does, then write that action in 1-3 words, e.g. aria-label=\"Play video\" or aria-label=\"Close menu\"; better still, show that word on screen next to the icon.",
"verified_after_fix": true,
"status": "verified",
"code_after": [
"<button class=\"b2\" aria-label=\"Describe the action\"><svg width=\"20\" height=\"20\"><circle cx=\"10\" cy=\"10\" r=\"9\"></circle></svg></button>"
],
"why_not_verified": null
}
},
"... more issues ..."
],
"legal_risk": {
"summary": "We found 5 definite accessibility problem type(s) (5 serious). Problems like these (missing labels, low contrast, keyboard traps, unnamed buttons) are the kind often listed in accessibility complaints and demand letters. Fixing them lowers that risk; it does not remove it.",
"laws": [
"... 4 regions with official sources ..."
],
"not_legal_advice": "..."
}
}Show the full result (5 issues)
{
"id": "scan_c1b9a6b19b2680b9ea03",
"object": "wcag_scan",
"status": "done",
"url": "https://www.example.com/",
"pages_requested": 2,
"device": "both",
"created_at": "2026-11-07T07:38:32Z",
"started_at": "2026-11-07T07:38:32Z",
"finished_at": "2026-11-07T07:38:59Z",
"links": {
"self": "/v1/wcag/scans/scan_c1b9a6b19b2680b9ea03",
"report_pdf": "/v1/wcag/scans/scan_c1b9a6b19b2680b9ea03/report.pdf"
},
"api_version": "2026-11-07",
"standard": "WCAG 2.2 AA",
"checked_views": [
"desktop 1280x900",
"phone 320x640"
],
"pages": [
{
"url": "https://www.example.com/",
"status": "checked",
"title": "Home - Fixture Plumbing",
"note": null
},
{
"url": "https://www.example.com/about.html",
"status": "checked",
"title": "About us - Fixture Plumbing",
"note": null
}
],
"score": 52,
"score_note": "0-100 from definite problems only (needs-review items do not lower it). Never a statement that a site is compliant.",
"counts": {
"definite": 5,
"needs_review": 0,
"fix_verified": 5,
"confirmed_by_second_check": 5
},
"owner_summary": [
"We checked 2 page(s) on desktop and phone. Accessibility score 52/100.",
"5 definite problem type(s), every one confirmed by a second, independent check; 5 serious.",
"Fix first: Button has no name. Some customers cannot use this control at all (blind or keyboard-only users), so enquiries, bookings or sign-ups can be lost here.",
"5 fix(es) were applied to a copy of the page and re-checked: the problem was gone and nothing new broke (Proven Fix)."
],
"legal_risk": {
"summary": "We found 5 definite accessibility problem type(s) (5 serious). Problems like these (missing labels, low contrast, keyboard traps, unnamed buttons) are the kind often listed in accessibility complaints and demand letters. Fixing them lowers that risk; it does not remove it.",
"laws": [
{
"region": "Australia",
"text": "The Disability Discrimination Act 1992 makes it unlawful to discriminate when providing goods, services and facilities (section 24). The Australian Human Rights Commission treats websites as part of a service; its advisory notes (2014, being updated) point to WCAG at level AA as the benchmark. People can complain to the Commission, which tries to settle it by conciliation; some complaints go on to court.",
"sources": [
{
"title": "Disability Discrimination Act 1992 (legislation.gov.au)",
"url": "https://www.legislation.gov.au/C2004A04426/latest/text"
},
{
"title": "Australian Human Rights Commission: World Wide Web Access advisory notes",
"url": "https://humanrights.gov.au/our-work/disability-rights/world-wide-web-access-disability-discrimination-act-advisory-notes-ver-41-2014"
}
]
},
{
"region": "United States",
"text": "The Americans with Disabilities Act covers businesses open to the public (Title III) and state and local governments (Title II). For businesses there is no detailed federal web standard; the Department of Justice names WCAG as helpful guidance. State and local governments have a rule that uses WCAG 2.1 AA, with dates in April 2027 or April 2028 depending on the population they serve. Private website lawsuits are common in the US.",
"sources": [
{
"title": "ADA.gov: Guidance on web accessibility and the ADA",
"url": "https://www.ada.gov/resources/web-guidance/"
},
{
"title": "ADA.gov: Title II web rule fact sheet",
"url": "https://www.ada.gov/resources/2024-03-08-web-rule/"
}
]
},
{
"region": "European Union",
"text": "The European Accessibility Act (Directive (EU) 2019/882) applies from 28 June 2025 to certain products and services sold to consumers, including e-commerce, banking and transport services. Microenterprises that provide services (fewer than 10 staff and no more than EUR 2 million turnover or balance sheet) are exempt. Each EU country applies it through its own law, so details and penalties differ. The technical benchmark is the standard EN 301 549, which is based on WCAG.",
"sources": [
{
"title": "Directive (EU) 2019/882 (EUR-Lex)",
"url": "https://eur-lex.europa.eu/eli/dir/2019/882/oj"
},
{
"title": "Irish regulator CCPC: EAA guidelines for microenterprises",
"url": "https://www.ccpc.ie/business/enforcement/accessibility/european-accessibility-act-guidelines-for-microenterprises/"
}
]
},
{
"region": "United Kingdom",
"text": "Under the Equality Act 2010 (Great Britain; Northern Ireland uses the Disability Discrimination Act 1995), businesses that provide services to the public must make reasonable adjustments for disabled people, and that includes websites. Public sector bodies also have specific website regulations that use WCAG 2.2 and need an accessibility statement.",
"sources": [
{
"title": "Equality Act 2010, section 29 (legislation.gov.uk)",
"url": "https://www.legislation.gov.uk/ukpga/2010/15/section/29"
},
{
"title": "GOV.UK: Accessibility requirements for public sector websites",
"url": "https://www.gov.uk/guidance/accessibility-requirements-for-public-sector-websites-and-apps"
}
]
}
],
"not_legal_advice": "This is general information, not legal advice. Whether a law applies to you depends on your business, your customers and where you trade. This report does not show that your site is or is not legal. If you are worried, or you got a complaint or demand letter, talk to a lawyer."
},
"issues": [
{
"id": "iss_1",
"page": "https://www.example.com/",
"level": "definite",
"kind": "button-name",
"wcag": [
"4.1.2"
],
"impact": "critical",
"title": "Button has no name",
"who_is_affected": "Screen-reader and voice users hear \"button\" and cannot say or know which one.",
"explanation": "Button has no text, aria-label or title, so it is announced only as \"button\" (WCAG 4.1.2). It shows only an SVG icon (one rectangle).",
"business_impact": "Some customers cannot use this control at all (blind or keyboard-only users), so enquiries, bookings or sign-ups can be lost here.",
"who_fixes": "Your web developer (page template change)",
"effort": "About 30-60 minutes",
"selector": ".b1",
"html_snippet": "<button class=\"b1\"><svg width=\"20\" height=\"20\"><rect width=\"20\" height=\"20\"></rect></svg></button>",
"count": 1,
"also_at": [],
"second_check": {
"confirmed": true,
"method": "accessibility tree",
"detail": "Chrome's accessibility tree gives this button no name (visible, 36 x 29 px)"
},
"fix": {
"text": "Proven Fix (verified on a copy of this page): Add aria-label=\"Describe the action\" - (set `aria-label=\"Describe the action\"`) change `<button class=\"b1\">` to `<button class=\"b1\" aria-label=\"Describe the action\">`. We applied exactly this to a copy and re-ran the axe-core rules and our own page checks: the problem is gone on this element and no new problem appeared on the changed element(s). The words are a placeholder: this button shows only an SVG icon (one rectangle) and its code has no text, title or meaningful class/id, so the tool cannot tell its action. Click it (or find its click handler in the script) to see what it does, then write that action in 1-3 words, e.g. aria-label=\"Play video\" or aria-label=\"Close menu\"; better still, show that word on screen next to the icon.",
"verified_after_fix": true,
"status": "verified",
"code_after": [
"<button class=\"b1\" aria-label=\"Describe the action\"><svg width=\"20\" height=\"20\"><rect width=\"20\" height=\"20\"></rect></svg></button>"
],
"why_not_verified": null
}
},
{
"id": "iss_2",
"page": "https://www.example.com/",
"level": "definite",
"kind": "button-name",
"wcag": [
"4.1.2"
],
"impact": "critical",
"title": "Button has no name",
"who_is_affected": "Screen-reader and voice users hear \"button\" and cannot say or know which one.",
"explanation": "Button has no text, aria-label or title, so it is announced only as \"button\" (WCAG 4.1.2). It shows only an SVG icon (one circle).",
"business_impact": "Some customers cannot use this control at all (blind or keyboard-only users), so enquiries, bookings or sign-ups can be lost here.",
"who_fixes": "Your web developer (page template change)",
"effort": "About 30-60 minutes",
"selector": ".b2",
"html_snippet": "<button class=\"b2\"><svg width=\"20\" height=\"20\"><circle cx=\"10\" cy=\"10\" r=\"9\"></circle></svg></button>",
"count": 1,
"also_at": [],
"second_check": {
"confirmed": true,
"method": "accessibility tree",
"detail": "Chrome's accessibility tree gives this button no name (visible, 36 x 29 px)"
},
"fix": {
"text": "Proven Fix (verified on a copy of this page): Add aria-label=\"Describe the action\" - (set `aria-label=\"Describe the action\"`) change `<button class=\"b2\">` to `<button class=\"b2\" aria-label=\"Describe the action\">`. We applied exactly this to a copy and re-ran the axe-core rules and our own page checks: the problem is gone on this element and no new problem appeared on the changed element(s). The words are a placeholder: this button shows only an SVG icon (one circle) and its code has no text, title or meaningful class/id, so the tool cannot tell its action. Click it (or find its click handler in the script) to see what it does, then write that action in 1-3 words, e.g. aria-label=\"Play video\" or aria-label=\"Close menu\"; better still, show that word on screen next to the icon.",
"verified_after_fix": true,
"status": "verified",
"code_after": [
"<button class=\"b2\" aria-label=\"Describe the action\"><svg width=\"20\" height=\"20\"><circle cx=\"10\" cy=\"10\" r=\"9\"></circle></svg></button>"
],
"why_not_verified": null
}
},
{
"id": "iss_3",
"page": "https://www.example.com/",
"level": "definite",
"kind": "contrast",
"wcag": [
"1.4.3"
],
"impact": "serious",
"title": "Text contrast 2.32:1 is below the required 4.5:1",
"who_is_affected": "People with low vision, older users, anyone on a phone in sunlight.",
"explanation": "Measured 2.32:1 (#aaaaaa on #ffffff, 16px normal); WCAG 1.4.3 requires 4.5:1.",
"business_impact": "Older visitors and people with low vision struggle to read this; on a phone in sunlight almost everyone does.",
"who_fixes": "Your web developer (style/CSS change)",
"effort": "About 30-60 minutes (one style rule often fixes every place)",
"selector": ".lo",
"html_snippet": "<p class=\"lo\">Open 7 days, 7am to 7pm, including public holidays.</p>",
"count": 1,
"also_at": [],
"second_check": {
"confirmed": true,
"method": "colour maths + pixels",
"detail": "own maths 2.32:1 (16px 400, needs 4.5:1) and the strongest on-screen text pixels reach only 2.32:1"
},
"fix": {
"text": "Proven Fix (verified on a copy of this page): Set the text colour to #707070 - add this CSS: `.lo { color: #707070; }`. We applied exactly this to a copy and re-ran the axe-core rules and our own page checks: the problem is gone on this element and no new problem appeared on the changed element(s). Measured on the copy: #707070 on #ffffff measures 4.95:1. Where the colour comes from: the CSS rule `.lo { color: rgb(170, 170, 170) }` in a <style> block in the page.",
"verified_after_fix": true,
"status": "verified",
"code_after": [
""
],
"why_not_verified": null
}
},
{
"id": "iss_4",
"page": "https://www.example.com/",
"level": "definite",
"kind": "img-alt",
"wcag": [
"1.1.1"
],
"impact": "serious",
"title": "Image has no text alternative",
"who_is_affected": "Blind screen-reader users hear the file name or nothing.",
"explanation": "Image has no alt attribute (WCAG 1.1.1); screen readers read the file name or skip it.",
"business_impact": "Screen-reader users hear nothing useful here, and search engines lose the same information.",
"who_fixes": "You or whoever edits the website content (no coding)",
"effort": "Quick: about 2 minutes per place",
"selector": "img",
"html_snippet": "<img src=\"/van.png\" width=\"200\" height=\"120\">",
"count": 1,
"also_at": [],
"second_check": {
"confirmed": true,
"method": "accessibility tree",
"detail": "Chrome's accessibility tree gives this image no name (visible, 200 x 120 px)"
},
"fix": {
"text": "Proven Fix (verified on a copy of this page): Add alt=\"Van\" - (set `alt=\"Van\"`) change `<img src=\"/van.png\" width=\"200\" height=\"120\">` to `<img src=\"/van.png\" width=\"200\" height=\"120\" alt=\"Van\">`. We applied exactly this to a copy and re-ran the axe-core rules and our own page checks: the problem is gone on this element and no new problem appeared on the changed element(s). The name was taken from its src \"/van.png\"; change the words if they do not say what it shows.",
"verified_after_fix": true,
"status": "verified",
"code_after": [
"<img src=\"/van.png\" width=\"200\" height=\"120\" alt=\"Van\">"
],
"why_not_verified": null
}
},
{
"id": "iss_5",
"page": "https://www.example.com/about.html",
"level": "definite",
"kind": "img-alt",
"wcag": [
"1.1.1"
],
"impact": "serious",
"title": "Image has no text alternative",
"who_is_affected": "Blind screen-reader users hear the file name or nothing.",
"explanation": "Image has no alt attribute (WCAG 1.1.1); screen readers read the file name or skip it.",
"business_impact": "Screen-reader users hear nothing useful here, and search engines lose the same information.",
"who_fixes": "You or whoever edits the website content (no coding)",
"effort": "Quick: about 2 minutes per place",
"selector": "img",
"html_snippet": "<img src=\"/team.png\" width=\"200\" height=\"120\">",
"count": 1,
"also_at": [],
"second_check": {
"confirmed": true,
"method": "accessibility tree",
"detail": "Chrome's accessibility tree gives this image no name (visible, 200 x 120 px)"
},
"fix": {
"text": "Proven Fix (verified on a copy of this page): Add alt=\"Team\" - (set `alt=\"Team\"`) change `<img src=\"/team.png\" width=\"200\" height=\"120\">` to `<img src=\"/team.png\" width=\"200\" height=\"120\" alt=\"Team\">`. We applied exactly this to a copy and re-ran the axe-core rules and our own page checks: the problem is gone on this element and no new problem appeared on the changed element(s). The name was taken from its src \"/team.png\"; change the words if they do not say what it shows.",
"verified_after_fix": true,
"status": "verified",
"code_after": [
"<img src=\"/team.png\" width=\"200\" height=\"120\" alt=\"Team\">"
],
"why_not_verified": null
}
}
],
"people_must_check": [
{
"what": "Whether alt text makes sense",
"why": "We can see an image has a description, not whether the words describe it well.",
"how": "Read each image description out loud. Would someone who cannot see the picture get the point?"
},
{
"what": "Screen reader experience",
"why": "Reading order, announcements and whether things make sense when heard.",
"how": "Turn on VoiceOver (iPhone: Settings > Accessibility) or NVDA (free on Windows) and try to use your contact form."
},
{
"what": "Keyboard use from start to finish",
"why": "We test focus and traps on each page, not a whole task like booking or paying.",
"how": "Put the mouse away. Use Tab, Shift+Tab, Enter and Space to book or send the form. Can you see where you are at every step?"
},
{
"what": "Captions and transcripts",
"why": "Whether video captions are correct and in sync.",
"how": "Play each video with sound off. Can you follow it from the captions alone?"
},
{
"what": "Plain language and error messages",
"why": "Whether instructions and form errors are clear to a real person.",
"how": "Send your form with a mistake on purpose. Does the message say what went wrong and how to fix it?"
},
{
"what": "PDFs, documents and pages we did not check",
"why": "This report covers the pages listed only.",
"how": "List your other important pages and PDFs (menus, price lists, forms) for a later check."
},
{
"what": "Zoom, motion and timing in real use",
"why": "Some pop-ups, carousels and time limits only appear after use.",
"how": "Zoom your browser to 200% and use the site. Does anything get cut off, move by itself, or time out?"
}
],
"limits": "Automated checks find many, not all, accessibility problems. 'definite' = failed our check and was confirmed by a second, independent check in a fresh page load. 'needs_review' = a person must look.",
"seconds": 26.3,
"blocked_private_requests": 0
}API keys
Send Authorization: Bearer tck_live_... on every /v1 call and on /mcp. If your tool prefers it, X-API-Key: tck_live_... works the same way.
Test and live keys. tck_test_... keys are for building your integration: every scan returns at once with a fixed example result (marked "test_mode": true), no real site is checked and no pages are counted. tck_live_... keys run real scans and count against your plan. Older tck_... keys keep working as live keys.
Create, name and revoke keys on your keys page (up to 5 at a time). We keep only a scrambled copy (HMAC-SHA256), so a lost key cannot be shown again: make a new one and revoke the old one. A revoked key stops working at once. Keep keys on your server or in CI secrets, never in a web page (the API does not allow browser calls).
Start a scan: POST /v1/wcag/scans
| Field | Type | Notes |
|---|---|---|
| url | string, required | public http(s) page on port 80 or 443 (the normal web ports), up to 2,048 characters. Redirects are followed only within the same site. |
| pages | integer, default 1 | 1 = this page only; more = this page + links found on it (same site, shortest paths first), up to your plan's pages per scan |
| device | both | desktop | phone | v1 always checks both views (1280x900 and 320x640); the field is echoed back |
| standard | string | only WCAG 2.2 AA in v1 |
| webhook_url | string, optional | public https:// address; we POST when the scan finishes |
| metadata | object, optional | up to 1,000 characters, kept with the scan for your own use |
Answer: 202 Accepted + Location header + the scan with status: "queued", queue_position and retry_after_seconds. Pages are counted when the scan starts; failed scans and pages we could not open are given back.
Get the result: GET /v1/wcag/scans/{id}
While queued or running the answer has retry_after_seconds (also a Retry-After header). Ask again after that. When done, the full result is in the same answer. failed has an error object (e.g. the site blocked our checker or took too long).
Result fields
| Field | Meaning |
|---|---|
status | queued, running, done, failed or expired (results are kept 30 days) |
queue_position, retry_after_seconds | while queued / running: when to ask again |
score | 0-100 from definite problems only; null if no page could be checked |
counts | definite, needs_review, fix_verified, confirmed_by_second_check |
pages[] | url, status (checked, partial, blocked, failed, refused), title, note |
owner_summary[] | 3-5 plain sentences for the site owner |
issues[] | one entry per problem type and element group (below) |
legal_risk | summary + laws per region with official source links + not_legal_advice |
people_must_check[] | what automated checks cannot see, and how a person can test it |
error | only when status is failed: code + message |
Each issue
| Field | Meaning |
|---|---|
level | definite = failed and confirmed by a second method; needs_review = a person must look (never fails your build by itself) |
wcag[] | WCAG 2.2 success criteria, e.g. 1.4.3 |
impact | critical, serious, moderate or minor |
kind, title, explanation | stable machine name, short title, what is wrong |
who_is_affected, business_impact | who is blocked and what it costs the site |
who_fixes, effort | content editor or developer, rough minutes |
selector, html_snippet, count, also_at[] | CSS selector of the first element, its HTML, how many places, more selectors |
second_check | confirmed true/false, method (e.g. accessibility tree, colour maths + pixels, keyboard walk), detail |
fix.text | what to change, in words and code |
fix.verified_after_fix | true = we applied this exact change to a copy of the page, re-ran the checks: problem gone, nothing new broke |
fix.code_after[] | the element after the fix (only when verified) |
fix.status, fix.why_not_verified | verified, suggested or needs_person, and why |
{
"id": "iss_1",
"page": "https://www.example.com/",
"level": "definite",
"kind": "button-name",
"wcag": [
"4.1.2"
],
"impact": "critical",
"title": "Button has no name",
"who_is_affected": "Screen-reader and voice users hear \"button\" and cannot say or know which one.",
"explanation": "Button has no text, aria-label or title, so it is announced only as \"button\" (WCAG 4.1.2). It shows only an SVG icon (one rectangle).",
"business_impact": "Some customers cannot use this control at all (blind or keyboard-only users), so enquiries, bookings or sign-ups can be lost here.",
"who_fixes": "Your web developer (page template change)",
"effort": "About 30-60 minutes",
"selector": ".b1",
"html_snippet": "<button class=\"b1\"><svg width=\"20\" height=\"20\"><rect width=\"20\" height=\"20\"></rect></svg></button>",
"count": 1,
"also_at": [],
"second_check": {
"confirmed": true,
"method": "accessibility tree",
"detail": "Chrome's accessibility tree gives this button no name (visible, 36 x 29 px)"
},
"fix": {
"text": "Proven Fix (verified on a copy of this page): Add aria-label=\"Describe the action\" - (set `aria-label=\"Describe the action\"`) change `<button class=\"b1\">` to `<button class=\"b1\" aria-label=\"Describe the action\">`. We applied exactly this to a copy and re-ran the axe-core rules and our own page checks: the problem is gone on this element and no new problem appeared on the changed element(s). The words are a placeholder: this button shows only an SVG icon (one rectangle) and its code has no text, title or meaningful class/id, so the tool cannot tell its action. Click it (or find its click handler in the script) to see what it does, then write that action in 1-3 words, e.g. aria-label=\"Play video\" or aria-label=\"Close menu\"; better still, show that word on screen next to the icon.",
"verified_after_fix": true,
"status": "verified",
"code_after": [
"<button class=\"b1\" aria-label=\"Describe the action\"><svg width=\"20\" height=\"20\"><rect width=\"20\" height=\"20\"></rect></svg></button>"
],
"why_not_verified": null
}
}needs_review with the reason.PDF report: GET /v1/wcag/scans/{id}/report.pdf
A client-ready PDF of a finished scan (409 not_ready before that).
List and usage
GET /v1/wcag/scans?limit=20 lists your latest scans (newest first, up to 100). GET /v1/usage shows your plan, pages used and left this month, and when the month resets (1st of the month, UTC).
Webhooks
Add webhook_url when you start a scan. When it finishes we POST {"type": "wcag_scan.done", "scan_id", "status", "url", "finished_at", "result_url"} (or wcag_scan.failed), up to 3 tries. The TC-Signature header is t=UNIX_TIME,v1=HMAC_SHA256(webhook_secret, t + "." + raw_body). Your webhook_secret is shown with the key when you create it.
import hmac, hashlib, time
def verify(raw_body: bytes, header: str, secret: str, max_age=300) -> bool:
"""header = request.headers["TC-Signature"] e.g. t=1762500000,v1=5f2c..."""
parts = dict(p.split("=", 1) for p in header.split(","))
if abs(time.time() - int(parts["t"])) > max_age:
return False
want = hmac.new(secret.encode(), parts["t"].encode() + b"." + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(want, parts["v1"])Idempotency
Send Idempotency-Key: any-unique-text (1-80 letters, digits, _ . : -) with a POST. The same key from the same API key within 24 hours returns the first scan (with "idempotent_replay": true) instead of starting and charging a new one. Safe for retries and CI re-runs.
Limits and quota
| Plan | Pages a month | Pages per scan | Requests a minute | Scans waiting at once |
|---|---|---|---|---|
| Free beta | 20 | 3 | 30 | 3 |
| Builder (coming) | 500 | 10 | 120 | 10 |
| Agency (coming) | 3,000 | 10 | 300 | 20 |
Limits depend on your plan; the headers always show yours. Every answer has X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset (seconds). Over the limit: 429 with Retry-After. Payments are off during the free beta.
Errors
Every error has the same shape: {"error": {"code": "...", "message": "...", "docs": "..."}}.
| HTTP | code | When |
|---|---|---|
| 400 | bad_request | a field is missing or wrong (message says which) |
| 400 | url_not_allowed | not a public http(s) page on port 80/443, or it points to a private network |
| 400 | webhook_not_allowed | webhook_url must be a public https:// address |
| 401 | unauthorized | missing, wrong or revoked key |
| 402 | quota_exceeded | not enough pages left this month (pages_left, resets_at in the error) |
| 404 | not_found | no such scan for your account, or no such endpoint |
| 405 | method_not_allowed | wrong HTTP method |
| 409 | not_ready | PDF asked for before the scan is done |
| 413 / 414 | bad_request | body over 16 KB / address too long |
| 429 | rate_limited | too many requests a minute; wait Retry-After seconds |
| 429 | too_many_open_scans | too many scans waiting at once |
| 500 | server_error | our fault; safe to retry with the same Idempotency-Key |
MCP for AI agents (Claude, ChatGPT and others)
Two tools: check_accessibility(url, pages=1, wait_seconds=150) starts a scan and waits for it, and get_scan_result(scan_id) fetches a scan that was still running. The agent gets the score, owner summary and the issues with fix code and the verified flag.
{
"mcpServers": {
"tc-wcag": {
"command": "python3",
"args": ["/full/path/to/tc_wcag_mcp.py"],
"env": { "TC_API_KEY": "tck_live_your_key_here" }
}
}
}# stdio (Python 3.9+, no extra packages): download the file
curl -O https://tradingcheckup.com/developers/tc_wcag_mcp.py
# test it by hand: one JSON-RPC message per line; the server answers each line,
# and check_accessibility waits for the scan (up to wait_seconds) before it answers
( echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}'
echo '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"check_accessibility","arguments":{"url":"https://www.example.com/"}}}'
) | TC_API_KEY=tck_live_... python3 tc_wcag_mcp.py
# optional: TC_API_BASE=https://... points it at another API address (e.g. a test server)What the agent gets back (a short form of the REST result: scan_id instead of id, up to 12 issues worst first, more_issues = how many were left out, full_result = where to read all of them). If the scan is still running after wait_seconds, the tool returns {"scan_id", "status": "running"}: call get_scan_result later.
{
"scan_id": "scan_...", "status": "done", "url": "https://www.example.com/", "score": 84,
"counts": {"definite": 1, "needs_review": 0, "fix_verified": 1, "confirmed_by_second_check": 1},
"owner_summary": ["..."], "pages": [{"url": "...", "status": "checked"}],
"issues": [{"level": "definite", "wcag": ["2.4.4", "4.1.2"], "impact": "critical", "title": "Link has no name",
"page": "...", "selector": "a", "count": 1, "confirmed_by_second_check": true,
"fix": "Proven Fix (verified on a copy of this page): ...", "verified_after_fix": true, "code_after": ["<a ... aria-label=\"Media\">"]}],
"more_issues": 0, "full_result": "GET /v1/wcag/scans/scan_...", "note": "..."
}# Claude Code, remote (HTTP) MCP server:
claude mcp add --transport http tc-wcag https://api.tradingcheckup.com/mcp --header "Authorization: Bearer $TC_API_KEY"
# any MCP client over HTTP: POST JSON-RPC 2.0 to https://api.tradingcheckup.com/mcp with the same header, e.g.
curl -s -X POST https://api.tradingcheckup.com/mcp -H "Authorization: Bearer $TC_API_KEY" -H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"check_accessibility","arguments":{"url":"https://www.example.com/"}}}'CI example (GitHub Actions)
# .github/workflows/accessibility.yml - fails the build only on CONFIRMED (definite) problems
name: accessibility
on: [pull_request]
jobs:
wcag:
runs-on: ubuntu-latest
steps:
- name: WCAG 2.2 AA check
env:
TC_API_KEY: ${{ secrets.TC_API_KEY }}
PAGE: https://staging.example.com/
run: |
ID=$(curl -s -X POST https://api.tradingcheckup.com/v1/wcag/scans -H "Authorization: Bearer $TC_API_KEY" \
-H "Content-Type: application/json" -H "Idempotency-Key: $GITHUB_RUN_ID" \
-d "{\"url\": \"$PAGE\", \"pages\": 3}" | jq -r .id)
for i in $(seq 1 60); do
R=$(curl -s https://api.tradingcheckup.com/v1/wcag/scans/$ID -H "Authorization: Bearer $TC_API_KEY")
S=$(echo "$R" | jq -r .status); [ "$S" = done ] || [ "$S" = failed ] && break; sleep 10
done
echo "$R" | jq '.owner_summary, [.issues[] | select(.level=="definite") | {wcag, title, selector, fix: .fix.text}]'
[ "$(echo "$R" | jq .counts.definite)" = 0 ]Python
# needs the requests package: pip install requests
import os, time, requests
BASE = "https://api.tradingcheckup.com"
H = {"Authorization": "Bearer " + os.environ["TC_API_KEY"]}
r = requests.post(BASE + "/v1/wcag/scans", headers=H, json={"url": "https://www.example.com/", "pages": 1}, timeout=30)
r.raise_for_status()
scan = r.json()
while scan["status"] in ("queued", "running"):
time.sleep(scan.get("retry_after_seconds", 10))
scan = requests.get(BASE + "/v1/wcag/scans/" + scan["id"], headers=H, timeout=30).json()
if scan["status"] != "done":
raise SystemExit(scan.get("error"))
print("score", scan["score"], scan["counts"])
for i in scan["issues"]:
if i["level"] == "definite":
print(i["wcag"], i["impact"], i["title"], "->", i["selector"])
print(" fix:", i["fix"]["text"][:120], "(verified)" if i["fix"]["verified_after_fix"] else "")JavaScript (Node 18+)
// Node 18+ (built-in fetch). Run: TC_API_KEY=tck_live_... node scan.mjs
const BASE = "https://api.tradingcheckup.com";
const H = { Authorization: `Bearer ${process.env.TC_API_KEY}`, "Content-Type": "application/json" };
let scan = await (await fetch(`${BASE}/v1/wcag/scans`, {
method: "POST", headers: H, body: JSON.stringify({ url: "https://www.example.com/", pages: 1 }),
})).json();
if (scan.error) throw new Error(scan.error.message);
while (scan.status === "queued" || scan.status === "running") {
await new Promise(r => setTimeout(r, (scan.retry_after_seconds || 10) * 1000));
scan = await (await fetch(`${BASE}/v1/wcag/scans/${scan.id}`, { headers: H })).json();
}
console.log(scan.score, scan.counts);
for (const i of scan.issues.filter(i => i.level === "definite"))
console.log(i.wcag.join(","), i.impact, i.title, i.selector, i.fix.verified_after_fix ? "(fix verified)" : "");Safety and privacy
- Only check sites you own or have permission to test.
- We load pages like a normal visitor (one browser, polite pace, our own user agent), never fill in or send forms, never log in.
- Private networks, loopback, cloud metadata addresses, other ports and non-web addresses are refused - also when a page redirects or loads assets from them.
- Results are deleted after 30 days. Logs keep the route, status code and time, never your key.
- Automated checks find many problems, not all. We never say a site is "compliant". The legal-risk text is general information, not legal advice.
Versions and status
The path has the major version (/v1). Every answer has a TC-API-Version header (date). New fields can be added at any time; we never remove or rename a field inside v1. Live status, queue length and average scan time: status page or GET /v1/status (no key needed).