API reference

HRS Exposure API

Read your organization's external exposure, impersonation worklist and posture rollup.

v1.0.0OpenAPI 3.0.3https://exposure.humanriskshield.com

Read-only, tenant-scoped and stable. A key sees exactly one organization's data — there is no cross-tenant read, and no parameter that could produce one.

Authentication. Send your key as Authorization: Bearer <key> or X-API-Key: <key>. Keys are issued in the console under Settings, shown once, and carry only the scopes ticked when they were created: read:findings, read:impersonation, read:domains, read:summary. A key with the wrong scope gets 403, never a silently empty list.

Paging. List endpoints return {data, meta}. Read meta.total for the size of the whole result and page with limit and offset; limit is clamped to 200.

Rate limits. Per key, per window, reported on every response in X-RateLimit-Limit and X-RateLimit-Remaining. Over the limit is 429 with Retry-After in seconds.

What absence means. No finding for a module means that module has not run or did not complete — not that the check passed. Treat a missing module as unknown, and read /api/v1/domains to see whether a domain is verified and active at all.

Authentication

Every request carries a key. Either header works; pick one.

SchemeHeader
bearerAuthAuthorization: Bearer <key>
apiKeyHeaderX-API-Key: <key>. Equivalent to the bearer form; use whichever your client makes easier.
curl -s https://exposure.humanriskshield.com/api/v1/summary \
  -H 'Authorization: Bearer hrs_live_...'

Keys are created in the console under Settings → API keys, shown once at creation, and scoped at that moment. Rotate by issuing a new key and revoking the old one — revocation takes effect on the next request.

GET /api/v1/findings

Current exposures with their remediation state

Requires scope read:findings

One row per (domain, module, code) taken from the newest scan that actually ran that module. A module that has never run, or that errored on the last scan, is absent — it is not reported as clean.

status is the remediation disposition, not a scan result. resolved is only ever set by a re-scan that could no longer find the exposure, so it is evidence rather than an assertion.

Query parameters

NameTypeDefaultNotes
statusstring
open · all · new · acknowledged · verifying · resolved · false_positive · risk_accepted
openFilter by disposition. open (the default) means new, acknowledged, verifying. all applies no filter. Note that findings which state health rather than a problem are returned like any other, and are excluded only from the counts in /summary.
severitystring
critical · high · medium · low
—Filter to a single severity. Omit for all severities.
limitinteger50Rows per page. Values above the maximum are clamped to it rather than rejected.
offsetinteger0Rows to skip. Page by adding limit until offset + count reaches meta.total.

Response fields

FieldTypePresentNotes
domainstringalways
modulestringalwaysWhich check produced this
codestringalwaysStable machine identifier — the thing to key your own logic on
severitystringalways(critical · high · medium · low)
titlestringalways
detailstringalwaysPlain-language explanation, safe to show a client
item_countinteger | nullalwaysHow many things this covers (accounts, hosts, records) where the count means something
statusstringalways(new · acknowledged · verifying · resolved · false_positive · risk_accepted)
first_seenstring | nullalways
updated_atstring | nullalways

Example

curl -s https://exposure.humanriskshield.com/api/v1/findings \
  -H 'Authorization: Bearer hrs_live_...'
{
    "data": [
        {
            "domain": "acme.com",
            "module": "email",
            "code": "dmarc_missing",
            "severity": "critical",
            "title": "No DMARC policy published",
            "detail": "acme.com publishes no DMARC record, so nothing tells receiving mail servers what to do with mail that fails authentication.",
            "item_count": 1,
            "status": "new",
            "first_seen": "2026-01-15T09:30:00Z",
            "updated_at": "2026-01-15T09:30:00Z"
        }
    ],
    "meta": {
        "count": 3,
        "total": 3,
        "limit": 50,
        "offset": 0,
        "status": "string",
        "severity": "string"
    }
}

GET /api/v1/impersonation

Look-alike domains registered against your brand

Requires scope read:impersonation

Candidates discovered by permutation of your monitored domains and confirmed to exist. has_mail is the field to alert on: a look-alike with MX records is configured to send mail as you.

Query parameters

NameTypeDefaultNotes
statusstring
active · all · open · monitoring · takedown_requested · resolved · dismissed
activeFilter by worklist state. active (the default) means open, monitoring, takedown_requested. all applies no filter.
limitinteger50Rows per page. Values above the maximum are clamped to it rather than rejected.
offsetinteger0Rows to skip. Page by adding limit until offset + count reaches meta.total.

Response fields

FieldTypePresentNotes
candidate_domainstringalwaysThe registered look-alike
brand_domainstringalwaysYour domain it imitates
has_mailbooleanalwaysIt has MX records, so it can send mail as you
statusstringalways(open · monitoring · takedown_requested · resolved · dismissed)
first_seenstring | nullalways
last_seenstring | nullalways

