Guide
Sending audits from CI
The extension is one way to get results onto your dashboard. The other is to post them yourself — from a build, a cron job, or a script — so a page's history keeps growing without anyone opening a browser.
Last reviewed: 8 August 2026
What this is for
Mend stores audits; it does not insist on having produced them. If you already run axe-core in your test suite, you have the results — they are just being thrown away when the job ends. Posting them to Mend turns each build into a data point on the same dashboard your extension scans land on, with the same trend lines and the same per-rule pages.
This is the whole API surface. Everything else on your account — exports, VPAT generation, monitors — is available while you are signed in to the site, and is not reachable with an API key.
Getting a key
Create one on your Account page. A key looks like mend_ followed by 43 random characters, and it is shown to you exactly once. Mend stores only a SHA-256 hash of it, so there is no “show key” button and nobody — including us — can recover it later. If you lose one, revoke it and make another.
Treat it like a password: it can write audits to your account, so put it in your CI provider's secret store rather than in a committed file. Revoking a key on the Account page stops it working immediately.
Free accounts can hold 3 keys and Pro accounts 20. More than one is genuinely useful — a key per repository or per pipeline means you can revoke the one that leaked without breaking the others.
The request
One endpoint, one method: POST https://mend-a11y.com/api/ingest, with Authorization: Bearer <your key> and Content-Type: application/json. One request stores one audit of one page.
Three fields are required: url (an http or https address, up to 2,000 characters), startedAt (when the scan ran, as epoch milliseconds), and issues (an array, which may be empty — a clean page is a result worth recording).
The optional ones are pageTitle (defaults to the URL), durationMs, totalChecks, and partial — set partial to true when the scan did not cover the whole page, so the dashboard can say so rather than reporting a suspiciously clean result.
Each entry in issues is one failing element, not one rule. Post an element per violation node and Mend groups them by ruleId on the way in, which is what makes the dashboard able to say “this rule, these fourteen places”. An issue needs ruleId, impact (one of critical, serious, moderate, minor), title, and selector. It may also carry description, helpUrl, html, failureSummary, category, wcag (an array of tag strings), and domOrder, which controls the order elements are listed in and defaults to the order you sent them.
If you are posting axe-core output, the mapping is direct: a violation's id is the ruleId, its help is the title, and each of its nodes becomes one issue with node.target.join(" ") as the selector.
Send rule text once, not per element
Six of those fields — impact, category, wcag, title, description and helpUrl — describe the rule rather than the element that broke it. Repeating them on every issue means sending the same paragraph hundreds of times, and on a badly broken page that is most of your request body.
So you can send them once instead, in a top-level rules object keyed by rule id, and leave them off the issues:
{
"url": "https://example.com/pricing",
"startedAt": 1786179600000,
"rules": {
"color-contrast": {
"impact": "serious",
"title": "Elements must meet minimum contrast ratio thresholds",
"wcag": ["wcag2aa", "wcag143"]
}
},
"issues": [
{ "ruleId": "color-contrast", "selector": "#nav a" },
{ "ruleId": "color-contrast", "selector": ".footer small" }
]
}Both shapes work and always will — repeating the fields per issue is still valid, because installed extensions update on their own schedule. If a field appears in both places, the issue's own value wins. A field in neither is simply absent, so a rule with no title anywhere is still rejected, and the error still names the issue that needed it.
The five per-element fields — ruleId, selector, html, failureSummary and domOrder — are never read from rules.
Limits, and what happens at them
The rule throughout is that identifiers reject and volume degrades. A rule id that is too long is refused, because a truncated one silently points at the wrong rule; an html snippet over 5,000 characters is simply cut, because a real page can hold a legitimately enormous element and losing the tail of a snippet beats losing the audit.
A body may be up to 2 MB, and anything larger is refused with 413. An issues array over 1,000 entries is not refused: it is truncated to the 1,000 most severe — critical first, then by domOrder — and the stored run is flagged partial so the dashboard says the coverage was incomplete rather than implying the rest of the page was clean.
That ordering is applied by the server, not taken from your payload, so every client loses the same issues no matter what order it sends them in. Trim to your own budget first if you have a preference about what survives; the cap is a backstop that degrades an untrimmed run instead of failing it.
Requests are rate limited per account, per minute: 60 on Free and 300 on Pro. Going over returns 429 with a Retry-After header saying how long to wait.
Posting the same audit twice is safe. A url and startedAt that you have already stored returns 200 with {"duplicate": true} and changes nothing, so a retrying job cannot litter your history with copies. This is checked before your storage cap, deliberately: a retry of a run that is already safely stored should never be the request that gets refused.
Storage caps are per account — 200 saved audits on Free, 50,000 on Pro — and a genuinely new audit that would exceed yours is refused with 403 and the code AUDIT_CAP. Audits are also kept for a limited window (30 days on Free, two years on Pro) and older ones are removed automatically, so a nightly job settles at a steady state rather than filling up.
Responses
A stored audit returns 201 with {"auditId": "…", "violations": 12, "issues": 340, "partial": false}. violations is the number of distinct rules that failed, issues is how many elements were actually stored — compare it against what you sent to see whether you were truncated — and partial is true when the run has gaps, whether because you said so or because the server trimmed it. A duplicate returns 200. Everything else is an error with a human-readable error string you can print in your build log: 400 for a body Mend could not parse or a field that failed validation (the message names the field), 401 for a missing, unknown, or revoked key, 403 at your audit cap, 413 for an oversized body, 429 for rate limiting, and 500 if something broke on our side.
One decision worth knowing when you wire this into a pipeline: a failed post is a failure to record an audit, not a failure of your build. Unless you are deliberately gating merges on accessibility, let a non-2xx response log a warning and carry on. The alternative is a red build caused by our rate limiter.