Developers

Accessibility scans, from your own tools.

The Pallyfi Developer API starts scans with the same engine as Quick Scan and returns every failed rule as JSON, so you can check pages from scripts, release pipelines and internal tools.

Base URL
https://app.pallyfi.com/api/v1
Version
2026-10-19, returned in Pallyfi-Api-Version
Format
JSON over HTTPS

Quick start

The whole workflow in a terminal on macOS or Linux: set your key, start a scan, keep its ID, then read the status and results. It uses jq to format JSON; there’s a Python alternative below.

  1. Create a key. In the Pallyfi app, open Account, Connections and create a key under Developer API. Copy it straight away: it’s shown once.
  2. Load the key without showing it. read -rs takes the key without echoing it or saving it in your shell history. Paste the key, then press Enter: nothing appears, which is expected.
  3. Start a scan and keep its ID. The request waits for the scan, usually a few seconds, and returns it. jq -r .id takes the id field from the response and stores it in SCAN_ID.
  4. Check the status and read the results using $SCAN_ID.

Run each block on its own. They work in zsh, the macOS default, and bash.

Step 1: load your key. Run this alone, paste the key, press Enter (nothing is shown)
read -rs PALLYFI_API_KEY && export PALLYFI_API_KEY
Step 2: start a scan and keep its ID in SCAN_ID
SCAN_ID=$(curl -s -X POST https://app.pallyfi.com/api/v1/scans \
  -H "Authorization: Bearer $PALLYFI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url":"https://example.com"}' | jq -r .id)
echo "Scan ID: $SCAN_ID"
Step 3: status, score and counts
curl -s https://app.pallyfi.com/api/v1/scans/$SCAN_ID \
  -H "Authorization: Bearer $PALLYFI_API_KEY" \
  | jq '{status, score, failure_reason, summary}'
Step 4: each failed rule, with severity, level, WCAG tags and affected elements
curl -s https://app.pallyfi.com/api/v1/scans/$SCAN_ID/results \
  -H "Authorization: Bearer $PALLYFI_API_KEY" \
  | jq '.data[] | {rule_id, impact, level, wcag, element_count, help, help_url}'

About SCAN_ID: it’s the id of the scan, a value like bad8f181-6e9c-42e1-9feb-90356b555d96 returned by POST /scans. Every example on this page uses $SCAN_ID, so after step 2 they work as written. In a new terminal, set it again with SCAN_ID="the-id-from-the-response".

Keep keys out of commands and screenshots. Don’t type a key into a command (it’s saved in your shell history), commit it, or share a screenshot or recording that shows it. The examples use $PALLYFI_API_KEY, so the key itself never appears. If a key was exposed, revoke it in Account, Connections and create a new one.

Readable output

The API returns compact JSON, which is the standard for APIs and what your code should read. To read it in a terminal, pipe it through a formatter.

SystemInstall jq
macOS (Homebrew)brew install jq
Ubuntu or Debiansudo apt-get install jq
Windowswinget install jqlang.jq
GitHub Actions runnersAlready installed
Shell: format any response with jq
curl -s https://app.pallyfi.com/api/v1/scans/$SCAN_ID \
  -H "Authorization: Bearer $PALLYFI_API_KEY" | jq .

No jq? Python 3, which macOS and most Linux systems include, can format and extract too.

Python: format a response
curl -s https://app.pallyfi.com/api/v1/scans/$SCAN_ID \
  -H "Authorization: Bearer $PALLYFI_API_KEY" | python3 -m json.tool
Python: keep the scan ID without jq
SCAN_ID=$(curl -s -X POST https://app.pallyfi.com/api/v1/scans \
  -H "Authorization: Bearer $PALLYFI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url":"https://example.com"}' \
  | python3 -c 'import json, sys; print(json.load(sys.stdin)["id"])')

The -s flag hides curl’s progress meter so only the JSON reaches the formatter. The Pallyfi CLI is in preview and not yet published.

Three ways to use results

Every scan has one set of results. Read them the way that suits the job.