Example

curl -s https://exposure.humanriskshield.com/api/v1/impersonation \
  -H 'Authorization: Bearer hrs_live_...'
{
    "data": [
        {
            "candidate_domain": "acrne.com",
            "brand_domain": "acme.com",
            "has_mail": true,
            "status": "open",
            "first_seen": "2026-01-15T09:30:00Z",
            "last_seen": "2026-01-15T09:30:00Z"
        }
    ],
    "meta": {
        "count": 3,
        "total": 3,
        "limit": 50,
        "offset": 0,
        "status": "string",
        "severity": "string"
    }
}

GET /api/v1/domains

Domains under monitoring for this organization

Requires scope read:domains

Every domain on the account, including ones that are inactive or not yet verified. An unverified domain is not scanned, so check verified before reading anything into an empty finding list.

Query parameters

NameTypeDefaultNotes
limitinteger50Rows per page. Values above the maximum are clamped to it rather than rejected.
offsetinteger0Rows to skip. Page by adding limit until offset + count reaches meta.total.

Response fields

FieldTypePresentNotes
domainstringalways
activebooleanalwaysInactive domains are not scanned
verifiedbooleanalwaysOwnership proved by DNS TXT. Unverified domains are not scanned.
cadence_daysinteger | nullalwaysHow often it is re-scanned
last_scan_atstring | nullalwaysCompletion of the most recent finished scan, or null if none has finished
next_scan_atstring | nullalwaysWhen the next scheduled scan is due
created_atstring | nullalways

Example

curl -s https://exposure.humanriskshield.com/api/v1/domains \
  -H 'Authorization: Bearer hrs_live_...'
{
    "data": [
        {
            "domain": "acme.com",
            "active": true,
            "verified": true,
            "cadence_days": 1,
            "last_scan_at": "2026-01-15T09:30:00Z",
            "next_scan_at": "2026-01-15T09:30:00Z",
            "created_at": "2026-01-15T09:30:00Z"
        }
    ],
    "meta": {
        "count": 3,
        "total": 3,
        "limit": 50,
        "offset": 0,
        "status": "string",
        "severity": "string"
    }
}

GET /api/v1/summary

Posture rollup for a dashboard or SIEM

Requires scope read:summary

A single small object, cheap enough to poll. Unlike the list endpoints this one has no meta and takes no paging parameters.

Response fields

FieldTypePresentNotes
open_findingsobjectalwaysOpen findings by severity. Open means new, acknowledged, verifying. Findings that state health rather than a problem (a valid certificate, an inventory) are excluded — they are returned by /findings but are not work.
open_findings.totalintegeralways
open_findings.criticalintegeralways
open_findings.highintegeralways
open_findings.mediumintegeralways
open_findings.lowintegeralways
monitored_domainsintegeralwaysActive monitored domains
impersonationobjectalways
impersonation.activeintegeralways
impersonation.running_mailintegeralwaysActive look-alikes with MX records
impersonation.takedown_requestedintegeralways
latest_gradeobject | nullalwaysThe most recently completed scan on the account, or null if none has completed.
latest_grade.domainstringwhen latest_grade is set
latest_grade.letterstringwhen latest_grade is set(A · B · C · D · F)
latest_grade.scoreinteger | nullwhen latest_grade is set
latest_grade.atstring | nullwhen latest_grade is set
generated_atstringalwaysWhen this rollup was computed (UTC)

Example

curl -s https://exposure.humanriskshield.com/api/v1/summary \
  -H 'Authorization: Bearer hrs_live_...'
{
    "data": {
        "open_findings": {
            "total": 3,
            "critical": 1,
            "high": 1,
            "medium": 1,
            "low": 1
        },
        "monitored_domains": 1,
        "impersonation": {
            "active": 1,
            "running_mail": 1,
            "takedown_requested": 1
        },
        "latest_grade": {
            "domain": "acme.com",
            "letter": "C",
            "score": 66,
            "at": "2026-01-15T09:30:00Z"
        },
        "generated_at": "2026-01-15T09:30:00Z"
    }
}

Errors & limits

Errors are JSON with a stable error string. Match on that, never on the prose in detail.

StatuserrorMeaning
401unauthorizedNo key was presented, or the key is unknown or revoked.
403insufficient_scopeThe key is valid but was not granted the scope this endpoint needs.
429rate_limitedToo many requests for this key in the current window.

Every response carries X-RateLimit-Limit and X-RateLimit-Remaining. A 429 also carries Retry-After in seconds — wait it out rather than retrying immediately.

A 5xx is ours. Responses include an X-Request-Id; quote it and we can find the exact request in our logs.