Getting started
This guide takes you from nothing to the findings of a fresh scan: create an API key, find your
organization and site ids, start a scan, wait for it, and read what it found. Every request is
plain HTTPS with a bearer token, so curl is enough.
1. Create an API key
An organization admin creates keys on the Integrations screen of the dashboard: New credential, a name that says what will use it, the permissions (scopes) it needs, and when it expires (90 days by default; 1 to 365 days, or never).
| Scope | Lets the key |
|---|---|
scans:read | list sites, list and read scan runs and their tasks, comparisons, consent inventories (and their CSV downloads), the approved third-party list and the dashboard summaries |
scans:write | start a scan of a site |
findings:read | read findings, issues and the rules behind them |
reports:read | read report snapshots and PDF report states, and download the PDFs |
For the calls below the key needs scans:read, scans:write and findings:read.
The key is shown once, in the dialog that follows - copy it into your secret store before you
close the dialog. Page Lookout keeps only a one-way hash of it and cannot show it again. It looks
like plk_live_<key id>_<secret>; the dashboard later shows only its first part and last four
characters, so you can tell keys apart.
Send it on every request:
Authorization: Bearer plk_live_...
A key works only inside its own organization and only for the operations listed in the API index. An organization can have at most 10 active keys.
2. Find your ids
The Integrations screen shows the two values every call needs: the API base URL and the organization id (the dialog that shows a new key repeats the organization id). The examples below read them, and the key, from environment variables:
export PAGE_LOOKOUT_API_URL="https://api.example.invalid" # the API base URL
export PAGE_LOOKOUT_ORG_ID="11111111-1111-4111-8111-111111111111"
export PAGE_LOOKOUT_API_KEY="plk_live_..." # from your secret store
A site id comes from the site list. The API calls a site a domain:
curl -sS "$PAGE_LOOKOUT_API_URL/organizations/$PAGE_LOOKOUT_ORG_ID/domains" \
-H "Authorization: Bearer $PAGE_LOOKOUT_API_KEY"
{
"items": [
{
"id": "22222222-2222-4222-8222-222222222222",
"hostname": "shop.example.com",
"baseUrl": "https://shop.example.com/",
"displayName": "Shop",
"isActive": true,
"tags": [{ "id": "44444444-4444-4444-8444-444444444444", "name": "shop", "color": "blue" }]
}
]
}
(Shortened: every site carries more fields - its last scan and its next scheduled scan among
them. tags are the labels your organization put on the site, by name, [] when there are none;
a key reads them but cannot change them. The reference lists them all.) A site with
"isActive": false is paused by Page Lookout: no scan of it starts until it is resumed.
3. Start a scan
export SITE_ID="22222222-2222-4222-8222-222222222222"
curl -sS -X POST \
"$PAGE_LOOKOUT_API_URL/organizations/$PAGE_LOOKOUT_ORG_ID/domains/$SITE_ID/scans" \
-H "Authorization: Bearer $PAGE_LOOKOUT_API_KEY"
The request takes no body: the scan uses the site's stored scan settings. The answer is 201
with the new scan run, "status": "queued"; its id is the scanRunId of every call below.
A scan started with a key is subject to every limit a scan started in the dashboard is:
409- a scan of this site is already queued or running;details.activeScanRunIdnames it, so you can follow that one instead;422withdetails.reasonscanning_disabled- scanning is switched off in this site's settings;422withdetails.reasonsite_paused- the site is paused by Page Lookout (isActiveisfalse); skip it, and contact support to resume it;422withdetails.reasonorganization_limit_exceeded- the organization has reached one of its limits;details.limitsays which one anddetails.valuewhat it is;422with nodetails.reason- the site's scan policy names only modules that are not available yet; change the policy in the dashboard.
4. Wait for it to finish
Read the run until its status is one of completed, partially_completed or failed:
export SCAN_RUN_ID="33333333-3333-4333-8333-333333333333"
curl -sS "$PAGE_LOOKOUT_API_URL/organizations/$PAGE_LOOKOUT_ORG_ID/scans/$SCAN_RUN_ID" \
-H "Authorization: Bearer $PAGE_LOOKOUT_API_KEY"
A scan takes minutes, not seconds - how long depends on the size of the site. Poll every 15 to 30
seconds rather than in a tight loop: every request counts against the key's per-minute limit
(Errors and limits). Better still, let Page Lookout tell you:
a scan.completed webhook arrives when the run ends, with the same scanRunId.
partially_completed means some of the work could not be done - a page that did not answer, a
module that failed - and the run's moduleOutcomes say which. What the other modules observed is
stored and readable as usual.
A module's rawResultsMissing above zero is not a failure: those checks completed and their
findings are stored, but the raw result behind them could not be saved. The next scan saves a new
one.
5. Read the findings
curl -sS \
"$PAGE_LOOKOUT_API_URL/organizations/$PAGE_LOOKOUT_ORG_ID/scans/$SCAN_RUN_ID/findings?limit=50&offset=0" \
-H "Authorization: Bearer $PAGE_LOOKOUT_API_KEY"
The answer is one page of findings, worst first, with the total that matches your filters:
{ "items": [], "total": 0, "offset": 0 }
Filters narrow it: severity (repeatable: ?severity=critical&severity=high), status,
module, ruleId, url and more - see
listScanRunFindings. The same filters
work across a whole site (listDomainFindings) and across the organization
(listOrganizationFindings).
Accessibility findings can be filtered by the WCAG 2.2 criterion their check relates to and by its
level: ?wcag=1.4.3 (repeatable: a finding that cites any of the criteria matches) and
?wcagLevel=AA (repeatable, and the level is the one WCAG gives the criterion). With both, the
level narrows the criteria: a finding matches when it cites a criterion that is one of the numbers
and of one of the levels. The same two filters work on a site's issues
(listDomainIssues), and every finding and issue carries the criteria it relates to as
wcagCriteria. A number that is not a WCAG 2.2 criterion is a 400 that says what to enter.
Read the consent inventory, as JSON or as a spreadsheet
The consent scan records which third parties a site's pages contact, which cookies they set and
which local and session storage keys they write - before any choice, after Reject all and after
Accept all. Page Lookout merges that over the pages of a scan into an inventory, and the API
serves it with the scans:read scope. It records names only: never a cookie's or a stored value.
export TAG_ID="44444444-4444-4444-8444-444444444444" # a tag of your organization
# One site: the third parties in its newest inventory.
curl -sS \
"$PAGE_LOOKOUT_API_URL/organizations/$PAGE_LOOKOUT_ORG_ID/domains/$SITE_ID/consent-inventory/trackers?limit=50" \
-H "Authorization: Bearer $PAGE_LOOKOUT_API_KEY"
# Every site at once: the cookies seen after Reject all, on the sites carrying one tag.
curl -sS \
"$PAGE_LOOKOUT_API_URL/organizations/$PAGE_LOOKOUT_ORG_ID/consent-inventory/cookies?scenario=reject_all&tagId=$TAG_ID" \
-H "Authorization: Bearer $PAGE_LOOKOUT_API_KEY"
A site's lists answer for one scan. The answer names it (
scanRunId);?scanRunId=picks an older one that the site's summary (readDomainConsentInventory) lists. A site with no inventory yet answers an empty list withscanRunId: null, not an error.The organization lists merge the latest inventory of each site the key can read, per third party or per cookie, and say on how many sites each row was seen (
sitesCount), on how many it is new (newOnSitesCount) and on up to five of them by name (exampleSites).siteId(repeatable) andtagIdnarrow the sites before the merge; together they are an intersection.sitesCoveredandsitesWithoutInventorysay how many of those sites the lists are made of and how many have no inventory yet, so a list that merged three of ten sites says so.sitesWithoutInventoryIdsnames the first 200 of the latter, andscenarioSitesObservedsays per scenario on how many of the sites with an inventory the latest run saw a page in it (zero: the scenario was not checked on any site).Every site behind a row. An organization row names five sites at most. To list all of them, ask for the sites of the row, naming it by its own fields exactly as the list returns them (URL-encode every value; a cookie may be called
.or.., which is why the row is not in the path). For a third party that isregistrableDomain; for a cookie,name,domain,registrableDomainandfirstParty. Each item is the row as that site's latest inventory has it, in hostname order, with when that scan finished (lastSeenAt), what it looked at (scenarioChecked:falsemeans the scenario was not checked there, which is not the same as the row not showing up in it) and that site's own classification. FollownextCursorascursoruntil it isnull;totalequals the row'ssitesCountunder the samesiteIdandtagId. A row that matches nothing is an empty list. The.csvvariants save the same sites, at most 10,000 rows.# The sites a cookie was seen on: the row's own fields, URL-encoded by curl. curl -sS -G \ "$PAGE_LOOKOUT_API_URL/organizations/$PAGE_LOOKOUT_ORG_ID/consent-inventory/cookies/sites" \ --data-urlencode "name=_example_id" \ --data-urlencode "domain=.example.com" \ --data-urlencode "registrableDomain=example.com" \ --data-urlencode "firstParty=false" \ --data-urlencode "limit=100" \ -H "Authorization: Bearer $PAGE_LOOKOUT_API_KEY""New" is a comparison, and it can be unknown.
newSincePreviousisnullwhen there is no earlier inventory to compare with: a first scan has nothing that is new.?new=truekeeps the rows that are new,?new=falsethe rows that were already there, and a row that could not be compared is in neither. Across the organization the same filter readsnewOnSitesCount:truekeeps the rows that are new on at least one site,falsethe rows that were compared on at least one site and are new on none; a row seen only on sites with no earlier inventory is in neither.A third-party row says whether it is on the approved list (see below):
approvalisapprovedornot_approved, andapprovalSource(a site's rows) isorganization,siteorexcluded. An organization row isapprovedonly when every site it appears on approves it, andunapprovedSitesCountsays on how many it is not. Both arenullwhile the list is off. They are worked out when you ask, against the list as it is now - the findings are judged when a scan ends.A row nobody has classified is not an error. Its
classificationisnull;?category=takes a category orunclassified, and?classified=trueorfalseasks the same question.Where a classification came from.
classification.classifiedByisbuiltin(the table Page Lookout ships),operator:<id>(a table an operator published) oropen-cookie-database(a public community list of cookie names, used under the Apache-2.0 licence); do not assume it is one of two values.classificationVersionis the table's version - for the community list a date and a short commit id, such as2026-09-30+a1b2c3d.owneron a third party. A domain that serves several purposes (a search engine's, a cloud host's) is left unclassified, because a request to it does not say which purpose it had; itsownernames the company that owns it, such asGoogle.ownerisnullfor a classified row (itsclassification.vendoris the owner) and for a domain no table knows.Long-lived cookies are findings too.
?ruleId=consent.cookies.long_lifetime_before_choiceand?ruleId=consent.cookies.long_lifetime_after_rejecton the findings list return the cookies a scan saw set to last more than 397 days, before a choice and after Reject all (severitylow, every category). The inventory's ownlifetimebucketgte_13mstarts earlier, at 395 days, so a cookie of 395 to 397 days is in that bucket and has no finding.
As a CSV file
Every list also has a .csv form: add the suffix to the path (.../consent-inventory/trackers.csv,
cookies.csv, and under a site storage-keys.csv). It answers the same filters and the same sort
as the list, as a file:
curl -sS -OJ \
"$PAGE_LOOKOUT_API_URL/organizations/$PAGE_LOOKOUT_ORG_ID/consent-inventory/trackers.csv?category=advertising" \
-H "Authorization: Bearer $PAGE_LOOKOUT_API_KEY"
- Comma-separated, UTF-8 with a byte-order mark, lines ending in CRLF, a header row of English column names. The columns keep their order and a column added later goes at the end of its group, but match columns by their header name rather than by position.
limitandoffsetdo not apply: the file is the whole list, up to 10 000 rows. When the list had more, theX-Rows-Truncatedresponse header istrue- narrow the filters to get the rest.- A text cell that could be read as a formula is prefixed with an apostrophe, so a spreadsheet
shows it as text and never runs it: one that begins with
=,+,-or@(also full-width=,+,-,@, and after leading white space) or with a tab or a carriage return. Excel may show the apostrophe. Numbers are never prefixed. - The third-party files end with the approval columns:
Approval(approved,not approved, or empty while the list is off) andApproval sourcefor a site,Sites not on approved listfor the organization - and thenOwner, the last column of both: the classification's vendor when the row is classified, else theownerof an unclassified domain, else empty. TheClassified bycolumn showsbuiltin,operator:<id>oropen-cookie-database. - The file name is
consent-<list>-all-sites-<date>.csvfor the organization and<host>-consent-<list>-<date>.csvfor a site.
Opening the file in a spreadsheet. Excel in a language whose list separator is a semicolon (Polish, German, French and many others) does not split a comma file that is opened by double-clicking it: every row lands in one column. Open it instead through Data → From Text/CSV, choose UTF-8 as the file origin and Comma as the delimiter, then Load. LibreOffice Calc asks the same questions in its text import dialog when you open the file: choose Unicode (UTF-8) and tick Comma as the separator.
Read your organization's classifications
An organization admin can classify the organization's own cookies and storage keys in the dashboard
(for all sites or for one). Scans use the answer from each site's next scan, ahead of Page Lookout's
tables, and every answer is marked as the organization's. Three reads, with the scans:read scope:
# The entries, in pages (follow nextCursor as cursor until it is null).
curl -sS \
"$PAGE_LOOKOUT_API_URL/organizations/$PAGE_LOOKOUT_ORG_ID/consent-classifications?kind=cookie&limit=50" \
-H "Authorization: Bearer $PAGE_LOOKOUT_API_KEY"
# The set one site's next scan would use (block and version are null when no entry applies).
curl -sS \
"$PAGE_LOOKOUT_API_URL/organizations/$PAGE_LOOKOUT_ORG_ID/domains/$SITE_ID/consent-classifications/effective" \
-H "Authorization: Bearer $PAGE_LOOKOUT_API_KEY"
- Keys read, and never write. Adding, changing and removing an entry are dashboard actions; a key
that calls them is answered
403withdetails.reasonapi_credential_not_allowed.GET .../consent-classifications/{entryId}reads one entry. classifiedBycan beorganization. On an inventory row,classification.classifiedByisorganizationwhen one of the organization's entries decided the row, andclassificationVersionis the version of that set. Do not assume it is one of a fixed list of values.organizationClassificationon a cookie or storage-key row says how the organization's entry stands now:applied(the scan used it),pending(it applies from the next scan),removed(the scan used an entry that has since been removed) ornull. It is worked out when you ask, against the entries as they are now. On the organization's cookie list a row whose sites do not agree isdiffers_by_site. Findings carryorganizationClassificationtoo: for a consent finding an entry judged, the entry as it was when the scan ran, and who last changed it (an API key is told that a member did, not who).- The CSV files for a site's cookies, a site's storage keys and the organization's cookies end with
five columns:
Organization classification,Organization category,Organization essential,Organization classified byandOrganization classified on. - A change costs one comparison. The first scan after a site's set changes is compared as a
transition: consent findings that appear are
incomparable, those that disappear areunscanned, and the comparison carries a warning whosedetails.differingKeysincludesorganizationClassification.
Read the consent signals of a scan run
A consent scan can also read what each page declared about the visitor's choice: the IAB consent
string (decoded) and the consent states Google tags sent under Google Consent Mode, in the three
situations the scan checks - before a choice, after Reject all and after Accept all. Two reads, with
the scans:read scope, take the id of a scan run:
curl -sS "$PAGE_LOOKOUT_API_URL/organizations/$PAGE_LOOKOUT_ORG_ID/scans/$SCAN_RUN_ID/consent-signals?limit=25" \
-H "Authorization: Bearer $PAGE_LOOKOUT_API_KEY"
rowshas one entry per page, in the order of the page's path, each with itsnormalizedUrland, forinitial,reject_allandaccept_all, whether the scenario wasobservedornot_performedand what the page declared:tcf(whether the interface answered, whether it applies to the visitor, the purposes and special features consented to or declared as legitimate interest, vendor counts) andconsentMode(forad_storage,analytics_storage,ad_user_dataandad_personalization, thedefault,updateandsentstate).summarycounts the pages of the whole run, not only this response's rows. It isnullwhen the run has no consent signals - an old run, a run scanned without them, or a run without the consent module - and thenrowsis empty: read that as "not read in this scan".- Page with
limit(1 to 100, default 25) andcursor(the previous response'snextCursor). - The consent string itself, and the URLs and details of the Google requests, are never stored or returned.
The same rows as a file, one line per page and scenario, with the header Page path, Scenario,
TCF platform id, Applies to visitor, String, Purposes granted, Special features, Vendors with consent, Vendors with legitimate interest, the four Consent Mode states and Google requests:
curl -sS -OJ \
"$PAGE_LOOKOUT_API_URL/organizations/$PAGE_LOOKOUT_ORG_ID/scans/$SCAN_RUN_ID/consent-signals.csv" \
-H "Authorization: Bearer $PAGE_LOOKOUT_API_KEY"
The file follows the same rules as the other CSV files above (byte-order mark, CRLF, at most 10 000
rows with X-Rows-Truncated, the apostrophe guard) and is named
<host>-consent-signals-<date>.csv.
Read the approved third-party list
An organization can keep a list of the third parties it expects on its sites; while the list is on,
every consent scan reports a third party it sees that is not on the list as a finding. What the list
is, and how an administrator turns it on, is explained in the dashboard's Settings → Approved
third parties. A key reads it (the scans:read scope) and cannot change it.
# Is the list on, which version is it, how many entries.
curl -sS \
"$PAGE_LOOKOUT_API_URL/organizations/$PAGE_LOOKOUT_ORG_ID/consent-approvals" \
-H "Authorization: Bearer $PAGE_LOOKOUT_API_KEY"
# The entries, in pages.
curl -sS \
"$PAGE_LOOKOUT_API_URL/organizations/$PAGE_LOOKOUT_ORG_ID/consent-approvals/trackers?limit=50" \
-H "Authorization: Bearer $PAGE_LOOKOUT_API_KEY"
# The list one site is judged against.
curl -sS \
"$PAGE_LOOKOUT_API_URL/organizations/$PAGE_LOOKOUT_ORG_ID/domains/$SITE_ID/consent-approvals/effective" \
-H "Authorization: Bearer $PAGE_LOOKOUT_API_KEY"
# The findings for third parties that were not on the list (needs findings:read).
curl -sS \
"$PAGE_LOOKOUT_API_URL/organizations/$PAGE_LOOKOUT_ORG_ID/scans/$SCAN_RUN_ID/findings?ruleId=consent.vendors.unapproved_tracker" \
-H "Authorization: Bearer $PAGE_LOOKOUT_API_KEY"
enabledisfalseuntil an administrator turns the list on; while it isfalseno scan reports anything against it, and an inventory row'sapprovalisnull, notnot_approved. It cannot be turned off again by a customer:PUT .../consent-approvals/settingswithenabled: falseis refused with409anddetails.reasonconsent_approvals_cannot_turn_off, for every role. A key cannot change the list at all.- An entry is a registrable domain (
analytics.example, notwww.analytics.example) with aneffect:allowfor a third party expected on the whole organization (scopeorganization) or on one site (scopesite, with itsdomainId), andexcludefor one the organization approves but that site does not expect. A site's effective list is the organization'sallowentries plus the site's own, minus the site'sexcludeentries; itseffectiveHashis the same for any two scans judged against the same set, andlistVersiongoes up with every change. - The finding has
ruleIdconsent.vendors.unapproved_tracker,moduleconsentand no page. Its title names the third party's domain, and its severity says when it was contacted:highat least once after Reject all,mediumat least once before a consent choice,lowonly after Accept all. It is judged when a scan ends, against the list as it is then. Approving the third party closes the finding with the next scan of the site that sees it on the list (resolved). Because the list stays on once it is on, a scan always judges against it; approve the third party to resolve its finding. - The inventory's approval fields are not the finding.
approvalon an inventory row is worked out when you ask, against the list as it is now; the consent inventory section has the fields and the CSV columns. A row can readapprovedwhile its finding is still open: the next scan closes it.
Pagination
Lists are paged in one of four ways, and it is worth knowing which:
limitandoffset- the answer carriestotal, the number of rows that match your filters across every page. Ask for the next page withoffset+limituntiloffsetreachestotal. Anoffsetpast the end answers an emptyitemsarray with the realtotal, not an error: the list may have changed between two requests.limitandcursor- the answer carriesnextCursor. Pass it back ascursorto get the next page, and stop when it isnull. A cursor is opaque: hand back exactly what you were given. The order is fixed, so a page boundary never repeats or skips an entry.limitonly - the newest (or first) rows, up tolimit. There is nooffsetand no cursor on these lists: narrow them with their filters instead.- none - the whole list in one answer.
| Operation | Pagination | limit: range (default) |
|---|---|---|
listScanRunFindings | limit + offset | 1-200 (50) |
listDomainFindings | limit + offset | 1-200 (50) |
listOrganizationFindings | limit + offset | 1-200 (50) |
listDomainIssues | limit + offset | 1-200 (50) |
listDomainIssuePages | limit + offset | 1-200 (50) |
listDomainConsentInventoryTrackers | limit + offset | 1-200 (50) |
listDomainConsentInventoryCookies | limit + offset | 1-200 (50) |
listDomainConsentInventoryStorageKeys | limit + offset | 1-200 (50) |
listOrganizationConsentInventoryTrackers | limit + offset | 1-200 (50) |
listOrganizationConsentInventoryCookies | limit + offset | 1-200 (50) |
listOrganizationConsentInventoryTrackerSites | limit + cursor | 1-200 (50) |
listOrganizationConsentInventoryCookieSites | limit + cursor | 1-200 (50) |
listApprovedTrackers | limit + cursor | 1-200 (50) |
listOrganizationConsentClassifications | limit + cursor | 1-200 (50) |
listScanRunConsentSignals | limit + cursor | 1-100 (25) |
listReportDocuments | limit + offset | 1-100 (20) |
listScanRuns | limit only | 1-100 (20) |
listDomainScanRuns | limit only | 1-100 (20) |
listScanTasks | limit only | 1-500 (100) |
listScanRunClosedFindings | limit only | 1-200 (50) |
listDomains | none | - |
listOrganizationFindingRules | none | - |
listDomainIssuePages also answers truncated: when it is true, total is a lower bound.
Next
- Errors and limits - what a refusal looks like, and how to handle
429. - Webhooks - be told when a scan finishes instead of polling.
- API reference - every operation, parameter and field.