WayBest forWhere
REST APIIntegrations and automation: machine-readable JSON.The endpoints on this page.
Pallyfi CLITerminals and CI logs: a readable summary, or JSON with --json.In preview, not yet published.
Scan ExplorerInvestigating visually: score, rule and element counts, each failed rule with its elements, and the raw JSON.API scans in the Pallyfi app, also linked from Account, Connections.

The Scan Explorer signs you in with your Pallyfi account, never an API key, and shows only your own account’s scans. It needs the same plans as the scan endpoints.

Authentication

Send your key as a bearer token on every request. Keys start with pf_live_ and act as the account that created them.

HTTP header
Authorization: Bearer pf_live_...
  • Create, name and revoke keys in Account, Connections. Up to 10 active keys per account.
  • Pallyfi stores only a hash of each key. If you lose one, revoke it and create another.
  • Revoking a key takes effect on the next request.

Scopes

Every key created today carries both scopes. A request without the scope it needs gets 403 insufficient_scope.

ScopeAllows
scans:writeStarting scans (POST /scans)
scans:readReading scans and results (GET /scans/:id, GET /scans/:id/results)

Start a scan

POST/api/v1/scans

Body: { "url": "https://..." }. Public http(s) pages on standard ports only. The request returns when the scan finishes, with 201 for a completed scan. If the page can’t be scanned the scan is returned with status: "failed" and a failure_reason.

Shell: start a scan with an idempotency key
curl -s -X POST https://app.pallyfi.com/api/v1/scans \
  -H "Authorization: Bearer $PALLYFI_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: release-2026-10-19-home" \
  -d '{"url":"https://example.com"}' | jq .
JSON: scan response (illustrative)
{
  "id": "bad8f181-6e9c-42e1-9feb-90356b555d96",
  "object": "scan",
  "url": "https://example.com/",
  "status": "completed",
  "score": 88,
  "summary": {
    "passed": 13,
    "failed": 4,
    "needs_review": 1,
    "by_level": [
      { "level": "A", "passed": 6, "failed": 0 },
      { "level": "AA", "passed": 4, "failed": 0 },
      { "level": "AAA", "passed": 2, "failed": 1 },
      { "level": "Best Practice", "passed": 1, "failed": 3 }
    ],
    "note": "Automated testing checks only part of WCAG. A clean scan is not a conformance claim."
  },
  "failure_reason": null,
  "created_at": "2026-10-19T05:24:09.842Z",
  "completed_at": "2026-10-19T05:24:16.737Z",
  "results_url": "/api/v1/scans/bad8f181-6e9c-42e1-9feb-90356b555d96/results"
}
FieldMeaning
statusrunning, completed or failed.
score0 to 100, the same score as Quick Scan. null unless completed.
summary.passed, summary.failedCounts of automated rules that passed and failed on the page (rules, not elements).
summary.needs_reviewRules the engine couldn’t decide; a person needs to check them.
summary.by_levelThe same rule counts per WCAG level (A, AA, AAA) and Best Practice.
failure_reasonWhen failed: timeout, access_blocked, not_found, unavailable, network, invalid or scanner_error.
results_urlPath to this scan’s results, relative to https://app.pallyfi.com.

How scans run

  • The request waits. POST /scans responds when the scan has finished, usually within seconds and at most about 90 seconds. Set your client timeout to at least 120 seconds.
  • If your connection drops, the scan still finishes and is saved. Send the same request with the same Idempotency-Key to get it back; if it’s still running, poll GET /scans/:id every few seconds.
  • A scan never stays running. One that hasn’t finished after 5 minutes is reported as failed with failure_reason: "timeout".
Shell: poll until a scan finishes
while true; do
  STATUS=$(curl -s https://app.pallyfi.com/api/v1/scans/$SCAN_ID \
    -H "Authorization: Bearer $PALLYFI_API_KEY" | jq -r .status)
  [ "$STATUS" != "running" ] && break
  sleep 5
done
echo "$STATUS"

Scan status

GET/api/v1/scans/:id

Returns the same scan object. status is running, completed or failed. Only scans started by keys on your own account are visible.

