API Documentation

Scan domains, read findings and export evidence from your own tools.

Quickstart

Every endpoint lives under /api/v1 and speaks JSON. Create an API token under Settings → API tokens, then start a scan and read its status:

export SETENFORCE_TOKEN=pat_...

curl -s -X POST /api/v1/validation/single \
  -H "Authorization: Bearer $SETENFORCE_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{"domain": "example.com"}'
# {"validation_id": 4711, "task_id": "...", "status": "PENDING", ...}

curl -s /api/v1/validation/jobs/4711/status \
  -H "Authorization: Bearer $SETENFORCE_TOKEN"

Authentication

Send a personal API token (pat_...) in either header: Authorization: Bearer pat_... or X-API-Key: pat_.... A token acts as the user who created it, in the organization it was created in, and never with more rights than that user's role there. Tokens are created, listed and revoked from a signed-in browser session only. A token can expire, and revoking it takes effect on the next request.

Give each token only the scopes it needs. A token with no scope list (all tokens issued before scopes existed) has its owner's full non-admin access. A scoped token is refused, with 403 INSUFFICIENT_SCOPE, by every endpoint outside its scopes.

Scope Grants
scan:write Start single and batch scans, manage the watchlist and trigger its scans
scan:read Read scan jobs, domains, results, assets, portfolio and monitoring
findings:read Read findings, framework coverage, score explanation and deviations
findings:write Triage findings and request exceptions (never approve them)
assets:write Create, update and archive assets, import CSV
reports:read List, view, download and export reports
webhooks:write Manage notification channels
audit:read Export the organization's audit trail (needs the owner, admin or auditor role)

Token management and approving exceptions or suppression rules never accept an API token, whatever its scopes (PAT_NOT_ALLOWED, PAT_CANNOT_APPROVE). Administration is not reachable with a token at all: those routes answer 404, as they do for any non-admin.

Scan, Poll, Export

  1. Start a scan (scan:write). POST /validation/single with {"domain": "example.com"}, or POST /validation/batch with {"domains": ["a.example", "b.example"]}. Both answer with a validation_id. A batch also lists the rejected_domains it will not scan, and why. A name that cannot be scanned is refused before any quota is spent (see the DOMAIN_* codes under Errors).
  2. Poll (scan:read). GET /validation/jobs/{validation_id}/status until status is SUCCESS, FAILURE, REVOKED or CANCELLED; PENDING and PROGRESS mean keep waiting. Poll every 5 to 10 seconds, or subscribe to the scan.completed webhook instead. A single-domain scan usually takes 2 to 5 minutes.
  3. Read findings (findings:read). GET /validations/{validation_id}/findings returns one row per check with check_id, status (pass, partial, fail, not_applicable, not_tested, unknown), severity and evidence. For a batch, add ?domain=.
  4. Export (reports:read). GET /validations/{validation_id}/export?format=... for one scanned domain:
format Result
sarif SARIF 2.1.0 for code-scanning tools. With fail_on=high (any severity), the log's exit code is 1 when an unsuppressed failing finding is at or above it, which makes it a pipeline gate.
oscal OSCAL 1.1.3 assessment results against one framework (required).
xlsx Auditor working paper: one sheet per control area, one row per finding, with lifecycle state, owner, due date and exception.
bundle Hash-chained evidence bundle (.zip); add framework to include that framework's coverage. It is not signed: see What an export proves.

Whole-validation reports (PDF, HTML, JSON, CSV, XLSX; batches as one file or a zip) come from POST /reports/{validation_id}/export?format=pdf.

