API v1.2
Developer API
A CI-native REST API to trigger scans, gate pull requests on dataLayer and compliance regressions, and pull results straight into your pipeline.
Authentication
Every request carries a project API key, minted in Project Settings › API Keys on the Starter plan or higher. Send it as a Bearer token or an X-API-Key header. Each key has one permission (read, edit or admin); the full key is shown once on creation. If the project drops to Free, its keys stay saved but answer plan_required until the plan is upgraded again.
# Preferred
Authorization: Bearer ap_live_xxxxxxxxxxxxxxxx
# Alternative
X-API-Key: ap_live_xxxxxxxxxxxxxxxxPermissions
| Permission | Description |
|---|---|
read | List targets, journeys, schedules and scan history; read a journey definition, monitored vendors and a scan’s full evidence; poll a scan, evaluate a CI gate and explain a result. |
edit | Everything Read can do, plus trigger, re-run and cancel scans, upload APK/XAPK files, acknowledge dataLayer diffs and change monitored vendors. |
admin | Everything Edit can do, plus manage the project’s API keys. An admin key cannot mint another admin key. |
Endpoints
All endpoints are rooted at the API base URL and require the listed permission. The two public scan endpoints take no key.
| Method | Endpoint | Permission | Summary |
|---|---|---|---|
| GET | /api/v1/scans | read | List scan history |
| POST | /api/v1/scans | edit | Trigger an ad-hoc scan |
| GET | /api/v1/scans/{id} | read | Poll a scan + evaluate a CI gate |
| DELETE | /api/v1/scans/{id} | edit | Cancel a pending/running scan |
| POST | /api/v1/scans/{id}/explain | read | Explain a scan in plain language |
| GET | /api/v1/scans/{id}/evidence | read | Download all evidence of a scan |
| GET | /api/v1/targets | read | List saved tests |
| POST | /api/v1/targets/{id}/scans | edit | Re-run a saved test, "Scan again" |
| GET | /api/v1/journeys | read | List saved journeys |
| GET | /api/v1/journeys/{id} | read | Get a saved journey with its steps |
| GET | /api/v1/schedules | read | List scan schedules |
| POST | /api/v1/uploads | edit | Upload a CI APK/XAPK |
| POST | /api/v1/journeys/{id}/datalayer/approve | edit | Approve a dataLayer diff into the journey baseline |
| POST | /api/v1/journeys/{id}/datalayer/approve-all | edit | Approve all pending dataLayer diffs for a scan |
| POST | /api/v1/journeys/{id}/datalayer/ignore | edit | Ignore a dataLayer element journey-wide |
| DELETE | /api/v1/journeys/{id}/datalayer/ignores/{decisionId} | edit | Remove a standing dataLayer ignore |
| GET | /api/v1/journeys/{id}/datalayer/decisions | read | List standing dataLayer ignore decisions |
| GET | /api/v1/targets/{id}/vendors | read | List a target's vendors and their monitoring state |
| PUT | /api/v1/targets/{id}/vendors | edit | Set vendor-monitoring states across a target's journeys |
| GET | /api/v1/whoami | read | Identify the calling key |
| GET | /api/v1/keys | admin | List project API keys |
| POST | /api/v1/keys | admin | Mint a project API key |
| DELETE | /api/v1/keys/{keyId} | admin | Revoke a project API key |
| POST | /api/v1/public/scans | Request a free public Web Compliance scan (public, no API key) | |
| GET | /api/v1/public/scans/{slug} | Read a public scan by slug (public, no API key) |
CI gate options
Pass a URL-encoded JSON gate as ?gate= when polling a scan. Predicates combine with OR — the gate fails if any predicate matches. CI should exit non-zero when gate.passed is false.
| Option | Type | Description |
|---|---|---|
minComplianceScore | number (0–100) | Fail when the compliance score is below this threshold (score_below_threshold). |
failOnNewDataLayerRegressions | boolean | Fail on new, unacknowledged dataLayer regressions (datalayer_regressions) in watched vendors. |
failOnSeverity | "info" | "warning" | "critical" | Fail when any diff meets or exceeds this severity (severity_exceeded). |
failOnBrokenStep | boolean | Fail when the journey replay broke mid-flow (status_broken_step). |
failOnStatus | "failed" | "cancelled" | "broken_step" | "no_data"[] | Fail when the scan ends in any of the listed statuses (status_<status>). Note: status_no_data always fails, whether or not it is listed. |
failOnNewVendors | boolean | Fail when a vendor appears for the first time that the journey watches by default (dataLayer, adobeDataLayer, Google Analytics 4, Adobe Analytics; on mobile app targets also the app's own tag container (Google Tag Manager)) on a journey that already has a vendor list (new_vendors). |
Diff detail (include=diffs)
Add ?include=diffs to a scan poll to embed the full unresolved-diff list. The diff is value-blind: it reports each event's parameter names and JS types, never their values.
# Ask for the full unresolved-diff list on a terminal scan:
GET /api/v1/scans/{id}?include=diffs
# Response adds a diffs[] array (value-blind: name + JS-type only):
{
"status": "completed",
"gate": { "passed": false, "failures": [ { "code": "new_vendors", "detail": "1 new-vendor diff(s)" } ] },
"warnings": [],
"diffs": [
{
"stepIndex": 2,
"elementKey": "event:tiktok::purchase",
"vendor": "tiktok",
"diffType": "extra_event",
"severity": "warning",
"description": "Event present in the scan but not in the expected schema"
}
]
}
# diffType is one of: missing_event | extra_event | event_drifted | missing_param |
# extra_param | param_type_changed | consent_value_changed | consent_unreadable |
# step_not_reached. elementKey is "event:<vendor>::<name>",
# "param:<vendor>::<event>::<param>", "consent:<vendor>::<event>::<country>.<signal>",
# or "step:<index>" for step_not_reached, which belongs to no vendor.Dry-run preview gating
Point a saved test at a per-PR preview URL to gate the deploy without corrupting the production baseline. With scanType web_journey, one journey (journeyId, or the first runnable one) is replayed as a dry-run: it produces diffs for the gate but writes nothing back to the journey. Without a scanType, a website test runs its default scan, a compliance audit. With journeys: all, every runnable journey is replayed and the response carries a scans list instead of a single scanId. Preview runs need the Professional plan; on lower plans they answer 403 plan_required.
# Gate a per-PR preview deploy WITHOUT mutating the saved journey baseline.
# "scanType":"web_journey" replays one journey against $PREVIEW_URL as a
# dry-run: it produces diffs for the gate, but writes nothing back to the
# journey's expected schema, vendor watch-list, or regressions. Without a
# scanType a website test runs its default scan, a compliance audit.
# Add "journeyId" to pick the journey; omitted, the first runnable one is used.
#
# Idempotency-Key: commit + CI run ($RUN), mode, target and a HASH of the preview
# URL (plus any journeyId / maxJourneys you send). A retry of the same request
# replays its scan; a new push gets a fresh one (a finished key replays for 24 h),
# and the hash keeps the key within 200 chars. RUN below is for GitHub Actions;
# on another CI use its commit SHA and job id (GitLab: $CI_COMMIT_SHA-$CI_JOB_ID).
RUN="${GITHUB_SHA}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT:-1}"
URL_HASH=$(printf '%s' "$PREVIEW_URL" | cksum | cut -d' ' -f1)
SID=$(curl -fsS -X POST \
-H "Authorization: Bearer $AP_KEY" \
-H "Idempotency-Key: $RUN-web_journey-$TARGET_ID-$URL_HASH" \
-H 'content-type: application/json' \
-d "$(jq -cn --arg url "$PREVIEW_URL" '{url: $url, scanType: "web_journey"}')" \
"$BASE/api/v1/targets/$TARGET_ID/scans" | jq -r .scanId)
# 400 journey_required is returned when the target has no journey to replay.
# Every runnable journey at once: "journeys":"all" (optional "maxJourneys").
# The response has NO top-level scanId, only scans[] (scanId + pollUrl per
# journey) and failed[] (journeys that could not be started).
RES=$(curl -fsS -X POST \
-H "Authorization: Bearer $AP_KEY" \
-H "Idempotency-Key: $RUN-all-$TARGET_ID-$URL_HASH" \
-H 'content-type: application/json' \
-d "$(jq -cn --arg url "$PREVIEW_URL" '{url: $url, journeys: "all"}')" \
"$BASE/api/v1/targets/$TARGET_ID/scans")
[ "$(echo "$RES" | jq '.failed | length')" = "0" ] || { echo "$RES" | jq .failed; exit 1; }
# Then poll each "$BASE$POLL" in turn (not in parallel: five waits per key) with
# the single-scan loop from the CI recipes, and fail the job unless every gate passed.
for POLL in $(echo "$RES" | jq -r '.scans[].pollUrl'); do echo "$BASE$POLL"; doneCursor pagination
List endpoints use keyset pagination. Follow nextCursor until it is null; the legacy truncated flag is retained for back-compatibility.
# Keyset pagination (ordered createdAt DESC, id DESC):
curl -fsS -H "Authorization: Bearer $AP_KEY" \
"$BASE/api/v1/scans?limit=20" | jq '{next: .nextCursor, count: (.scans | length)}'
# Pass the previous response's nextCursor to fetch the next page;
# nextCursor is null on the final page (truncated is kept for back-compat):
curl -fsS -H "Authorization: Bearer $AP_KEY" \
"$BASE/api/v1/scans?limit=20&cursor=$NEXT_CURSOR"Rate limits
Limits are enforced per API key and surfaced on every response.
- 60 requests / minute per key. Every response carries
X-RateLimit-Limit,X-RateLimit-Remaining, andX-RateLimit-Reset(seconds until the window resets). - A
429response addsRetry-After(seconds). - 5 concurrent long-polls per key. Exceeding the cap returns
429 too_many_long_pollswithRetry-After: 5.
Idempotency
Trigger and re-run endpoints are safe to retry with an idempotency key.
- Send
Idempotency-Key(1–200 chars) as a header, or anidempotencyKeybody field — the header wins. - While the first request is still being processed, a duplicate gets
409 idempotent_in_progresswithRetry-After: 5— it is retryable. The in-flight hold is 5 minutes onPOST /api/v1/scansand 60 seconds onPOST /api/v1/targets/{id}/scans. - A completed key replays for 24 hours. A preview scan on a test (a
request with
url) is tied to the key and the request body: the same key with the same body returns the same scans for as long as they exist, also after 24 hours. A replay returns200withidempotentReplay: true. - A key identifies one logical operation, scoped per project across both trigger endpoints — reusing the same key for a different call replays the original scan's id rather than starting a new scan, so use a fresh key (e.g. the commit SHA) per distinct scan.
# Make retries safe: repeat the same Idempotency-Key header (or "idempotencyKey"
# body field) and a replay returns HTTP 200 with idempotentReplay=true and the
# original scanId instead of starting a second scan.
curl -fsS -X POST \
-H "Authorization: Bearer $AP_KEY" \
-H "Idempotency-Key: $GITHUB_SHA" \
-H 'content-type: application/json' -d '{}' \
"$BASE/api/v1/targets/$TARGET_ID/scans"Error codes
Every error response has a stable shape: an error object carrying code, message, and optional details, plus a top-level requestId. Gate-failure codes appear in gate.failures[].code, not in error.code.
| Code | Description |
|---|---|
unauthorized | No usable credentials were presented. |
missing_credentials | Neither an Authorization Bearer token nor an X-API-Key header was sent. |
invalid_key | The API key is not recognised. |
key_revoked | The API key has been revoked. |
key_expired | The API key is past its expiry date. |
plan_required | The project's plan does not include this operation. error.details.minTier names the lowest plan that does: starter for any keyed API call, professional for Android/TV scans, APK uploads and preview runs (dryRun, a journey replayed against another url, journeys:"all"). |
insufficient_permission | The key’s permission is below the one this operation requires (read < edit < admin). |
rate_limited | Per-key request rate limit exceeded — see the Retry-After header. |
too_many_long_polls | Too many concurrent long-poll requests for this key (max 5). |
invalid_request | The request body or query failed validation. |
not_found | The referenced scan, target, journey, or upload does not exist for this project. |
idempotent_in_progress | A prior request with the same Idempotency-Key is still in flight. Retryable: wait for Retry-After (5 s) and send the same request again. |
idempotency_conflict | The same Idempotency-Key was reused with a different payload on the same resource (e.g. single-journey vs journeys:"all"). Use a new key when the payload changes. |
idempotency_corrupt | The stored idempotency record for this key could not be read. Retry with a new Idempotency-Key. |
not_cancellable | The scan is already terminal and cannot be cancelled. |
scan_not_terminal | The scan has not reached a terminal status yet, so the requested artifact (SARIF / explain) is not available. Poll GET /scans/{id} first. |
evidence_too_large | The evidence document is larger than 25 MB. Request fewer steps with ?steps= (error.details.stepCount says how many were selected). |
journey_mismatch | The supplied journeyId does not belong to the target. |
journey_required | A preview-URL gate needs a journey to replay, but the target has none. |
journey_sleeping | The journeyId is ranked beyond the plan's per-target journey quota. Reorder the target's journeys or upgrade the plan. |
journey_over_step_cap | The journeyId has more steps than the plan's per-journey step limit. Shorten the journey (re-record it) or upgrade the plan. |
no_url | A Web or TV scan requires a url. |
scan_type_mismatch | The scanType disagrees with the target/upload type. |
scan_in_progress | The target already has an active scan (public scans: the same URL is already queued or running; poll the returned statusUrl). |
target_quota_exceeded | persist: true would save a second test where the project already has one of the same family on that target type — one saved test per family, as in the dashboard (Web Compliance and Web Journey share the one website test; likewise TV and Android). |
invalid_target | The target is not valid for this scanType. |
dispatch_failed | The scan could not be enqueued. |
public_scans_disabled | Public scans are temporarily switched off by the operator. Retry later; keyed scans are unaffected. |
target_has_no_journeys | A target-scoped vendor-monitoring write had no journeys to fan out to. Record a journey on the target first. |
open_regression | Approving this missing event or param would remove an element that is marked as an error at the same step. Resolve that error first. |
internal_error | Unexpected server-side failure — safe to retry. |
file_too_large | The uploaded artifact exceeds the size limit. |
package_parse_failed | The APK/XAPK package name could not be parsed. |
upload_failed | The artifact could not be stored. |
score_below_threshold | gate: compliance score fell below minComplianceScore. |
datalayer_regressions | gate: new, unacknowledged dataLayer regressions were found. |
severity_exceeded | gate: a diff met or exceeded failOnSeverity. |
status_broken_step | gate/warning: the journey replay broke mid-flow. It fails only when explicitly configured as a generic API gate predicate; the fixed GitHub policy reports it as a non-blocking warning. |
status_failed | gate: the scan ended in a failed status. |
status_cancelled | gate: the scan ended cancelled. |
status_no_data | gate: the scan produced no trustworthy measurement, so it has no score and cannot be gated on one. Always fails, whether or not it is listed in failOnStatus. |
new_vendors | gate: a first-seen vendor appeared in the dataLayer. |
diffs_not_persisted | gate: the scan finished but its findings could not be stored (including a diff-engine error), so the diff list is incomplete. Always fails — an empty diff list here means "not written", not "nothing found". An automatic replay within 6 hours of the scan clears it when it succeeds; otherwise re-run the scan. |
verdict_scope_partial | gate: the consent banner could not be shown or offers no accept and no reject control, so the compliance score covers only how the page behaves before consent — the accept and reject flows were not tested. Always fails, whatever the score; score_below_threshold is still reported beside it when the score is also low. |
no_tracking_observed | gate: the page loaded but no tracking tool and no consent banner were observed on it, so it has no compliance score. Always fails — a preview that lost its tracking looks exactly like this. |
infrastructure_interrupted | gate: the scan was interrupted on the AnalyticsProof side (for example a restart, a shutdown or our own database failing), so the result says nothing about the page. Reported instead of status_failed, with no score_below_threshold beside it; always fails (the API stays fail-closed) — re-run the scan. failureOrigin on such a scan is normally "infrastructure", but failureOrigin is broader: a scanner-side failure can carry it and still fail as status_failed. |
CI recipes
Trigger a scan, re-poll until it reaches a terminal status and settled is no longer false, then evaluate the gate. A terminal answer with settled: false is provisional — its findings are still being stored. On failure the recipe prints the offending diffs and exits non-zero. Gating a preview deploy, as these recipes do, needs the Professional plan; triggering and polling a saved Web Journey test works from Starter, and on-demand Web Compliance, Android and TV runs from Professional.
Full walkthrough: CI/CD with the API
GitHub Action (multi-journey)
The composite action wraps the whole trigger, poll, gate, SARIF and comment loop in one uses: block. It runs every runnable, quota-admitted journey against your preview URL (draft and broken included; paused excluded), reports only direct dataLayer changes, and blocks only critical findings. Warning, info, and an incomplete journey replay remain non-blocking feedback; dispatch and scanner failures still fail safely. It needs the repository secret ANALYTICSPROOF_API_KEY and the permissions contents: read, pull-requests: write, security-events: write.
# .github/workflows/analyticsproof.yml
name: AnalyticsProof
on: pull_request
concurrency:
group: analyticsproof-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read # checkout
pull-requests: write # PR comment
security-events: write # SARIF upload
jobs:
gate:
runs-on: ubuntu-latest
if: github.event.pull_request.head.repo.full_name == github.repository
timeout-minutes: 20 # poll deadline (15 min default) + at least 5 min (start retries, SARIF, comment)
steps:
- uses: actions/checkout@v4
- uses: your-org/your-repo/.github/actions/analyticsproof@v1
with:
api-key: ${{ secrets.ANALYTICSPROOF_API_KEY }}
target-id: <TARGET_ID> # discover via: GET /api/v1/targets
url: https://preview-${{ github.event.number }}.example.com
all-journeys: 'true' # run EVERY runnable, quota-admitted journey
- if: always() && hashFiles('analyticsproof.sarif') != ''
uses: github/codeql-action/upload-sarif@v4
with:
sarif_file: analyticsproof.sarif
category: analyticsproofDiscover the target ID
# Find the TARGET_ID of a saved test (project-scoped):
curl -fsS -H "Authorization: Bearer $AP_KEY" \
"$BASE/api/v1/targets" | jq -r '.targets[] | "\(.id)\t\(.name)"'GitHub Actions
# .github/workflows/analyticsproof.yml — gate one journey of a PR preview (dry run)
# Run it only on pull requests (on: pull_request), so the preview belongs to the PR.
- name: AnalyticsProof dataLayer gate
env:
AP_KEY: ${{ secrets.ANALYTICSPROOF_API_KEY }}
BASE: https://analyticsproof.com
TARGET_ID: <TARGET_ID> # discover via: GET $BASE/api/v1/targets
PREVIEW_URL: https://preview-${{ github.event.number }}.example.com
run: |
set -euo pipefail
# scanType web_journey replays the first runnable journey (add "journeyId"
# to pick one); without it a website test runs its compliance audit.
# Key = commit + run id + run attempt + mode + target + preview-URL hash (add any
# journeyId you send): a retry replays its scan, a new push, workflow run or
# re-run gets a fresh one, and it stays under 200 chars.
URL_HASH=$(printf '%s' "$PREVIEW_URL" | cksum | cut -d' ' -f1)
SID=$(curl -fsS -X POST \
-H "Authorization: Bearer $AP_KEY" \
-H "Idempotency-Key: $GITHUB_SHA-$GITHUB_RUN_ID-${GITHUB_RUN_ATTEMPT:-1}-web_journey-$TARGET_ID-$URL_HASH" \
-H 'content-type: application/json' \
-d "$(jq -cn --arg url "$PREVIEW_URL" '{url: $url, scanType: "web_journey"}')" \
"$BASE/api/v1/targets/$TARGET_ID/scans" | jq -r .scanId)
# A journey is judged by its diffs; minComplianceScore belongs to a compliance test.
GATE=$(jq -rn '{failOnSeverity:"critical",failOnBrokenStep:true} | @uri')
# Re-poll until the scan reaches a terminal status and its findings have
# settled (a terminal answer with settled:false is provisional).
# ?wait long-polls up to 120s per attempt (server cap 300s).
RES=''
for _ in $(seq 1 20); do
RES=$(curl -fsS -H "Authorization: Bearer $AP_KEY" \
"$BASE/api/v1/scans/$SID?wait=120&gate=$GATE&include=diffs&scope=direct-datalayer")
case "$(echo "$RES" | jq -r 'if .settled == false then "settling" else .status end')" in
completed|failed|cancelled|broken_step|no_data) break ;;
esac
done
echo "$RES" | jq '{status, settled, score, gate, warnings}'
if [ "$(echo "$RES" | jq -r '.settled != false and .gate.passed == true')" != "true" ]; then
echo "::error::AnalyticsProof gate failed"
echo "$RES" | jq -r '.diffs[]? | " [\(.severity)] step \(.stepIndex) \(.vendor // "-"): \(.description)"'
exit 1
fiGitLab CI
# .gitlab-ci.yml — gate one journey of a merge-request preview (dry run)
analyticsproof:
stage: verify
image: alpine:latest
variables:
BASE: https://analyticsproof.com
TARGET_ID: <TARGET_ID> # discover via: GET $BASE/api/v1/targets
PREVIEW_URL: https://preview-$CI_MERGE_REQUEST_IID.example.com
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
before_script:
- apk add --no-cache curl jq
script:
- |
set -euo pipefail
# scanType web_journey replays the first runnable journey (add "journeyId"
# to pick one); without it a website test runs its compliance audit.
# Key = commit + job + mode + target + preview-URL hash (add any journeyId you
# send): a retry of the same request replays its scan, a new push or a job
# retry gets a fresh one, and it stays under 200 chars.
URL_HASH=$(printf '%s' "$PREVIEW_URL" | cksum | cut -d' ' -f1)
SID=$(curl -fsS -X POST \
-H "Authorization: Bearer $ANALYTICSPROOF_API_KEY" \
-H "Idempotency-Key: $CI_COMMIT_SHA-$CI_JOB_ID-web_journey-$TARGET_ID-$URL_HASH" \
-H 'content-type: application/json' \
-d "$(jq -cn --arg url "$PREVIEW_URL" '{url: $url, scanType: "web_journey"}')" \
"$BASE/api/v1/targets/$TARGET_ID/scans" | jq -r .scanId)
# A journey is judged by its diffs; minComplianceScore belongs to a compliance test.
GATE=$(jq -rn '{failOnSeverity:"critical",failOnBrokenStep:true} | @uri')
RES=''
for _ in $(seq 1 20); do
RES=$(curl -fsS -H "Authorization: Bearer $ANALYTICSPROOF_API_KEY" \
"$BASE/api/v1/scans/$SID?wait=120&gate=$GATE&include=diffs&scope=direct-datalayer")
case "$(echo "$RES" | jq -r 'if .settled == false then "settling" else .status end')" in
completed|failed|cancelled|broken_step|no_data) break ;;
esac
done
echo "$RES" | jq '{status, settled, score, gate, warnings}'
[ "$(echo "$RES" | jq -r '.settled != false and .gate.passed == true')" = "true" ] || {
echo "AnalyticsProof gate failed"
echo "$RES" | jq -r '.diffs[]?'
exit 1
}API changes
Since 2026-10-10: every journey watches dataLayer, adobeDataLayer, Google Analytics 4, Adobe Analytics by default (in a mobile app also its own Google Tag Manager container), each only when present. Any vendor can be switched on or off through the vendors endpoint; 422 vendor_not_alert_ready is no longer returned. Approve and approve-all accept an optional mode (required by default, or optional). scope=direct-datalayer does not depend on the watch-list.
For AI agents
An agent can request the free Web Compliance scan of a public URL without a key: POST /api/v1/public/scans answers with a slug to poll, or with the cached result when the URL was scanned in the last 28 days (query string and fragment ignored). With a key of permission read, GET /api/v1/scans/<scan id>/evidence returns everything stored about one scan, with screenshot links valid for 15 minutes.
# No key: request the free Web Compliance scan of one URL.
# 202 with a slug to poll, or 200 with cached: true and the result when the
# URL (query string and fragment ignored) was scanned in the last 28 days.
curl -fsS -X POST "$BASE/api/v1/public/scans" \
-H "Content-Type: application/json" -d '{"url":"https://www.example.com"}'
curl -fsS "$BASE/api/v1/public/scans/<SLUG>"
# With a key of permission read: everything stored about one scan.
# Screenshot links in it work without a session for 15 minutes.
curl -fsS -H "Authorization: Bearer $AP_KEY" \
"$BASE/api/v1/scans/<SCAN_ID>/evidence?steps=0,3"OpenAPI spec
The full API is described by a machine-readable OpenAPI 3.0 document — no authentication required to read it.
https://analyticsproof.com/api/v1/openapi.jsonThe server URL is absolute, so you can paste this URL straight into editor.swagger.io or Redoc (redocly.com/redoc) and call the API from there.