Integrations
CI/CD with the API
A deploy that breaks measurement is best caught before it reaches production. The API can replay your saved journeys against the preview of a specific change, compare the result with the expected state and return a clear pass or fail for the CI job to exit on. This guide shows how to build that flow and where it usually gets stuck.
The flow has four steps: trigger, wait, evaluate the gate, and optionally emit SARIF or a comment.
API keys, polling and the evidence export are available from Starter; replaying journeys against preview URLs needs Professional.
GitHub App or your own calls
You have two options, both built on the same API:
- The GitHub App (pull request check) is set up in the dashboard; nothing goes into the repository. Its rule is fixed: it reports only direct changes in the site's data layer (
window.dataLayer,window.adobeDataLayer) and blocks only critical findings. - Your own API calls work in any CI, and you choose the gate rule. The CI recipes for GitHub, GitLab and Bitbucket already wait and evaluate.
Happy with the default rule on GitHub? Use the App. A different CI or a different rule means the API.
The key
An editor or higher role creates the key under Project Settings › API Keys. A key has one permission: Read, Edit or Admin. CI triggers scans and reads them, so it needs Edit. Nothing more. Expiry is optional, and the full key is shown only once, right after creation.
The key starts with ap_live_ and goes in an Authorization: Bearer … or X-API-Key: … header. Store it as a masked variable or secret, never in the repository. A key works from the Starter plan. A call the plan does not include gets 403 plan_required, and error.meta.minTier names the plan it needs: a preview run, the subject of this guide, needs Professional. When a project drops to Free, its keys stay saved and work again after an upgrade. Details are in the API reference.
Triggering a run against a preview
Trigger a scan with POST /api/v1/targets/{id}/scans; find the test's id via GET /api/v1/targets. When the body carries a url with the preview address, the scan runs as a dry run: it produces diffs and a result for the gate, but nothing is written back to the journey. The expected state, the watched tools and the regression history stay as they were. Without url, the same call starts an ordinary scan of the saved test.
On a website test, a url alone is not enough for a journey. Without a scan type the request runs the test's default scan, a compliance audit of the preview. To replay one journey, add "scanType": "web_journey" and optionally a journeyId; without it the test's first runnable journey is used. The response then carries one scan: its id and pollUrl.
With "journeys": "all", every journey of the test that your plan allows to run is replayed, and maxJourneys caps how many. The response has a different shape: there is no top-level id, only a scans array with an id and pollUrl per journey, and a failed array with journeys that could not be started, each with error.code. Your script has to wait for each scans[].pollUrl. A non-empty failed should fail the job.
Two things worth setting from the start:
- An
Idempotency-Keyheader. A retry with the same key does not start a second scan; it returns the first one. The key identifies one specific request: the commit, the CI run (on GitHub the run and its attempt, on GitLab the job), the test, the journey selection and the preview URL. Keep it unchanged across retries of that request. While the first request is still being processed, a retry gets a409 idempotent_in_progresswithRetry-After; wait and send it again. A finished key keeps returning the same scans for as long as they exist, even after 24 hours, and the preview URL usually stays the same for the whole pull request, so the key has to change with every new push; that is why the commit belongs in it. The CI run gives a re-run or retried job a fresh scan too. Without the URL, the same commit deployed to a second preview would get422 idempotency_conflict. Put in a hash of the URL rather than the URL itself: a key longer than 200 characters is refused with a 400. - The scan country. The preview is scanned from the country chosen when the test was created. If the preview sits behind an IP allowlist, allow that country's address.
Every request body field is in the OpenAPI specification.
Waiting for the result
Read the scan's state with GET /api/v1/scans/{id}?wait=N. The server holds the request open until the scan has finished and its findings are stored, for at most N seconds. It never waits more than 90 seconds; repeat the request until the scan has finished.
The scan has finished when its status is one of these:
| API value | In the dashboard |
|---|---|
completed |
Completed |
failed |
Failed |
cancelled |
Cancelled |
broken_step |
Broken step |
no_data |
No data |
Repeat until status is final and settled is true. settled: false means your wait ran out while the findings were still being stored. Just ask again.
One key may hold at most five waiting requests at a time; the sixth gets 429 too_many_long_polls. If you run several journeys at once, read them one after another, not in parallel.
Designing the gate
Pass the gate as ?gate= with URL-encoded JSON when reading the scan. Predicates combine with OR: the gate fails if any of them matches. All predicates are in the reference. For a preview and the dataLayer, this is a sensible start:
{ "failOnSeverity": "critical", "failOnBrokenStep": true }
Rules you will not read off the predicate table:
- A scan that ends
failed,cancelledorno_datanever passes a gate, whatever you set. A scan without data does not get a false “score below threshold” either; it has its own code. - By default a broken step does not fail the gate. It ends as a warning, and that scan's diffs are not evaluated. If a change breaks the basket button, a gate without
failOnBrokenSteppasses. That is why we recommend turning it on. - An event shifted by one step is within tolerance: it is not among the diffs and never fails the gate. An event shifted by more than one step is an
event_drifteddiff withwarningseverity, so it fails a gate withfailOnSeverity: "warning"orfailOnNewDataLayerRegressions. - While a journey has no expected state yet, the dataLayer predicates have nothing to compare against. They cannot fail, and
warningscarriesdatalayer_gate_no_baseline. It pays to act on this warning in CI. - Without
?gate=, the response contains no gate at all. Your script must therefore treat a missinggate.passedas a failure, not a pass. minComplianceScorejudges the compliance score, so it belongs with a compliance test. A journey is judged by its diffs.- A page with no tracking tool and no consent banner fails the gate with
no_tracking_observed. - A mobile app scan whose GTM container did not load fails a diff gate with
vendors_not_evaluated. - A failed scan that was interrupted on the AnalyticsProof side (for example a restart, a shutdown or our own database failing) fails the gate with
infrastructure_interruptedinstead ofstatus_failed, without a score code beside it. The result says nothing about the page, so run the scan again.
The scope=direct_datalayer parameter narrows the diffs to what the site pushes directly into window.dataLayer or window.adobeDataLayer. Requests that GA4, Adobe Analytics and other tools build from it stay in the dashboard but do not affect the gate. The App uses the same narrowing. The comparison covers the presence of events and parameters and their JavaScript type, not their values. The exception is consent signals, where a changed value is a finding of its own.
SARIF and the comment
With ?format=sarif, a finished scan whose findings are stored returns a SARIF 2.1.0 document. Until then the answer is the ordinary JSON: for a scan that has not finished, and for a finished one whose findings are still being stored, with settled: false. So you never get an incomplete SARIF, but you may get JSON instead. The simplest order is to fetch SARIF after a JSON answer whose status is final and whose settled is true. SARIF contains the same findings the gate judges (critical as error, warning as warning, info as note); events shifted by one step are not in it. It does not carry the verdict; gate.passed still decides the job. Add the same scope so the file matches the gate.
For a pull request or merge request comment, call POST /api/v1/scans/{id}/explain. It returns a plain-language explanation of the diffs without parameter values. Before the scan finishes it answers 409 scan_not_terminal.
Example: GitLab CI and other CIs
The steps are the same in any CI that has curl and jq:
- Store the key and the test id as masked CI variables.
- Place the job after the preview deploy, so it knows the preview address.
- Trigger a dry run with
url,"journeys": "all"and anIdempotency-Keybuilt from the commit, the CI run, the test ID, the journey selection and a hash of the preview URL. Keep everypollUrlfromscans, and fail on a non-emptyfailed. - Read each scan with
wait=90, the gate and thescopeuntil it is final and settled. Bound the whole wait with the job timeout. - Fail the job when
gate.passedis nottruefor any journey. Printgate.failuresandwarningsto the log. - If your CI does not display SARIF, keep it as a job artifact.
A complete GitLab script is in the CI recipes. It replays one journey against the merge request's preview with the body {"url": "…", "scanType": "web_journey"}, an Idempotency-Key from the commit, the job and a hash of the preview URL, and the journey gate above, and reads a single id. For "journeys": "all" it needs a loop over scans[], because the response has no top-level id.
Errors and what to do
| Code | Usual cause | What to do |
|---|---|---|
missing_credentials, invalid_key |
The secret did not reach the job, or is truncated | Check the variable and the header |
key_revoked, key_expired |
The key is revoked or past expiry | Create a new one and swap the secret |
plan_required |
The plan does not include the call (error.meta.minTier) |
Any key needs Starter, a preview run needs Professional |
insufficient_permission |
The key's permission is too low | Triggering needs Edit; Read is enough for reading |
invalid_request with details[].code journey_required |
The test has no journey to replay | Record a journey |
journey_sleeping |
The journey is over the plan's limit | Reorder the journeys or change the plan |
step_cap_exceeded |
The journey has more steps than the plan allows | Shorten the journey or change the plan |
scan_in_progress |
An ordinary scan of the test is already running | Wait, or send a url for a dry run |
idempotency_conflict |
The same key reused for a different request | Use a new key for a different body |
rate_limited, too_many_long_polls |
More than 60 requests a minute, or five waits at once | Wait as Retry-After says |
Codes in gate.failures are not API errors; they are the reasons the gate did not pass. Every code is described in the error overview.