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
- 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).
- 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.
- 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=.
- 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.