What an export proves

  • Lifecycle is as of export time. Finding state, owner, due date and exceptions belong to the asset, not to the scan, so every export shows the state at the moment you export, not at the moment the scan ran. Exporting an older scan shows today's owner, due date and exceptions, and its OSCAL risks leave out findings closed since. Each format says so itself: SARIF in runs[0].properties.lifecycle_as_of_note, OSCAL in metadata.remarks, XLSX on the About sheet, the bundle in manifest.json (lifecycle_as_of_note), the coverage report API response in as_of_note (next to exceptions_as_of), and the HTML and PDF reports in the introduction of their coverage section.
  • The evidence bundle is hash-chained but not signed. manifest.json lists the SHA-256 of every member, and the findings fold into a Merkle root that is compared with the root sealed when the scan completed. That proves the files are intact and match what the server held for this scan (integrity against the server). It does not prove who produced the bundle (authorship): there is no digital signature, so a party who can rebuild a manifest can rebuild a self-consistent bundle. manifest.json states this in integrity.signed (false) and integrity.authenticity_note. Treat a bundle that reached you from someone else as a claim, not as proof: export the same scan yourself and compare the Merkle roots.
curl -s "/api/v1/validations/4711/export?format=sarif&fail_on=high" \
  -H "Authorization: Bearer $SETENFORCE_TOKEN" -o setenforce.sarif

Webhooks

Add a webhook under Notification channels (or POST /notifications/channels with webhooks:write). Only organization owners and admins can create, edit or test a webhook (others get 403 ROLE_CANNOT_MANAGE_WEBHOOKS); the role is checked in the active organization, and finding.* events are delivered only while the webhook's owner is an owner or admin of the finding's organization. Each webhook is an HTTPS POST of a JSON body {"id", "type", "created_at", "data"}. Events: scan.completed, finding.opened, finding.fixed, finding.state_changed, exception.expiring, alert.raised, certificate.expiring (and webhook.test from the Test button).

X-SetEnforce-Signature: t=<unix time>,v1=<hex>, where v1 is HMAC-SHA256 of "<t>." + raw body keyed with your whole whsec_... secret. Verify against the raw bytes before parsing the JSON, compare in constant time, and reject a t more than 5 minutes from your clock to stop replays. Delivery is at-least-once: dedupe on X-SetEnforce-Event-Id. Answer with any 2xx within 10 seconds.

Retries and dead-letter

A delivery is attempted up to 5 times in all: the first send and four retries. After a failed attempt (no 2xx within 10 seconds, or a connection error) the retry comes 1 second, then 10 seconds, then 1 minute, then 10 minutes later, each delay varied by up to 20% either way. If the fifth attempt fails too, the delivery is dead-lettered: it is marked failed, kept in the delivery log under Notification channels for 30 days, and never sent again. A delivery is also dead-lettered without further attempts when the webhook is disabled or its owner no longer holds the role needed to receive that event. Every attempt re-sends the same body and the same X-SetEnforce-Event-Id. The Test button sends once and does not retry.

# Python
import hashlib, hmac, time

def verify(secret: str, body: bytes, header: str, tolerance: int = 300) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(","))
    t = int(parts["t"])
    if abs(time.time() - t) > tolerance:
        return False  # too old (or too new): possible replay
    expected = hmac.new(secret.encode(), f"{t}.".encode() + body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, parts.get("v1", ""))
// Node.js (rawBody: the request body as a Buffer, before JSON.parse)
const crypto = require('crypto');

