← All articles

API & webhooks

Pull your scans and findings programmatically, submit new domains, or have results pushed to you.

Every scan and finding in your account is available over an HTTP API, so you can pull results into a dashboard, a CI pipeline, or your own reporting — and, if you grant the permission, submit new domains for scanning. Base URL: https://cyber-report.app/api/v1.

Creating a token

Go to Settings → API tokens, give the token a name you'll recognise later (CI pipeline, Grafana), and create it. Tokens begin with cr_.

Every token can read. Submitting domains is a separate permission — tick Allow submitting new domains when you create the token. Leave it unticked for anything that only needs to read, which is most integrations.

Tokens created before this permission existed remain read-only, and permissions can't be changed after creation. To upgrade a token, revoke it and create a new one — that way a credential's capabilities can never change underneath the systems already holding it.

Copy it immediately — we show it once and never again. We store only a SHA-256 hash, so if you lose the value we cannot recover it for you; revoke it and create another. You can hold up to 10 tokens at a time, and a token stays valid until you revoke it. Revoking takes effect on the next request.

Use a separate token per system. They're independently revocable, and each gets its own rate-limit budget — so a runaway CI job can't throttle your dashboard.

Authenticating

Send the token as a bearer token. This is the only accepted form — there is no query-string fallback.

Authorization: Bearer cr_your_token_here

A missing or invalid token returns 401 with a body of {"error": "..."}. A revoked token is indistinguishable from one that never existed.

Every error from this API has an error string, whatever your Accept header says. Some also carry a machine-readable reason: missing_scope (403 — the token can't submit domains), email_unverified (403), domain_cap (402), domain_locked (403), rescan_cooldown (429) and rate_limited (429). Validation failures return 422 with a per-field errors object.

List scans

GET /api/v1/scans — your scans, newest first.

Accepts page and per_page (default 25, maximum 100). Out-of-range values are clamped rather than rejected. Scans in progress and failed scans are included, so check status before relying on a result.

FieldTypeNotes
idstringUUID — use it to fetch the full scan
domainstring
statusstringcompleted, scanning, failed, and others
risk_scoreinteger or nullnull until the scan finishes
created_atstringISO-8601
completed_atstring or nullISO-8601
findings_countinteger

Results sit under data, with a meta object carrying current_page, per_page, total and last_page.

Fetch one scan

GET /api/v1/scans/{id} returns the same fields plus a findings array, ordered critical first. Each finding carries finding_type, severity, title, description, remediation, location, detected_at, and — where we have them — cve, cvss_score, epss_score, epss_percentile and kev_listed.

Findings you've accepted as risks are included, marked status: "accepted_risk" with a suppression_reason. Filter them out client-side if you only want open items. The findings array is not paginated.

A scan that doesn't exist — or belongs to someone else — returns 404. We don't distinguish the two, deliberately.

Submit a domain

POST /api/v1/scans — add a domain and get back what you need to prove you control it. Requires a token with the domain-submission permission.

{"domain": "example.com", "authorization": true}

authorization is required. By sending it you confirm — for this specific domain — that you own it or are authorized to test it, which is the same warranty as Terms of Service §3. We record when you made it. true, "true", 1, "1" and "yes" all count; omitting it returns 422. No other field is accepted — everything else about the scan is set by us.

Returns 201 with the usual scan fields plus normalized_domain and a verification object containing the token, the exact DNS record to publish (methods.dns_txt.record_name / .value), the exact file URL and contents (methods.http_file.url / .content), and a verify_url for doing it in the browser instead.

The scan does not start yet. Nothing is sent to your site until verification succeeds.

Submitting the same domain twice while the first submission is still awaiting verification returns 200 (not 201) with that same scan, so the call is safe to retry after a timeout. A domain that has already been scanned recently returns 429 with reason: "rescan_cooldown" and a Retry-After instead.

Verify ownership

POST /api/v1/scans/{id}/verify with {"method": "dns_txt"} or {"method": "http_file"}. On success the scan moves to queued and starts running; poll GET /api/v1/scans/{id} for the result.

A failed check returns 422 with verified: false, an error explaining what we found instead, and attempts_remaining. DNS records take time to propagate — if you've just added the record, wait 30 seconds before retrying.

You get 10 verification attempts per scan per hour. Exhausting them marks the scan verification_failed permanently and you'll need to submit a new one. That budget is shared with the browser flow, so switching between them doesn't give you more.

Email-code verification is browser-only; the API offers the DNS and file methods.

Rate limits

120 requests per minute, per token. Two tokens on the same account get two independent budgets, and one token shares a single budget across every machine using it. Every response carries X-RateLimit-Limit and X-RateLimit-Remaining; exceeding the limit returns 429 with a Retry-After header telling you how long to wait.

The two write endpoints are limited far more tightly, in addition to that budget: 6 per minute and 30 per hour. Each one stages work that sends real traffic to a real site, so the ceiling is deliberately low. Your plan's domain allowance and the rescan cooldown still apply on top.

Webhooks

If polling isn't what you want, set a webhook URL under Settings → Notifications and we'll POST to it when a scan completes. The URL must be https and resolve to a public address. We generate a signing secret for you, shown on the same page.

Each delivery carries X-CyberReport-Event: scan.completed and X-CyberReport-Signature: sha256=<hex>, an HMAC-SHA256 of the raw request body using your secret. Verify against the raw bytes — re-serialising the parsed JSON will produce a different signature.

Delivery is best-effort: one attempt, a 5-second timeout, and no retries. If your endpoint is down or answers slowly you will miss that notification, so treat webhooks as a prompt to fetch rather than as the record itself. Note the counts in the webhook payload exclude accepted risks, whereas the API's findings array includes them.

Stability

The API is versioned in the path. We may add new fields to a response — and new endpoints — without notice, so parse defensively and ignore what you don't recognise. Anything that would break an existing integration lands on a new version rather than v1. New endpoints never widen what a token you already hold can do: that's what the permissions on the token are for.

Questions?

Building something and need an endpoint we don't offer yet? Email [email protected] — we'd like to hear what you're pulling.