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. Send it as a Bearer token or an X-API-Key header. Each key is scoped; the full key is shown once on creation.
# Preferred
Authorization: Bearer ap_live_xxxxxxxxxxxxxxxx
# Alternative
X-API-Key: ap_live_xxxxxxxxxxxxxxxx Scopes
| Scope | Description |
|---|---|
scan:read | List targets, journeys, and scan history; poll a scan and evaluate a CI gate. |
scan:write | Trigger ad-hoc scans, re-run saved tests, and cancel a pending/running scan. |
upload:write | Upload a CI APK/XAPK artifact for a mobile scan (valid 14 days). |
Endpoints
All endpoints are rooted at the API base URL and require the listed scope.
| Method | Endpoint | Scope | Summary |
|---|---|---|---|
| GET | /api/v1/scans | scan:read | List scan history |
| POST | /api/v1/scans | scan:write | Trigger an ad-hoc scan |
| GET | /api/v1/scans/{id} | scan:read | Poll a scan + evaluate a CI gate |
| DELETE | /api/v1/scans/{id} | scan:write | Cancel a pending/running scan |
| GET | /api/v1/targets | scan:read | List saved tests |
| POST | /api/v1/targets/{id}/scans | scan:write | Re-run a saved test |
| GET | /api/v1/journeys | scan:read | List saved journeys |
| POST | /api/v1/uploads | upload:write | Upload a CI APK/XAPK |
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). |
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"[] | Fail when the scan ends in any of the listed statuses (status_<status>). |
failOnNewVendors | boolean | Fail when a first-seen vendor appears in the monitored dataLayer (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. elementKey is
# "event:<vendor>::<name>", "param:<vendor>::<event>::<param>", or
# "consent:<vendor>::<event>::<signal>".Dry-run preview gating
Point a saved test at a per-PR preview URL to gate the deploy without corrupting the production baseline. The linked journey is replayed as a dry-run: it produces diffs and a score for the gate but writes nothing back to the journey.
# Gate a per-PR preview deploy WITHOUT mutating the saved journey baseline.
# The target's linked journey is replayed against $PREVIEW_URL as a dry-run:
# it still produces diffs + score for the gate, but writes nothing back to the
# journey's expected schema, vendor watch-list, or regressions.
SID=$(curl -fsS -X POST \
-H "Authorization: Bearer $AP_KEY" \
-H 'content-type: application/json' \
-d "{\"url\":\"$PREVIEW_URL\"}" \
"$BASE/api/v1/targets/$TARGET_ID/scans" | jq -r .scanId)
# 400 journey_required is returned when the target has no journey to replay.Cursor 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. - An in-flight duplicate is held for 5 minutes; a completed key replays for 24 hours. A replay returns
200withidempotentReplay: 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. |
tier_forbidden | The project tier does not include programmatic API access (Enterprise only). |
insufficient_scope | The key lacks the scope this operation requires. |
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. |
not_cancellable | The scan is already terminal and cannot be cancelled. |
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. |
no_url | A web/hbbtv 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. |
invalid_target | The target is not valid for this scanType. |
dispatch_failed | The scan could not be enqueued. |
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: the journey replay broke mid-flow. |
status_failed | gate: the scan ended in a failed status. |
status_cancelled | gate: the scan ended cancelled. |
new_vendors | gate: a first-seen vendor appeared in the dataLayer. |
CI recipes
Trigger a scan, re-poll until it reaches a terminal status, then evaluate the gate. On failure the recipe prints the offending diffs and exits non-zero.
Discover 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
- name: AnalyticsProof compliance gate
env:
AP_KEY: ${{ secrets.ANALYTICSPROOF_API_KEY }}
BASE: https://app.analyticsproof.com
TARGET_ID: <TARGET_ID> # discover via: GET $BASE/api/v1/targets
run: |
set -euo pipefail
# Trigger a re-scan of the saved target (idempotent per commit SHA)
SID=$(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" | jq -r .scanId)
GATE=$(jq -rn '{minComplianceScore:90,failOnNewDataLayerRegressions:true,failOnBrokenStep:true} | @uri')
# Re-poll until the scan reaches a terminal status.
# ?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")
case "$(echo "$RES" | jq -r .status)" in
completed|failed|cancelled|broken_step) break ;;
esac
done
echo "$RES" | jq '{status, score, gate, warnings}'
if [ "$(echo "$RES" | jq -r '.gate.passed')" != "true" ]; then
echo "::error::AnalyticsProof gate failed"
echo "$RES" | jq -r '.diffs[]? | " [\(.severity)] step \(.stepIndex) \(.vendor // "-"): \(.description)"'
exit 1
fi GitLab CI
# .gitlab-ci.yml
analyticsproof:
stage: verify
image: alpine:latest
before_script:
- apk add --no-cache curl jq
script:
- |
set -euo pipefail
SID=$(curl -fsS -X POST \
-H "Authorization: Bearer $ANALYTICSPROOF_API_KEY" \
-H "Idempotency-Key: $CI_COMMIT_SHA" \
-H 'content-type: application/json' -d '{}' \
"https://app.analyticsproof.com/api/v1/targets/$TARGET_ID/scans" | jq -r .scanId)
GATE=$(jq -rn '{minComplianceScore:90,failOnNewDataLayerRegressions:true,failOnBrokenStep:true} | @uri')
RES=''
for _ in $(seq 1 20); do
RES=$(curl -fsS -H "Authorization: Bearer $ANALYTICSPROOF_API_KEY" \
"https://app.analyticsproof.com/api/v1/scans/$SID?wait=120&gate=$GATE&include=diffs")
case "$(echo "$RES" | jq -r .status)" in
completed|failed|cancelled|broken_step) break ;;
esac
done
echo "$RES" | jq '{status, score, gate, warnings}'
[ "$(echo "$RES" | jq -r '.gate.passed')" = "true" ] || {
echo "Compliance gate failed"
echo "$RES" | jq -r '.diffs[]?'
exit 1
}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.json The 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.