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.
| Scheme | Header |
|---|---|
| bearerAuth | Authorization: Bearer <key> |
| apiKeyHeader | X-API-Key: <key>. Equivalent to the bearer form; use whichever your client makes easier. |
curl -s https://hrsexposure.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
| Name | Type | Default | Notes |
|---|---|---|---|
| status | string open · all · new · acknowledged · verifying · resolved · false_positive · risk_accepted | open | Filter 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. |
| severity | string critical · high · medium · low | — | Filter to a single severity. Omit for all severities. |
| limit | integer | 50 | Rows per page. Values above the maximum are clamped to it rather than rejected. |
| offset | integer | 0 | Rows to skip. Page by adding limit until offset + count reaches meta.total. |
Response fields
| Field | Type | Present | Notes |
|---|---|---|---|
| domain | string | always | |
| module | string | always | Which check produced this |
| code | string | always | Stable machine identifier — the thing to key your own logic on |
| severity | string | always | (critical · high · medium · low) |
| title | string | always | |
| detail | string | always | Plain-language explanation, safe to show a client |
| item_count | integer | null | always | How many things this covers (accounts, hosts, records) where the count means something |
| status | string | always | (new · acknowledged · verifying · resolved · false_positive · risk_accepted) |
| first_seen | string | null | always | |
| updated_at | string | null | always |
Example
curl -s https://hrsexposure.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
| Name | Type | Default | Notes |
|---|---|---|---|
| status | string active · all · open · monitoring · takedown_requested · resolved · dismissed | active | Filter by worklist state. active (the default) means open, monitoring, takedown_requested. all applies no filter. |
| limit | integer | 50 | Rows per page. Values above the maximum are clamped to it rather than rejected. |
| offset | integer | 0 | Rows to skip. Page by adding limit until offset + count reaches meta.total. |
Response fields
| Field | Type | Present | Notes |
|---|---|---|---|
| candidate_domain | string | always | The registered look-alike |
| brand_domain | string | always | Your domain it imitates |
| has_mail | boolean | always | It has MX records, so it can send mail as you |
| status | string | always | (open · monitoring · takedown_requested · resolved · dismissed) |
| first_seen | string | null | always | |
| last_seen | string | null | always |
Example
curl -s https://hrsexposure.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
| Name | Type | Default | Notes |
|---|---|---|---|
| limit | integer | 50 | Rows per page. Values above the maximum are clamped to it rather than rejected. |
| offset | integer | 0 | Rows to skip. Page by adding limit until offset + count reaches meta.total. |
Response fields
| Field | Type | Present | Notes |
|---|---|---|---|
| domain | string | always | |
| active | boolean | always | Inactive domains are not scanned |
| verified | boolean | always | Ownership proved by DNS TXT. Unverified domains are not scanned. |
| cadence_days | integer | null | always | How often it is re-scanned |
| last_scan_at | string | null | always | Completion of the most recent finished scan, or null if none has finished |
| next_scan_at | string | null | always | When the next scheduled scan is due |
| created_at | string | null | always |
Example
curl -s https://hrsexposure.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
| Field | Type | Present | Notes |
|---|---|---|---|
| open_findings | object | always | Open 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.total | integer | always | |
| open_findings.critical | integer | always | |
| open_findings.high | integer | always | |
| open_findings.medium | integer | always | |
| open_findings.low | integer | always | |
| monitored_domains | integer | always | Active monitored domains |
| impersonation | object | always | |
| impersonation.active | integer | always | |
| impersonation.running_mail | integer | always | Active look-alikes with MX records |
| impersonation.takedown_requested | integer | always | |
| latest_grade | object | null | always | The most recently completed scan on the account, or null if none has completed. |
| latest_grade.domain | string | when latest_grade is set | |
| latest_grade.letter | string | when latest_grade is set | (A · B · C · D · F) |
| latest_grade.score | integer | null | when latest_grade is set | |
| latest_grade.at | string | null | when latest_grade is set | |
| generated_at | string | always | When this rollup was computed (UTC) |
Example
curl -s https://hrsexposure.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.
| Status | error | Meaning |
|---|---|---|
| 401 | unauthorized | No key was presented, or the key is unknown or revoked. |
| 403 | insufficient_scope | The key is valid but was not granted the scope this endpoint needs. |
| 429 | rate_limited | Too 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.