Shell: get a scan ($SCAN_ID from POST /scans)
curl -s https://app.pallyfi.com/api/v1/scans/$SCAN_ID \
  -H "Authorization: Bearer $PALLYFI_API_KEY" | jq .

Results

GET/api/v1/scans/:id/results

Every failed rule, one entry each: its WCAG level and tags, impact and help link. element_count is the full number of failing elements; elements holds up to 25 of them, returned_element_count says how many, and truncated is true when there were more. Rules aren’t paginated or cut off. Available once the scan has completed.

Shell: get results ($SCAN_ID from POST /scans)
curl -s https://app.pallyfi.com/api/v1/scans/$SCAN_ID/results \
  -H "Authorization: Bearer $PALLYFI_API_KEY" | jq .
Shell: only critical and serious rules
curl -s https://app.pallyfi.com/api/v1/scans/$SCAN_ID/results \
  -H "Authorization: Bearer $PALLYFI_API_KEY" \
  | jq '[.data[] | select(.impact == "critical" or .impact == "serious") | {rule_id, impact, element_count, help}]'
JSON: results response (illustrative)
{
  "object": "list",
  "scan_id": "bad8f181-6e9c-42e1-9feb-90356b555d96",
  "data": [
    {
      "rule_id": "color-contrast-enhanced",
      "level": "AAA",
      "impact": "serious",
      "help": "Elements must meet enhanced color contrast ratio thresholds",
      "help_url": "https://dequeuniversity.com/rules/axe/4.13/color-contrast-enhanced",
      "wcag": ["wcag2aaa", "wcag146"],
      "element_count": 3,
      "returned_element_count": 1,
      "truncated": false,
      "elements": [
        { "html": "<a href=\"https://iana.org/domains/example\">", "target": ["a"], "failure_summary": "Fix any of the following: ..." }
      ]
    }
  ],
  "has_truncated_elements": false,
  "max_elements_per_rule": 25,
  "note": "Automated testing checks only part of WCAG. A clean scan is not a conformance claim."
}

Element html is the page’s own markup, returned as text. Escape it before showing it in a browser.

Automated testing checks only part of WCAG. A scan with no failures is not a conformance claim; combine it with manual testing.

Errors

Every error has the same shape, with a stable code to branch on and a message to show people. Codes don’t change within an API version; messages may be reworded.

JSON: error response
{ "error": { "code": "rate_limited", "message": "This key can start 30 scans an hour. Try again in 600 seconds." } }
StatusCodeWhenRetry?
400invalid_requestThe body isn't JSON, or the url field is missing.No: fix the request.
400invalid_urlThe url isn't a public http(s) address on port 80 or 443, or it contains a username or password.No: use a public address.
401unauthorizedNo key, a malformed key, or a key that was revoked.No: check the key.
403insufficient_scopeThe key doesn't have the scope this endpoint needs.No: use a key with the scope.
403plan_requiredThe account's plan doesn't include scanning (Business Pro and Enterprise Pro do). Any plan can create keys.No: change plan.
404not_foundNo scan with that id for this account.No.
409idempotency_conflictThis Idempotency-Key was already used for a different url.No: use a new key.
409scan_runningResults asked for before the scan finished.Yes: poll the scan, then retry.
409scan_failedResults asked for on a scan that failed. See failure_reason on the scan.No: start a new scan.
429rate_limitedThe key started 30 scans in the last hour.Yes: after Retry-After seconds.
429account_rate_limitedThe account started 100 scans in the last hour, across all its keys.Yes: after Retry-After seconds.
429concurrency_limitedThe account already has 5 scans running.Yes: after Retry-After seconds.
500internal_errorSomething failed on Pallyfi's side.Yes: with the same Idempotency-Key.
503not_enabledThe Developer API isn't available for this workspace yet.Later.