function verify(secret, rawBody, header, toleranceSec = 300) {
  const parts = Object.fromEntries(header.split(',').map(p => p.split('=', 2)));
  const t = Number(parts.t);
  if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;
  const expected = crypto.createHmac('sha256', secret).update(`${t}.`).update(rawBody).digest('hex');
  const a = Buffer.from(expected), b = Buffer.from(parts.v1 || '');
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

Pagination

List endpoints take limit (page size) and cursor, and answer with items and next_cursor. To read everything, pass each response's next_cursor back as cursor until it is null. Keep the other query parameters (filters, sort) the same on every page. Treat the cursor as opaque: its format may change. A cursor this API did not issue is refused with 400 INVALID_CURSOR.

cursor=""
while :; do
  page=$(curl -s "$BASE/validation/jobs?limit=100${cursor:+&cursor=$cursor}" -H "Authorization: Bearer $SETENFORCE_TOKEN")
  echo "$page" | jq -c '.items[]'
  cursor=$(echo "$page" | jq -r '.next_cursor // empty')
  [ -z "$cursor" ] && break
done
Endpoint limit (default / max) Order
GET /validation/jobs 50 / 1000 newest first (or `sort`, `order`)
GET /reports/ 50 / 200 oldest first
GET /assets 25 / 100 domain name
GET /portfolio/domains 25 / 100 domain name
GET /remediation-tickets all / 500 overdue first, then due date, severity, newest
GET /monitoring/alerts 50 / 500 newest first
GET /monitoring/snapshots/{watchlist_id} 50 / 500 newest first
GET /watchlist 100 / 1000 newest first
GET /notifications/channels/{channel_id}/deliveries 50 / 200 newest first

Apart from GET /reports/ (see Versioning), these endpoints also keep the paging parameters they had before (offset, skip, page and page_size) and fields such as total; a cursor wins over them. Short, bounded lists (your organizations, members, tokens, notification channels, suppression rules, one scan's findings) return every item at once.

Rate Limits

Every API response carries your request budget for the current one-minute window:

  • RateLimit-Limit: requests allowed per minute.
  • RateLimit-Remaining: requests left in this window.
  • RateLimit-Reset: seconds until the window resets.

The budget is per API token, so one busy integration never slows down another or your own use of the web app. Its size is your plan's requests per minute:

Plan Requests per minute, per token
Free Tier 40
Standard Tier 60
Premium Tier 100

Past the budget an API token gets 429 RATE_LIMITED. Every 429 carries Retry-After (seconds): wait that long, then retry. Some endpoints have their own tighter limit (scan submission, sign-in, sending a test notification). A 429 from such a limit reports that limit in its RateLimit-* headers. Scans are also bound by daily quotas (QUOTA_EXCEEDED, BATCH_QUOTA_EXCEEDED) and by how many may run at once (CONCURRENT_LIMIT_EXCEEDED); GET /quota/status shows where you stand. Browser sessions see the same headers for information; they are not throttled by this budget.

Idempotency

POST /validation/single and POST /validation/batch accept an Idempotency-Key header (any unique string up to 255 characters; a UUID works). If a request times out or your pipeline step is retried, send it again with the same key: you get the first response back, with Idempotent-Replayed: true, and no second scan starts or counts against your quota.

  • Keys are remembered for 24 hours, per user and per endpoint.
  • Only a successful response is remembered. If the first attempt failed (for example QUOTA_EXCEEDED), the retry runs again for real.
  • The same key with a different body, or from another organization, is refused with 422 IDEMPOTENCY_KEY_REUSED.
  • A retry that arrives while the first request is still running gets 409 IDEMPOTENCY_KEY_IN_PROGRESS; retry it a moment later.

Errors

Errors use the HTTP status codes you would expect, and every error body has a stable, machine-readable code. Branch on code, not on the human-readable message or detail, whose wording may change. Most errors look like this:

HTTP/1.1 403 Forbidden
{
  "error": "PatScopeException",
  "code": "INSUFFICIENT_SCOPE",
  "message": "This API token lacks the 'scan:write' scope",
  "details": {"code": "INSUFFICIENT_SCOPE", "required_scope": "scan:write"},
  "path": "/api/v1/validation/single"
}

Some older endpoints answer {"detail": ..., "code": ...} instead, where detail is a message or an object. code is always at the top level. New codes may be added at any time, so treat an unknown code like the generic code for its HTTP status.

code Status Meaning
BAD_REQUEST 400 The request is malformed; see message.
UNAUTHENTICATED 401 No valid credential: missing, malformed, expired or revoked token.
FORBIDDEN 403 Authenticated, but not allowed to do this.
NOT_FOUND 404 No such resource, or not one you can see (other users' and orgs' ids are 404, not 403).
METHOD_NOT_ALLOWED 405 The path exists but not with this HTTP method.
CONFLICT 409 The resource is not in a state that allows this change.
PAYLOAD_TOO_LARGE 413 The upload is too large.
UNSUPPORTED_MEDIA_TYPE 415 The body's content type is not accepted.
UNPROCESSABLE 422 The request is well-formed but its content is refused; see message.
VALIDATION_FAILED 422 Request parameters or body failed schema validation; see details.validation_errors.
RATE_LIMITED 429 Too many requests. Wait for Retry-After seconds.
INTERNAL_ERROR 500 Unexpected server error. Safe to retry later.
DATABASE_ERROR 500 A database error occurred. Safe to retry later.
UPSTREAM_ERROR 502 A service we depend on failed.
SERVICE_UNAVAILABLE 503 Temporarily unavailable. Retry later.
INSUFFICIENT_SCOPE 403 The API token lacks the scope this endpoint needs; details.required_scope names it.
PAT_NOT_ALLOWED 403 This endpoint never accepts API tokens (for example token management or a password change); use a browser session.
PAT_CANNOT_APPROVE 403 Approvals, revocations and policy changes need an interactive session, not an API token.
INVALID_CURSOR 400 The cursor is not one this API issued.
IDEMPOTENCY_KEY_REUSED 422 This Idempotency-Key was already used with a different request body.
IDEMPOTENCY_KEY_IN_PROGRESS 409 A request with this Idempotency-Key is still being processed. Retry shortly.
QUOTA_EXCEEDED 429 Daily domain quota used up; details carry used, limit and reset_time.
CONCURRENT_LIMIT_EXCEEDED 429 Too many scans running at once for your tier.
BATCH_QUOTA_EXCEEDED 429 Daily batch-job quota used up.
DOMAIN_SYNTAX_INVALID 422 Not a syntactically valid domain name.
DOMAIN_IS_IP 422 An IP address, not a domain name.
DOMAIN_SUFFIX_INVALID 422 The name does not end in a real top-level domain.
DOMAIN_NOT_REGISTRABLE 422 A single-label name or a public suffix, not a registrable domain.
DOMAIN_RESERVED 422 A reserved or special-use name that does not resolve on the public internet.
DOMAIN_NOT_REGISTERED 422 The registrable domain does not exist in DNS.
HOST_NOT_FOUND 422 The domain exists but this host under it publishes nothing.
DNS_LOOKUP_FAILED 503 Could not tell whether the domain exists. Retry later.
NO_SCANNABLE_DOMAINS 422 Batch: none of the submitted domains can be scanned; detail.rejected says why.
ROLE_CANNOT_TRIAGE 403 Your organization role cannot change findings.
NOTHING_TO_CHANGE 422 The update names no state, assignee or unassign.
ILLEGAL_TRANSITION 422 That state change is not allowed from the finding's current state.
FIXED_IS_ENGINE_ONLY 422 Only a passing scan marks a finding fixed.
RESOLVE_EVENT_STYLE_ONLY 422 Only an event-style finding can be resolved by hand.
STALE_FINDING_STATE 409 The finding changed since you read it (expected_version mismatch). Reload and retry.
INVALID_ASSIGNEE 422 The assignee is not an owner, admin or member of the organization.
ROLE_CANNOT_REQUEST 403 Your organization role cannot request exceptions.
ROLE_CANNOT_APPROVE 403 Only an organization owner or admin can do this.
SEPARATE_APPROVER_REQUIRED 403 The organization requires someone other than the requester to approve.
SOD_NEEDS_TWO_APPROVERS 409 Separation of duties needs at least two active owners or admins.
SOD_ON_PERSONAL_ORG 422 Separation of duties cannot be enabled on a personal organization.
INVALID_KIND 422 kind must be risk_accepted or false_positive.
EXPIRY_REQUIRED 422 An expiry date is required.
EXPIRY_OUT_OF_RANGE 422 The expiry exceeds the organization's maximum acceptance period.
REASON_TOO_SHORT 422 The reason is shorter than the minimum length.
REASON_TOO_LONG 422 The reason is longer than the maximum length.
MAX_ACCEPTANCE_DAYS_OUT_OF_RANGE 422 max_acceptance_days must be between 1 and 365.
PENDING_EXISTS 409 The finding already has a pending exception request.
NOT_PENDING 409 The request or rule is no longer pending.
NOT_APPROVED 409 The exception is not active.
NOT_ACTIVE 409 The suppression rule is not active.
EXCEPTION_EXPIRED 409 The request's expiry date has already passed.
RULE_EXPIRED 409 The rule's expiry date has already passed.
EVIDENCE_CHANGED_SINCE_REQUEST 409 The finding's evidence changed after the request was filed.
CHECK_ID_INVALID 422 A suppression rule covers exactly one check_id; no wildcards.
CHECK_ID_UNKNOWN 422 No such check_id.
SELECTOR_INVALID 422 The asset selector is malformed.
SELECTOR_UNKNOWN_KEY 422 The asset selector has a key it does not support.
SELECTOR_EMPTY 422 The asset selector needs at least one condition (environment, tags_any or asset_ids).
ASSET_NOT_IN_ORG 422 An asset id is not an asset of this organization.
ROLE_CANNOT_EDIT_TICKETS 403 Your organization role cannot create or change tickets.
OPEN_TICKET_EXISTS 409 The finding already has an open ticket; details.ticket_id names it.
TICKET_CLOSED 409 The ticket is closed; reopen it first.
TICKET_NOT_CLOSED 409 The ticket is not closed.
EMPTY_TEXT 422 The text must not be empty.
TEXT_TOO_LONG 422 The text is longer than the maximum length.
ROLE_CANNOT_MANAGE_WEBHOOKS 403 Only an organization owner or admin can create, edit or test a webhook channel.
ASSET_HAS_LIFECYCLE_HISTORY 409 The asset has finding history and cannot be deleted; archive it.
PROFILE_RULES_INVALID 422 The policy profile rules are invalid; details.errors lists why.
PROFILE_NAME_TOO_LONG 422 The policy profile name is too long.

Versioning

The version is in the path: /api/v1. Within v1 we only make changes that a well-behaved client absorbs without a code change:

  • new endpoints, new optional parameters and new headers;
  • new fields in responses and webhook payloads (ignore fields you do not know);
  • new checks, controls, frameworks, webhook event types and error codes;
  • new values for a deprecated alias's replacement, never a change to the alias itself.

These need a new API version, and will not happen within v1:

  • removing or renaming an endpoint, a parameter or a response field, or changing a field's type;
  • changing the status vocabularies: job status (PENDING, PROGRESS, SUCCESS, FAILURE, REVOKED, CANCELLED), finding status and severity, finding lifecycle states;
  • renaming, reusing or changing the meaning of a check_id or a control id. A check id is an external interface: when a check is replaced, the new check gets a new id and the old id is retired, never recycled;
  • changing what an error code means, or what a token scope grants.

Breaking change in v1: GET /reports/

GET /reports/ used to return a bare JSON array of at most 50 reports. It now returns an object, {"items": [...], "next_cursor": null}, like the other paginated lists: read the reports from items and follow next_cursor for the rest (default limit 50, maximum 200). A client that parses the response as an array breaks, and unlike the other lists this one keeps no old-shape fallback. This change does not follow the rules above; we made it on purpose and record it here.

Deprecated fields and parameters keep working for the life of v1. Today these are the validations, offset and has_more fields of GET /validation/jobs, and the offset, skip, page and page_size parameters, replaced by cursor and limit. A security fix may tighten behaviour without a version change.