Rate limits

  • Each key can start 30 scans per rolling hour.
  • Each account can start 100 scans per rolling hour, shared by all its keys. More keys don’t add allowance: four keys starting 30, 30, 30 and 10 scans use the account’s whole hour. Creating, revoking or replacing keys doesn’t reset it.
  • An account can have 5 scans running at once, across all its keys.
  • Every scan started counts, including ones that fail. Replays of an Idempotency-Key, rejected requests and reading scans or results don’t.
  • “Rolling” means each start stops counting exactly an hour after it began, so allowance frees up gradually rather than all at once on the hour.
  • Over any limit you get 429 with a Retry-After header: the seconds until a scan can start again.
  • Limits hold for simultaneous requests too: each start is checked and recorded together, so requests arriving at the same moment can’t go past them.

Scan-start responses, including 429s, tell you what’s left:

HTTP: allowance headers
HTTP/1.1 429 Too Many Requests
Retry-After: 751
X-RateLimit-Limit-Key: 30
X-RateLimit-Remaining-Key: 12
X-RateLimit-Limit-Account: 100
X-RateLimit-Remaining-Account: 0

{ "error": { "code": "account_rate_limited", "message": "Your account can start 100 scans an hour across all its API keys. Try again in 751 seconds." } }

Branch on the code: rate_limited (this key), account_rate_limited (the whole account) or concurrency_limited (too many running). Wait for Retry-After before trying again; sending more requests sooner doesn’t start scans sooner. You can see the account’s current usage in Pallyfi under Account, Connections, Developer API.

Idempotency

Send an Idempotency-Key header (up to 200 characters) when starting a scan. Use it so a retried CI step never scans twice.

  • The same key and url returns the original scan, with Idempotent-Replayed: true, whatever its outcome. A failed scan replays as failed: use a new key to try again.
  • The same key with a different url is refused with 409 idempotency_conflict; it never returns an unrelated scan.
  • Simultaneous requests with one key start one scan.
  • An Idempotency-Key belongs to the API key that sent it and is kept as long as the scan is.

Versioning

Every response has a Pallyfi-Api-Version header with the date of the API version that served it, currently 2026-10-19. There is one version today; you can’t request another.

  • Without notice: new endpoints, new optional request fields, new response fields, new error codes and reworded messages. Ignore fields you don’t know.
  • Only in a new dated version: removing or renaming a field or error code, or changing what one means. Breaking changes will be announced on this page before they ship.

Plans

Scan endpoints come with Business Pro and Enterprise Pro, the same plans that include Quick Scan. The plan is checked on every request, so a change takes effect immediately. Any account can create keys.

Security

  • Keep keys in a secret store (CI secrets, a password manager), never in source code, front-end bundles, commands, screenshots or screen recordings.
  • Load a key into your terminal with read -rs PALLYFI_API_KEY rather than typing it after export, so it isn’t saved in your shell history.
  • Use one key per integration so you can revoke one without breaking the others.
  • Pallyfi only scans public pages. Private, loopback, link-local and cloud metadata addresses, internal host names and non-standard ports are refused, and every address the page itself loads, including redirects, is checked again when the scan runs.
  • Keys are 40 random characters after pf_live_, shown once and stored only as a hash. Create keys for a purpose and revoke ones you no longer use.
  • Scans and results are visible only to the account that owns the key.

Integration ideas

  • Release checks. Scan key pages after each deploy and post the summary to your team.
  • Content workflows. Scan a page when it’s published from your CMS.
  • Dashboards. Pull scores and failure counts into your own reporting.

A Pallyfi CLI and GitHub Action built on this API are in preview and not yet published. This page will link to them when they are.

Troubleshooting

401 unauthorized
Check the header is exactly Authorization: Bearer pf_live_... and the key hasn’t been revoked.
403 plan_required
The account’s plan doesn’t include scanning. Business Pro and Enterprise Pro do. Creating a key doesn’t grant scanning by itself.
409 scan_running
Wait for the scan to finish: poll GET /scans/:id, then fetch results.
The request timed out on my side
Raise your client timeout to 120 seconds, and retry with the same Idempotency-Key to pick up the scan instead of starting another.
A scan came back failed
The page blocked automated checks, timed out or couldn’t be reached. failure_reason says which.
400 invalid_url on a valid-looking URL
Internal addresses, private networks and ports other than 80 and 443 aren’t scanned.

Still stuck? Contact us.