API Overview
The ShadowMap API gives your own systems read-only access to your ShadowMap data — SIEM/SOAR pipelines, BI tools (Qlik, Power BI, Tableau), GRC platforms and custom automation. It serves the same scored data you see in the dashboard, as JSON or XML.
It exposes a connectivity check, a capabilities listing, the Vendor Risk Management export, your asset inventory and scan sessions, and the records of 17 modules: Alerts and IP Reputation, Web and Mobile Applications, six Data Leaks modules, five Dark Web modules, and SLA policies and violations. Each of those modules has a paged list of its records, a detail endpoint for one record and a summary of its counts. More modules are added over time; GET /api/v1/capabilities always lists the modules that exist, what each supports and whether your token can use it. The machine-readable contract is openapi.yaml (OpenAPI 3.0.3): it describes every field of every record.
Three properties define how the API behaves:
- Read-only. There are no write or mutation endpoints.
- Token authentication. Every request carries your bearer token,
Authorization: Bearer <token>, over HTTPS. A client certificate is required only if your token was issued with one. IP allowlisting is optional, set by ShadowMap on request (see Authentication). - Scoped to your company. A token only ever returns your company's records, and there is no
company_idparameter. One field reaches beyond your company by design: a Compromised Computers record'ssubdomainslists every sub-domain of any monitored organisation's credentials found on that computer, not only your own (see Data rules).
At a glance
| Topic | Detail |
|---|---|
| Base URL | https://api.shadowmap.com/api/v1, or the hostname ShadowMap gives you at onboarding |
| Authentication | bearer token over HTTPS; a client certificate only if your token was issued with one; IP allowlisting optional |
| Token lifetime | 30 to 365 days, chosen when you create it |
| Limits | 60 requests per minute and 2 requests in flight, per token |
| Response format | JSON (the Vendor Risk Management export also offers XML) |
| Paging | pages of up to 500 records (200 on five heavy modules); follow next_cursor until it is null |
| Times | ISO-8601 with an explicit offset (+05:30) |
| Change feed | changed_since on Compromised Users, Compromised Computers and SLA Violations only |
| Errors | {"error": {"code", "message", "request_id"}} with a stable code |
| Request log retention | 365 days |
| Contract | openapi.yaml |
Getting access
An Administrator of your ShadowMap account creates and manages API tokens in the dashboard. No request to ShadowMap is needed.
- Open Settings → Integrations → API Access.
- Choose Create token, give it a name, pick the scopes it needs (one per area of the dashboard, see Scopes) and a lifetime (30, 90, 180 or 365 days).
- Confirm with a one-time code: the code from your authenticator app if you use one, otherwise a code ShadowMap emails to you.
- Copy the token now. It is shown once and stored only as a hash, so a lost token cannot be recovered: revoke it and create another.
The same page lists every token of your account with its scopes, status, expiry and last use, and lets an Administrator revoke a token at once (also confirmed with a code).
- Client certificate (optional). For teams that want certificate pinning, an Administrator can add a client certificate to a token with one click. The page downloads a package with the certificate and key as a
.pfxfile and as PEM files, and shows the passphrase once. Requests with that token to the API hostname then also need the certificate (see Client certificate). The private key is not kept by ShadowMap, so download the package once and store it safely. Removing the certificate returns the token to token-only access. A token that also works on your dashboard hostname (an integration set up before October 2026, marked Legacy host) cannot have a certificate added, because the certificate is checked only on the API hostname: create a new token to use a certificate. Tokens marked Managed by ShadowMap are shown for your information and cannot be changed on this page. - Expiry. A token is marked Expiring 30 days before it expires (a 30-day token: in its last 10 days). For a token you created, create a replacement and revoke the old one; for a token ShadowMap set up for you, the expiry emails ask you to contact ShadowMap to renew it.
- Activity. The Activity tab shows every call made with your tokens: time, token, endpoint, status, error code and source address. You can filter by token, outcome and date.
- Security emails. Every active Administrator of your account receives an email when a token is created or revoked, when a certificate is added or removed, and when a token is 30, 14 and 1 days from expiry (a token created with the 30-day lifetime skips the 30-day email).
- IP allowlist. An allowlist is not set from the dashboard; it is optional and ShadowMap sets it on request through Contact Support (see IP allowlist).
If the API Access page is not shown for your account, or you prefer ShadowMap to set up an integration for you, raise a request through Contact Support with the integration's name, a technical contact and the scopes it needs.
Base URL and versioning
https://api.shadowmap.com/api/v1The /v1 prefix versions the contract. Accounts hosted in a dedicated ShadowMap zone use that zone's own API hostname, which ShadowMap gives you at onboarding — the path structure is identical. Always call the hostname you were given: requests addressed to the API any other way are refused.
Authentication
The bearer token is the credential. Two further restrictions exist, and each applies only to a token that was set up with it: a client certificate, and an IP allowlist.
Bearer token
Send the full token on every request, over HTTPS:
curl -H "Authorization: Bearer {token}" \
https://api.shadowmap.com/api/v1/health- A token has the form
{id}|smk_{secret}. Send the whole string, including the id and thesmk_part. The prefix lets secret scanners recognise a leaked ShadowMap token. - A token belongs to the service account ShadowMap created for your integration, and therefore to one company. It can never read another tenant's records.
- A token expires at most 365 days after it was issued.
GET /api/v1/healthreturns itsexpires_at, so your integration can watch its own credential. - Renewal. Administrators are emailed 30, 14 and 1 days before a token expires. Create a replacement in Settings → Integrations → API Access, switch your integration to it, then revoke the old token. For a token ShadowMap set up for you, ask ShadowMap for a successor: it has the same scopes, IP allowlist and client certificate requirement, and the old token keeps working for a 14-day overlap.
- Revocation. An Administrator can revoke a token on the API Access page, or you can tell ShadowMap. The token stops working at once and returns
401 token_revoked. - Treat the token like a password: keep it out of source control, client-side code, URLs and chat. Since the token alone grants access unless you asked for one of the restrictions below, ask for only the scopes the integration needs.
Client certificate (optional)
A client certificate is required only if your token was issued with one. If you want one, generate the key and a certificate signing request (CSR) yourself and send ShadowMap only the CSR:
openssl req -new -newkey rsa:2048 -nodes -keyout client.key -out client.csr -subj "/CN=my-integration"ShadowMap signs it and returns client.crt, valid for one year, and issues your token tied to that certificate. Your private key never leaves your systems. Your client then presents the certificate during the TLS handshake on every request:
curl --cert client.crt --key client.key \
-H "Authorization: Bearer {token}" \
https://api.shadowmap.com/api/v1/health- A token issued with a certificate is tied to that one certificate. A request that presents no certificate or a different one (even another one ShadowMap signed for you) returns
401 certificate_mismatch. A revoked certificate is refused at ShadowMap's network edge (403, an HTML page) before it reaches the API. - The certificate is valid for one year and is renewed together with the token: each yearly rotation of such a token comes with a new certificate, so send ShadowMap a new CSR.
- A token issued without a certificate needs none, and any certificate your client happens to present is ignored.
Windows clients
Windows' built-in TLS stack (Schannel) is used by curl.exe on Windows and by .NET and PowerShell clients. It loads the client certificate from a PKCS#12 file, not from separate PEM files. Create one with openssl pkcs12 -export -in client.crt -inkey client.key -out client.p12, then call curl.exe --cert client.p12:<password> --cert-type P12 .... In our testing, Schannel also failed the TLS 1.3 handshake with a certificate loaded from a file, and limiting the client to TLS 1.2 (--tls-max 1.2) solved it. OpenSSL-based clients, such as curl on Linux or macOS and Python requests, work over TLS 1.3.
IP allowlist (optional)
IP allowlisting is optional, set by ShadowMap on request. A token without an allowlist is accepted from any address. A token with one is accepted only from the IPv4 and IPv6 networks on its list, and from anywhere else it is refused with 403 ip_not_allowed.
- An entry is a single address or a network: IPv4 from
/8to/32, IPv6 from/32to/128, with the host bits zero (203.0.113.0/24is valid,203.0.113.5/24is not). - IPv4 and IPv6 are separate entries. ShadowMap sees the address your connection actually used. If your network can reach the API over IPv6, a call may arrive from an IPv6 address even though you only listed IPv4, and it is then refused with
403 ip_not_allowed. List both families, or make your client use IPv4 only (curl -4). - An IPv4-mapped IPv6 address (
::ffff:203.0.113.5) is not accepted: give the IPv4 form. - List your public egress address — the address your network presents to the internet — not an internal or NAT address.
Get your egress address right
If you call the API from a cloud function, container or VPN, the address your provider presents to the internet is what must be allowlisted. Verify it from the same network path before you send it to ShadowMap, and prefer a range if the address changes.
Scopes
A token carries one or more scopes, and each endpoint requires one. A token without the scope an endpoint needs gets 403 scope_missing. A module's list, detail and summary all need the module's scope.
| Scope | Grants |
|---|---|
alerts:read | Alerts and IP Reputation |
attack-surface:read | Web Applications and Mobile Applications |
data-leaks:read | S3 Buckets, Code Repositories, Leaked Credentials, Docker Containers, Leaked Files and Leaked APIs |
dark-web:read | Data Breaches, Discussions, Compromised Users, Compromised Computers and Telegram |
sla:read | SLA policies and SLA violations |
inventory:read | your asset inventory: domains, subdomains, IP addresses and their counts |
scans:read | your scan sessions |
vrm:read | the Vendor Risk Management export |
/health and /capabilities need no scope. Ask for only the scopes the integration needs: a scope also grants the leaked values its modules hold, such as passwords and API credentials, in full (see Data rules). More scopes are introduced as more modules are released; GET /api/v1/capabilities shows, for each module, the scope it needs and whether your token holds it (granted).
Limits
| Limit | Value | Applies to |
|---|---|---|
| Requests | 60 per minute | each token |
| Requests in flight | 2 at the same time | each token |
- Successful responses carry
X-RateLimit-LimitandX-RateLimit-Remaining, and so does arate_limitedrefusal (withX-RateLimit-Remaining: 0). Aconcurrency_limitedrefusal carries onlyRetry-After. - A
429carriesRetry-After, the number of seconds to wait.rate_limitedmeans you used the minute's budget;concurrency_limitedmeans more than 2 requests were running at once. A request refused asconcurrency_limitedstill counts toward the minute's 60. - Limits are applied after your credentials are checked, so a request refused for a token, certificate or address reason never spends your budget.
- A request that would start a database read more than 25 seconds after it reached the API is stopped and answered
503 api_unavailable(a read already under way can run for up to 30 seconds): retry it with a backoff, or narrow it (for example fewerstatusvalues).
Design your client to pull on a schedule, run requests one after another (or two at a time at most), and honour Retry-After instead of retrying in a tight loop.
Endpoints
| Method | Path | Scope | Purpose |
|---|---|---|---|
GET | /api/v1/health | none | Check that your token is accepted (with its certificate and source address, if your token has those restrictions). |
GET | /api/v1/capabilities | none | List the modules, what each supports, and whether your token can use each. |
GET | /api/v1/vendor-risk-management/vendors | vrm:read | The full Vendor Risk Management export. |
GET | /api/v1/inventory/domains, /subdomains, /ips | inventory:read | Your asset inventory, one page at a time. |
GET | /api/v1/inventory/summary | inventory:read | Your domains, subdomains and IP addresses counted, in total and by status. |
GET | /api/v1/scans | scans:read | Your scan sessions, one page at a time. |
GET | /api/v1/<path> | the module's | One page of a module's records (see Module records for each module's path). |
GET | /api/v1/<path>/{id} | the module's | One record. |
GET | /api/v1/<path>/summary | the module's | The module's counts, by status tab and other dimensions (see Summaries). |
Only GET (and HEAD) is supported. On the API hostname, any other method is refused at ShadowMap's network edge (403, an HTML page) before it reaches the API.
Health check
Use this to verify your token and network path before you wire up a real integration. (If your token was issued with a client certificate, add --cert client.crt --key client.key to this and every other example.)
curl -H "Authorization: Bearer {token}" \
https://api.shadowmap.com/api/v1/health{
"data": {
"status": "ok",
"time": "2026-10-09T09:30:00+05:30",
"company_id": 12345,
"token": {
"id": 17,
"name": "SIEM integration",
"scopes": ["vrm:read"],
"expires_at": "2027-10-09T09:30:00+05:30"
}
}
}A 200 confirms that your token is accepted, together with its client certificate and source address when your token requires them. time and expires_at are ISO-8601 with an explicit offset (here +05:30). /health needs no scope, so it does not prove the token can call a particular endpoint: check token.scopes, or granted in /capabilities.
Capabilities
curl -H "Authorization: Bearer {token}" \
https://api.shadowmap.com/api/v1/capabilitiesAn abridged response (the real one lists every module):
{
"data": {
"api_version": "v1",
"modules": [
{
"module": "vendor-risk-management.vendors",
"scope": "vrm:read",
"endpoints": ["GET /api/v1/vendor-risk-management/vendors"],
"formats": ["json", "xml"],
"filters": [],
"discovery_column": null,
"changed_since": false,
"group_by": [],
"pagination": null,
"sort_keys": [],
"refreshed": "hourly snapshot",
"status_labels": [],
"status_partition": null,
"granted": true
},
{
"module": "darkweb.compromised-users",
"scope": "dark-web:read",
"endpoints": [
"GET /api/v1/darkweb/compromised-users",
"GET /api/v1/darkweb/compromised-users/{id}",
"GET /api/v1/darkweb/compromised-users/summary"
],
"formats": ["json"],
"filters": ["status", "discovered_from", "discovered_to", "changed_since"],
"discovery_column": "seen_on",
"changed_since": true,
"group_by": ["status"],
"pagination": { "key": "id", "default_page_size": 100, "max_page_size": 500 },
"sort_keys": ["first_seen", "id"],
"refreshed": "live",
"status_labels": ["Needs Action", "Working Accounts", "Valid Users", "Action Taken", "To Be Closed", "Dismissed", "Mitigated"],
"status_partition": true,
"granted": false
}
]
}
}Each module declares what it supports, so your client discovers it instead of guessing:
endpoints— the module's paths: for a module with records, its list, its detail and its summary.filters— every filter parameter the module's list and summary accept, with the same values on both. The list also takespage_size,cursorandsort, the summarygroup_by(andtopon Alerts and Web Applications); the detail takes no parameter.status_labels— the dashboard's tab labels, as thestatusfilter takes them and the summary reports them; forscans, the lifecycle states itslifecycle_statefilter takes.group_by— the dimensions the summary can count by.host, on Alerts and Web Applications, ranks the hosts with the most records (see Hosts at risk).discovery_column— the record fielddiscovered_fromanddiscovered_tofilter.changed_since— whether the list and summary can return only the records changed since a moment (see Changes since your last pull).pagination— how the list pages:key(the field records are ordered by),tiebreak(present when the key is not the id),default_page_sizeandmax_page_size;nullwhen the response is not paged.sort_keys— the keys the list takes insort(see Sorting); empty where the list takes nosort.refreshed—livewhen the data is read at the time of your request,hourly snapshotfor the Vendor Risk Management export.status_partition— whether the module's status tabs are designed to cover every record (see Summaries);nullfor the Vendor Risk Management export, the inventory and the scans.granted— whether your token holds the module's scope.
Vendor Risk Management export
Returns one row per vendor record for your company — the same dataset as the dashboard's Excel export, flattened for programmatic consumption.
# JSON (default)
curl -H "Authorization: Bearer {token}" \
https://api.shadowmap.com/api/v1/vendor-risk-management/vendors
# XML
curl -H "Authorization: Bearer {token}" \
"https://api.shadowmap.com/api/v1/vendor-risk-management/vendors?format=xml"The response is a flat array of vendor objects (not wrapped in a data envelope, and not paged), so it streams directly into BI table loaders. With format=xml it is an XML document with one <item> element per row. You can also ask for XML with an Accept: application/xml header when JSON is not also accepted; ?format= takes precedence.
An abridged row:
{
"type": "current",
"name": "Example Vendor Ltd",
"initials": "EV",
"scan_date": "2026-10-01 06:00:00",
"score_date": "2026-10-01 07:00:00",
"tags": "cloud,finance",
"custom_tags": "tier-1",
"overall_security_rating": "B",
"overall_risk_score": 84,
"alerts_rating": "B",
"alerts_risk_score": 82,
"alerts_high": 3,
"alerts_medium": 12,
"alerts_low": 8
}Every row carries all of these field groups:
| Field group | Fields | Notes |
|---|---|---|
| Identity | type, name, initials, scan_date, score_date, tags, custom_tags | type is current (latest assessment) or historical (an earlier assessment). scan_date is the authoritative scan start time, or null if the vendor has not been scanned yet. score_date is when the vendor's security rating was calculated — reported separately because score freshness can differ from scan freshness; null if the vendor has not been scored yet. |
| Overall | overall_security_rating, overall_risk_score | Letter grade A–F plus the numeric 0–100 score. |
| Per-category ratings | alerts_*, brand_protection_*, data_leaks_*, dark_web_*, email_and_dns_*, encryption_and_certificates_*, application_security_*, vulnerability_management_* | A _rating (letter) and a numeric score (_risk_score, or _score for the last four categories) for each of the eight scoring categories. |
| Finding counts | {category}_high, {category}_medium, {category}_low | High/medium/low counts for the four finding-bearing categories (alerts, brand protection, data leaks, dark web). |
Letter grades are derived from the numeric score on the standard band: A = 90–100, B = 80–89, C = 70–79, D = 60–69, F = below 60. This is the same banding used everywhere else in ShadowMap — see Security Rating Algorithm. Vendors assessed under the older four-category model return null for the four newer categories rather than omitting them, so the column set stays stable across your dataset.
Rows come in no guaranteed order
The export does not promise any row order. Use type and scan_date to order or de-duplicate rows in your own tooling.
No filters or sorting
The export has no filter or sort parameters; the only parameter is format. Filter and sort in your own tooling. (Earlier versions of this page listed priority, tag, score and sort parameters. They were never applied to the snapshot that normally answers the request, so they are not part of the contract.)
Snapshot-backed for consistency
For performance, the export is served from a snapshot that is refreshed hourly. A pull therefore sees a stable, point-in-time dataset rather than values that shift mid-scan, and pulling more often than hourly returns the same data. The same scoring data is browsable in the dashboard under Vendor Risk Management.
Inventory and scans
The inventory lists — GET /api/v1/inventory/domains, /inventory/subdomains and /inventory/ips — page through your asset inventory with the same page_size, cursor and next_cursor as module records (see Record lists), at most 500 per page. Domains and IP addresses are ordered by id and carry the values of the dashboard's Domains and IP Addresses exports; subdomains are one record per subdomain, with all its IP addresses, ordered by name. Each list takes one status value: In Scope or Out of Scope, subdomains also Not Reachable, IP addresses Online or Offline. GET /api/v1/inventory/summary counts each asset type in total and by status, and takes no parameter.
GET /api/v1/scans lists the scan sessions the dashboard's Reports page lists, in id order, and takes started_from and started_to (dates, +05:30) and lifecycle_state. A completed scan is sealed. Scans that ended before lifecycle tracking began (18 July 2026) have no lifecycle_state: treat a scan with no lifecycle_state and an ended_at as completed.
Filter scans with lifecycle_state; its accepted values are the status_labels that /capabilities lists for scans (sending status= returns 422 with the accepted names).
Module records
Seventeen modules serve records. Each has three endpoints under one path — the list GET /api/v1/<path>, the detail GET /api/v1/<path>/{id} and the summary GET /api/v1/<path>/summary — and all three need the module's scope.
| Module | Path under /api/v1/ | Scope | Largest page | Discovery column | Module filters | Sort keys |
|---|---|---|---|---|---|---|
| Alerts | threats/alerts | alerts:read | 500 | added_on | severity, finding_type, closed_by | severity, first_seen, last_seen, id |
| IP Reputation | threats/ip-reputation | alerts:read | 500 | first_seen | severity | first_seen, last_seen, id |
| Web Applications | attack-surface/web-applications | attack-surface:read | 200 | first_seen_on | severity | severity, first_seen, last_seen, id |
| Mobile Applications | attack-surface/mobile-applications | attack-surface:read | 500 | created_at | — | first_seen, id |
| S3 Buckets | data-leaks/s3-buckets | data-leaks:read | 500 | created_at | severity | severity, first_seen, id |
| Code Repositories | data-leaks/code-repositories | data-leaks:read | 200 | first_seen_on | severity | severity, first_seen, last_seen, id |
| Leaked Credentials | data-leaks/leaked-credentials | data-leaks:read | 500 | created_at | impact_level | first_seen, last_seen, id |
| Docker Containers | data-leaks/docker-containers | data-leaks:read | 500 | first_seen_on | severity | severity, first_seen, last_seen, id |
| Leaked Files | data-leaks/leaked-files | data-leaks:read | 500 | time_stamp | severity | severity, first_seen, id |
| Leaked APIs | data-leaks/leaked-apis | data-leaks:read | 200 | leaked_on | severity | severity, first_seen, id |
| Data Breaches | darkweb/data-breaches | dark-web:read | 200 | created_at | severity | severity, first_seen, last_seen, id |
| Discussions | darkweb/discussions | dark-web:read | 500 | published_on | — | first_seen, id |
| Compromised Users | darkweb/compromised-users | dark-web:read | 500 | seen_on | changed_since | first_seen, id |
| Compromised Computers | darkweb/compromised-computers | dark-web:read | 200 | breach_date | changed_since | first_seen, id |
| Telegram | darkweb/telegram | dark-web:read | 500 | messaged_at | — | first_seen, id |
| SLA Policies | sla/policies | sla:read | 500 | created_at | — | first_seen, id |
| SLA Violations | sla/violations | sla:read | 500 | created_at | module, changed_since | first_seen, id |
Every module's list and summary also take status, discovered_from and discovered_to. GET /api/v1/capabilities is the authority for all of this — each module's filters, the labels each module filter accepts (filter_labels), status_labels, group_by, discovery_column, changed_since, pagination and sort_keys — so have your client read them from there rather than hard-code them. openapi.yaml describes every field of every record, including how each module decides a record's status tab.
Record lists
GET /api/v1/<path> returns one page of the module's records for your company. An abridged page:
{
"data": [
{ "id": 1203, "status": "Needs Review", "created_at": "2026-09-14T10:02:11+05:30" },
{ "id": 1304, "status": "Investigating", "created_at": "2026-09-15T08:45:00+05:30" }
],
"meta": { "page_size": 2, "next_cursor": "djE6eyJrIjoxMzA0fQ" }
}Paging:
page_size— 1 to the module's largest page (the table above, andmax_page_sizein/capabilities); 100 when you leave it out. Any other value is refused with422, never silently clamped.cursor— thenext_cursorof the previous page, exactly as you received it; leave it out for the first page. It is opaque: do not build or edit it, and keep every other parameter the same while you walk. A cursor that is not in the API's format is refused with422.next_cursor—nullon the last page. The walk ends whennext_cursorisnull, and only then. A page can hold fewer records thanpage_sizeand still have a next page — a record deleted or removed from your scope while its page is read is left out of that page — so never stop on a short page.meta.page_size— the page size the request used, not the number of records on the page.- Order — without
sort, ascendingidin every module except Alerts, which is ascendingadded_on, thenid(pagination.keyandpagination.tiebreakin/capabilities); withsort, the order it asks for (see Sorting). Walking every page withoutsort(or withsort=idorsort=first_seen) returns each record once. Each page is read when you request it, so a record that changes during a walk is returned as it was when its page was read.
Filters:
- Filters combine: a record is returned only when it matches every filter you give.
status— the dashboard's tabs, by their labels exactly asstatus_labelsspells them: one label, or several separated by commas, for examplestatus=Needs%20Review,Investigating. Several labels return the union of their tabs, each record once; a label given twice counts once. An unknown label or an empty entry is refused with422. A record in no tab (itsstatusisnull) is never returned under astatusfilter.discovered_fromanddiscovered_to— when ShadowMap first found the record: they filter the module'sdiscovery_column, so a window answers "what was found in September".- A date covers whole days at +05:30:
discovered_from=2026-09-01starts at 00:00 on 1 September, anddiscovered_to=2026-09-30ends at 23:59:59 on 30 September. - A date-time is ISO-8601 with
Zor an offset such as+05:30, for example2026-09-01T09:30:00Z. An explicit offset is honoured; a date-time without one is read as +05:30. Send+as%2B: a bare+in a URL means a space, and the value is then refused. - A window that ends before it starts, and a date that does not exist, are refused with
422. - A record whose discovery time is unknown — none stored, or the zero date (
0000-00-00 00:00:00) some older records hold — is in no window, not even one given onlydiscovered_to. Its discovery field readsnull; walk the list without a window to see it.
- A date covers whole days at +05:30:
- Module filters — the last column of the table, and in
filters:severity,impact_level,finding_type,closed_byandmoduletake the labels the module's summary groups by, in the same comma-separated form asstatusand with the same refusals, so the records listed under a label are the ones the summary counts under it. Alerts'closed_byreturns Closed alerts only, by who closed them (analystorscanner). IP Reputation'sseverityfilter is refused with422for a company with more than 10,000 listings.changed_sincehas its own section. - Encode labels in the URL — a space as
%20and&as%26, for examplemodule=Ransomware%20Group%20%26%20Forumon SLA Violations. No label contains a comma, so a comma always separates two labels. - Nothing is silently ignored. A parameter the endpoint does not take is refused with
422, and the message lists the ones it does take. So is a parameter given twice (status=Investigating&status=Closed— send one comma-separated value instead), in array form (status[]=Investigating) or with an empty value.
Sorting
sort orders a list by one of the module's sort keys (sort_keys in /capabilities, and the last column of the table above): sort=<key> ascending, sort=-<key> descending. The record id breaks ties, in the same direction.
Sorting is for ranking and top-N reads
severity and last_seen can change while you walk the pages: a record whose value moves during a walk can be skipped, or listed twice. For a complete pull, use no sort, sort=id or sort=first_seen (the discovery time does not change), and de-duplicate by id.
severity— by the risk behind the record'sseverity:sort=-severityputs the most severe first. On Alerts it isrisk_score, the analyst's risk where one is set; on Code Repositories the numericrisk; elsewhere theseveritylabel's own rank (for example Critical, High, Medium, Low on Data Breaches); a value outside the labels ranks lowest - last with-severity, first ascending.first_seen— the module's discovery column, the fielddiscovered_fromanddiscovered_tofilter.last_seen— when a scan last saw the record, where the module records it.id— the record id.
# The five most severe open alerts
curl -H "Authorization: Bearer {token}" \
"https://api.shadowmap.com/api/v1/threats/alerts?sort=-severity&page_size=5&status=Needs%20Review,Investigating,Accepted%20Risk"- One key. A second key (
sort=severity,id) or a key the module does not offer is refused with422, and the message lists the keys it takes. IP Reputation has noseveritykey, because its severity is computed per listing, and Leaked Credentials has none because its records carryimpact_levelinstead. - A missing value — a record with no
first_seenorlast_seen— sorts before every value ascending and after every value descending. - Cursors follow their sort. A
next_cursorcontinues only the sort it was returned under: send it back with the samesort. With another key, the other direction, or nosortat all it is refused with422, and a cursor from a list withoutsortis refused by a sorted one. Withoutsorta list pages exactly as before, so cursors you already hold keep working. - Filters combine with
sort: the filters choose the records,sortorders them.
Record details
GET /api/v1/<path>/{id} returns {"data": {...}}: the record the list returns for that id, with the same fields. It takes no query parameter, so any is refused with 422.
An id that is not one of your listed records answers 404 not_found, with the same body whichever it is: another company's record, one that does not exist, one the list leaves out (for example one removed from your scope), or an id not written as a plain integer (007). So the API cannot be used to learn whether another company's record exists. The dashboard still opens some records the API answers 404 for, such as one removed from your scope.
Summaries
GET /api/v1/<path>/summary counts the module's records: by the dashboard's status tabs, and by any other dimension the module declares in group_by.
curl -H "Authorization: Bearer {token}" \
"https://api.shadowmap.com/api/v1/threats/alerts/summary?discovered_from=2026-09-01&discovered_to=2026-09-30&group_by=status,severity"An abridged response:
{
"data": {
"all": 1840,
"removed_from_scope": 12,
"groups": [
{ "status": "Needs Review", "severity": "High", "count": 41 },
{ "status": "Needs Review", "severity": "Medium", "count": 310 },
{ "status": "Investigating", "severity": "High", "count": 7 }
]
},
"meta": {
"module": "threats.alerts",
"group_by": ["status", "severity"],
"status_partition": true,
"generated_at": "2026-10-17T09:30:00+05:30"
}
}all— every record the module holds for you, whatever its tab. Records removed from your scope are not in it.removed_from_scope— the records hidden because they left your scope (the dashboard's Removed From Scope tab);nullfor a module without that view. On Alerts it counts every descoped alert, including alertsallleaves out, so the two must not be added together.groups— with nogroup_by, one count per status tab: every tab, zeros included, as the dashboard's badges show them.group_bytakes up to three comma-separated dimensions from the module'sgroup_bylist, for examplestatus,severity, or on Alertscustom_tag:<key>for one of your tag keys (one custom tag at a time). The order you give is the order of the keys and of the sort, and only non-zero combinations are returned. A record with several values for one custom tag is counted under each.meta.status_partition—truewhen the module's tabs are designed to coverall, one tab per record. Even then a record left in a state no tab names is counted inalland in no tab. It isfalsefor Discussions, Code Repositories and Leaked APIs, where some records sit in no tab by design.
Filters on the summary
The summary takes exactly the filters its list takes (filters in /capabilities), with the same labels and the same 422s, plus group_by (and top on Alerts and Web Applications); it refuses page_size, cursor and sort.
- The discovery window,
changed_sinceand the module filters narrow every count: each tab,allandremoved_from_scope. - A
statusfilter limits the status groups to the tabs it names — in tab order, zeros included — andallto the records in any of them.removed_from_scopeis thennull: a record removed from your scope is in no tab. group_bythen groups the records the filters leave.
So "September's alerts by status and severity" is one call, the example above, and "open SLA violations on alerts" is GET /api/v1/sla/violations/summary?status=Open,Escalated&module=Alerts, whose all is the number.
Hosts at risk
On Alerts and Web Applications, group_by=host ranks the hosts with the most records under the summary's filters: the top of them (1 to 100, default 10), by count descending and then host ascending. Each group is a host with its count and its by_severity counts — every severity label, zeros included. A host can be a domain or an IP address; only findings with no stored host (for example leaked credentials) are not ranked, so the hosts' counts add up to at most all. Hosts that differ only in letter case are one host. host is given on its own: it cannot be combined with another group_by dimension, and top without group_by=host is refused with 422.
# The ten hosts with the most open alerts
curl -H "Authorization: Bearer {token}" \
"https://api.shadowmap.com/api/v1/threats/alerts/summary?group_by=host&top=10&status=Needs%20Review,Investigating,Accepted%20Risk"{
"data": {
"all": 5640,
"removed_from_scope": null,
"groups": [
{ "host": "portal.example.com", "count": 212, "by_severity": { "High": 9, "Medium": 61, "Low": 120, "Informational": 22 } },
{ "host": "www.example.com", "count": 187, "by_severity": { "High": 2, "Medium": 40, "Low": 101, "Informational": 44 } }
]
},
"meta": {
"module": "threats.alerts",
"group_by": ["host"],
"status_partition": true,
"generated_at": "2026-10-24T09:30:00+05:30",
"top": 10
}
}Every filter narrows the ranking as it narrows the counts: add severity=High for the hosts with the most open High alerts, or a discovery window for the hosts most hit this month. Web Applications ranks the record's host - a domain or an IP address - each application counted once.
Totals reconcile with the list
Under the same filters, all is the number of records a full walk of the list returns, and each status group is the number a walk with status=<that tab> returns. Read the two close together: each is a separate read of live data, so a record that changes in between is counted by one and not the other. The exceptions are deliberate — in each, the summary counts as the dashboard's badges count:
- Docker Containers counts an image once per details record ShadowMap holds for it — in
all, in its tab and in every group — while the list returns the image once. - Code Repositories counts a repository with more than one details, index or extensions record once per record in every status tab except Takedown In Progress, while
all, Takedown In Progress andremoved_from_scopecount it once, as the list returns it. - Docker Containers without a
statusfilter (no filter, or only the discovery window andseverity): the Requested Takedown, Taken Down and Filtered by AI groups also count an image thatallleaves out — one that is not Dismissed and whose scanner status is neitheronlinenortakendown. The list never returns such an image. Add astatusfilter and each tab counts only the imagesallholds. removed_from_scopecounts records the list never returns.
Changes since your last pull
Three modules can return only the records changed since a moment: Compromised Users, Compromised Computers and SLA Violations, the modules where every change to a record's fields moves its updated_at. /capabilities says changed_since: true for them and lists the parameter in their filters. Every other list and summary refuses changed_since with 422 rather than answer it incompletely: in those modules some changes leave every timestamp as it was, so do not use their updated_at to detect changes — pull the list whole, or compare summaries.
curl -H "Authorization: Bearer {token}" \
"https://api.shadowmap.com/api/v1/darkweb/compromised-users?changed_since=2026-10-16T06:00:00%2B05:30"changed_sincetakes the formsdiscovered_fromtakes: a date is the start of that day at +05:30, and a date-time is ISO-8601 (send+as%2B).- It returns the records whose
updated_atis at or after your moment, compared from 5 hours 30 minutes earlier on Compromised Users and Compromised Computers, and from the moment itself on SLA Violations. The stealer-log ingest that writes the two Dark Web modules stamps its changes with a UTC clock, so its stamps read 5 hours 30 minutes early; the allowance means none of them is missed, and it also returns ShadowMap's own changes from up to 5 hours 30 minutes before your moment. SLA violations have a single writer on ShadowMap's own clock, so they need no allowance. - It narrows the records, never their order — the list keeps its
idorder — and combines with every other filter. On the summary it narrows every count.
It over-reports, and it never misses a change to a field the record carries, if you overlap your pulls (see below). Every such change moves updated_at, so a pull from your moment returns every record changed since. It can also return records you already have: one changed shortly before your moment, one changed again, or — on the two Dark Web modules — one the ingest saw again without any visible change. Remove repeats by id.
What it cannot tell you:
- Deletions. A deleted record is in no pull. ShadowMap deletes false-positive credentials outright, and deletes SLA violations with their policy, with a module type removed from their policy and with their finding, among other clean-ups.
- Scope changes. A record that leaves your scope disappears from every list without a trace, and one restored to your scope comes back with its old
updated_at, so no pull returns it. - Records without a change time. A record whose
updated_atis unknown — none stored, or the zero date some older records hold — is in no pull until it changes.
Reconcile these with a periodic full pull, or by comparing the summary's all with your own count.
Overlap your pulls. A change is stamped when it is written, which can be a little before the API can see it. Start each pull from the moment your previous pull started, less a margin — we suggest an hour — and remove repeats by id.
Start from a full pull. Before this change feed was released, some changes did not move updated_at (an SLA violation's escalation, for one), so take one full pull first and use changed_since from then on.
Fields kept out of these records. None of the three records carries a field stored elsewhere whose changes leave the record's updated_at as it was: such a field could change without the record being returned. Read it where it lives:
| Record | Not carried | Read it from |
|---|---|---|
| Compromised Users | the computer's name | Compromised Computers, joined by hwid |
| Compromised Computers | its credentials and its password count | Compromised Users, joined by hwid, with that endpoint's own changed_since |
| SLA Violations | the policy name and each level's due date | /api/v1/sla/policies, by policy_id (see Violations) |
A Compromised Computers record does carry your custom tags: every tag change moves the computer's updated_at.
SLA policies and violations
Policies
GET /api/v1/sla/policies lists every SLA policy of your company, whoever created it.
statusis Active or Inactive, as the SLA Policies page labels the policy, andnullfor a stored status neither names. ShadowMap's SLA sweep — the recurring job that checks your findings against your policies and raises, escalates and closes violations — evaluates Active policies only.escalation_enabled— whenfalse, the policy's escalation levels are inert, and every violation it raises is created already Closed.module_typeslists the modules the policy covers:type(the SLA module type number, which its violations carry asmodule_type),labelandevaluated.evaluatedisfalsefor every type the sweep does not evaluate, for which the policy raises no violation — for example IP Reputation and SSL Certificates, which can be chosen in the dashboard, or a type labelled Unknown.escalationsare the policy's levels, 1 to 4, in order; a level can be missing. A level is dueoffsetdays, or months (unit; how a month is counted is under Violations), after the start of the day the violation was raised (+05:30), and the sweep escalates an open violation on or after that day, one level at a time.- The list and summary take
status,discovered_fromanddiscovered_to(oncreated_at). There is no change feed: no field of the record tells you a policy was edited — editing a level does not move the policy's own time — so pull this list whole when you need it. - Not in the record: the policy's recipients and integrations, its creator and its matching criteria.
Violations
GET /api/v1/sla/violations lists every SLA violation of your company. ShadowMap's SLA sweep raises a violation when it first finds a finding that matches one of your Active policies, and closes it when the finding is settled or an analyst closes it; a finding that opens again gets a new violation. A record:
{
"id": 88231,
"web_url": "https://dashboard.example.com/dashboard/sla-violations/escalated",
"status": "Escalated",
"module_type": 2,
"module": "Alerts",
"finding_id": 5512094,
"policy_id": 41,
"created_at": "2026-10-02T11:00:04+05:30",
"updated_at": "2026-10-09T00:30:12+05:30",
"escalation_level": 2,
"escalations": [
{ "level": 1, "escalated_at": "2026-10-05T00:30:09+05:30" },
{ "level": 2, "escalated_at": "2026-10-09T00:30:12+05:30" }
]
}statusis the dashboard's tab: Open (open and never escalated), Escalated (open and escalated at least once — the dashboard's tab, not a stored status) or Closed;nullfor a stored status no tab names.status=Open,Escalatedreturns every open violation. A policy that does not escalate creates its violations already Closed, so Closed does not always mean resolved.module_typeandmodulename the module of the violated finding: its SLA type number and its name (Unknown for a type ShadowMap has no name for). Themodulefilter andgroup_by=moduletake those names, for examplemodule=Alerts,Exposures.finding_idis the finding'sidin its module, so it joins to that module's records (the table below).policy_idis the policy that raised the violation, in/api/v1/sla/policies.created_atis the clock start: when the sweep first matched the finding, not when the finding was discovered. It is also the module's discovery column, sodiscovered_fromanddiscovered_tofilter it.escalationsis the violation's escalation history: each level it reached, once, in level order, with when it was first reached (escalated_at;nullwhen no time was recorded).escalation_levelis the highest level reached,nullfor a violation never escalated.updated_atis when the violation last changed: set when it is raised, moved when it is closed, each time it is escalated to a level, and when it is re-pointed to another finding.changed_sincefollows it. An escalation recorded before this change feed was released did not move it, so begin with one full pull.web_urlopens the dashboard tab the violation is listed under; the dashboard has no page for one violation.
module_type | module | Records at |
|---|---|---|
| 1 | Exposures | /api/v1/attack-surface/web-applications |
| 2 | Alerts | /api/v1/threats/alerts |
| 5 | Ransomware Group & Forum | /api/v1/darkweb/discussions |
| 6 | Data Breaches | /api/v1/darkweb/data-breaches |
| 7 | Docker Container | /api/v1/data-leaks/docker-containers |
| 12 | Leaked APIs | /api/v1/data-leaks/leaked-apis |
| 13 | Leaked File | /api/v1/data-leaks/leaked-files |
| 14 | Code Repositories | /api/v1/data-leaks/code-repositories |
| 17 | S3 Bucket | /api/v1/data-leaks/s3-buckets |
| 20 | Telegram Conversations | /api/v1/darkweb/telegram |
| 24 | Malware Compromised Users | /api/v1/darkweb/compromised-users |
| 25 | Malware Compromised Computers | /api/v1/darkweb/compromised-computers |
| 31 | Leaked Credentials | /api/v1/data-leaks/leaked-credentials |
Mobile Application (15) and IP Reputation (19) have record endpoints too, but the sweep does not evaluate them, so it raises no violation on them; the other module types have no endpoint in this version. Where a module lists one record for several stored copies — Web Applications and Data Breaches (see Data rules) — the finding can be listed there under another id, and its own id then answers 404.
Why a violation carries no policy name and no due dates. Both come from the policy, and editing the policy changes them without moving the violation's updated_at, so a changed_since pull would never show the change. Read them from /api/v1/sla/policies by policy_id, pulling that list whole, and compute each level's due date from the violation's created_at: the start of that day (+05:30) plus the level's offset in its unit. Count a month offset as the sweep does: a day the target month lacks rolls over into the next month, so one month after 31 January is 3 March (2 March in a leap year) — up to 3 days past the end of the shorter month. The number of days a violation has been open is not a field either: compute it from created_at.
Data rules
Rules every module's records follow; the contract describes every field.
- Leaked values are returned in full. Passwords (
passwordin Data Breaches and Compromised Users), leaked secrets (matching,before,afterandprobe_metadatain Leaked Credentials) and leaked requests (auth,headers,paramsandbodyin Leaked APIs) are returned exactly as stored — not masked, not truncated — even where the dashboard masks or hides them. Telegram messages are returned whole and can quote leaked material. Store and log these records as secrets, and give each token only the scopes its integration needs. ididentifies a record within its module. Use it to follow a record from one pull to the next, with two exceptions:- Web Applications. A record is one logical application: its
proto,host,portandpath, the host and path compared case-insensitively. ShadowMap can hold several copies of one application — a CDN copy, a copy found again at another IP address, a closed earlier copy — and represents it by one of them, the one the dashboard shows. The record'sidis that copy's, so it can change, for example when a scan closes the representative while another copy is still live; the oldidthen answers 404. Follow an application by (proto,host,port,path). - Data Breaches. A credential stored more than once is one record, under the
idof the copy kept for it: the copy linked to one of your tracked executives when there is one, otherwise the one stored first (the lowest id). Thatidis stable only while the rule keeps the same copy — adding or removing an executive can move the credential to anotherid— and theidof a copy that is not kept answers 404.
- Web Applications. A record is one logical application: its
- Executive names are your own executives' only. Data Breaches is the only module whose records name an executive, and it names your own tracked executives only: a breach linked to an executive who is not one of yours — another company's, or one no longer tracked — has
is_executive: falseandexecutive_name: null. Compromised Users and Compromised Computers only say whether a record is linked to one of your tracked executives (is_executive). - Times are ISO-8601 with an offset,
+05:30(for example2026-10-01T08:30:00+05:30): compare them as instants, not as text. A time ShadowMap does not know — none stored, or the zero date some older records hold — isnull. A value that is not an instant is returned as stored text and never read as a time, such as a breach'sbreach_date(2019-01, sometimes a bare year). Where a collector's clock differs from ShadowMap's, the module's contract says so: the stealer-log ingest stampscreated_atandupdated_atin UTC, so on Compromised Users and Compromised Computers a time it wrote reads 5 hours 30 minutes early. statusis a tab label. A record'sstatusis the dashboard tab it is under, one of the module'sstatus_labels, andnullfor a record in no tab. A stored status code is returned only where a module shows it beside the tab, for transparency.- A token sees the whole company. The integration's account is not narrowed by any dashboard user's organisation or saved-search restrictions, so its lists and counts can be larger than what a restricted dashboard user sees.
- One field names other organisations' sub-domains. A Compromised Computers record's
subdomainslists every sub-domain of any monitored organisation's credentials found on that computer — not only your own. Your own credentials for the computer are Compromised Users records: join them byhwid. web_urlopens the record on your dashboard.
A minimal client
import time
from datetime import datetime, timedelta, timezone
import requests
BASE = "https://api.shadowmap.com/api/v1"
session = requests.Session()
session.headers["Authorization"] = "Bearer {token}" # the full token string
# session.cert = ("client.crt", "client.key") # only if your token was issued with a client certificate
def get(path, params=None, retries=5):
for _ in range(retries):
response = session.get(BASE + path, params=params, timeout=90)
if response.status_code == 429: # rate_limited or concurrency_limited
time.sleep(int(response.headers.get("Retry-After", "1")))
continue
response.raise_for_status()
return response
raise RuntimeError("still rate limited after %d attempts" % retries)
def walk(path, params=None):
"""Every record a module list returns under params, page after page."""
params = dict(params or {})
while True:
page = get(path, params).json()
yield from page["data"]
if page["meta"]["next_cursor"] is None: # the only end of a walk: a short page is not one
return
params["cursor"] = page["meta"]["next_cursor"]
vendors = get("/vendor-risk-management/vendors").json()
# September's alerts that still need review, and their counts by severity
september = {"status": "Needs Review", "discovered_from": "2026-09-01", "discovered_to": "2026-09-30"}
alerts = list(walk("/threats/alerts", dict(september, page_size=500)))
by_severity = get("/threats/alerts/summary", dict(september, group_by="severity")).json()["data"]["groups"]
# A monthly report: the five most severe open alerts, and the ten hosts with the most open alerts
open_alerts = {"status": "Needs Review,Investigating,Accepted Risk"}
top_alerts = get("/threats/alerts", dict(open_alerts, sort="-severity", page_size=5)).json()["data"]
top_hosts = get("/threats/alerts/summary", dict(open_alerts, group_by="host", top=10)).json()["data"]["groups"]
# After one full pull: compromised users changed since the previous pull started, less an hour of overlap
def changed_users(previous_start):
started = datetime.now(timezone.utc)
since = (previous_start - timedelta(hours=1)).isoformat(timespec="seconds")
users = {user["id"]: user for user in walk("/darkweb/compromised-users", {"changed_since": since})}
return users, started # keep `started` for the next pullResponses and errors
Every response from the API, success or failure, carries an X-Request-Id header. Quote it when you contact support: it finds the exact request in ShadowMap's log.
Some requests never reach the API, because ShadowMap's network edge refuses them first:
- a revoked client certificate;
- a path outside
/api/v1/; - a method other than
GETorHEAD.
They get an HTML 403 page with no JSON error body and no X-Request-Id.
Every error has the same shape. Branch on the HTTP status first, then on error.code, which is stable; the message is for humans and may be reworded.
{
"error": {
"code": "ip_not_allowed",
"message": "Your source IP address is not on this token's allowlist.",
"request_id": "01J9ZQ8K3M4N5P6R7S8T9V0W1X"
}
}| Code | Status | Meaning and what to do |
|---|---|---|
origin_not_verified | 403 | The request did not reach the API through ShadowMap's network edge, for example because it was addressed to a server directly instead of the API hostname. Use the hostname you were given; if you do, send the request_id to support. |
token_invalid | 401 | The bearer token is missing, malformed or unknown. Send the full token string from onboarding in the Authorization: Bearer header. |
token_expired | 401 | The token is past its expiry. Switch to the successor issued at rotation, or ask ShadowMap for a new token. |
token_revoked | 401 | The token was revoked. Ask your ShadowMap contact for a new one. |
token_not_permitted | 403 | The token is not valid for the hostname or path you used — for example a token issued for the API hostname used on the dashboard hostname — or it was never set up for API use. Contact support. |
account_disabled | 403 | The account behind the token is suspended, or access for your organisation is disabled. Contact support. |
certificate_mismatch | 401 | Your token was issued with a client certificate, and the request presented none or a different one. Present the certificate issued for this token. A token issued at a yearly rotation is tied to the new certificate that came with it, not to the old one. |
ip_not_allowed | 403 | Your token has an IP allowlist, and the address your request came from is not on it. Check both IPv4 and IPv6 (see IP allowlist) and ask support to add the missing range. |
scope_missing | 403 | The token does not hold the scope this endpoint needs — for a module's list, detail or summary, the module's scope (see Scopes). granted in /capabilities shows which modules your token can read. |
rate_limited | 429 | More than 60 requests in a minute. Wait Retry-After seconds. |
concurrency_limited | 429 | More than 2 requests in flight. Wait Retry-After (1 second) and keep to 2 parallel requests. |
not_found | 404 | No such path: check the URL and the /api/v1 prefix. On a record detail, the id is not one of your listed records — another company's, one that does not exist, one the list leaves out, or one not written as a plain integer — and every case gets the same response. |
method_not_allowed | 405 | The method is not supported. The API is read-only: use GET. (On the API hostname, other methods are refused at the network edge before they reach the API.) |
validation_failed | 422 | A query parameter is invalid or not accepted here; message names it and why. For example: a parameter the endpoint does not take (such as changed_since outside the three modules that offer it, or any parameter on a record detail); a parameter given twice, in array form or with no value; a page_size outside 1 to the module's largest page; a cursor that is not in the API's format; an unknown label or an empty entry in status or a module filter; a date that is malformed or does not exist, or a window that ends before it starts; a group_by the module does not offer; a sort key the list does not offer, or a cursor sent with another sort than the one it came from; a top outside 1 to 100, or without group_by=host; IP Reputation's severity filter for a company with more than 10,000 listings. Fix the request before retrying. |
api_unavailable | 503 | The API is temporarily unavailable on ShadowMap's side, or the request ran out of time (see Limits). Retry with a backoff, and narrow a request that keeps running out of time; contact support if it persists. |
server_error | 500 | An unexpected error. ShadowMap is notified automatically. Retry later, and quote the request_id if it persists. |
Retry only 429 (after Retry-After), 500 and 503 (with a backoff). Every other 4xx needs something changed first.
Diagnosing a 401 or a 403
A 401 means ShadowMap could not accept your token, or the certificate your token requires (token_invalid, token_expired, token_revoked, certificate_mismatch). A 403 with a JSON body means your credentials were recognised but are not permitted here — for a token with an IP allowlist most often ip_not_allowed: check which family (IPv4 or IPv6) your network used, and that the address is on the token's allowlist. A 403 that is an HTML page came from ShadowMap's network edge: your client used a method or path the API does not serve, or presented a revoked client certificate.
Logging and monitoring
ShadowMap logs every request to the API, including refused ones, and keeps the log for 365 days, then deletes it. A log entry holds the time, the request id, your token and company, the source address, method, path and query string, the response status and error code, the response size and number of rows returned, your user agent and the fingerprint of the client certificate, if one was presented. Credential-like query parameters (such as a token, key, secret or password) are stored redacted — but never put a credential in a URL.
The log supports support requests and security monitoring. ShadowMap alerts its own operations team on patterns such as repeated refusals, a token used from a new address or a sudden jump in volume, and may contact your technical contact about them.
Integrations set up before October 2026
An integration that was set up before October 2026 and has been in regular use calls the API on your dashboard hostname with its existing token and no client certificate. It keeps working there for now. It follows the same limits (60 requests per minute, 2 in flight), returns the error format above, and its token has an expiry date; on the dashboard hostname its health check keeps its earlier response shape.
To move it to the API hostname, change only its base URL to the one in Base URL and versioning: the same token works there, with the same scopes, and needs no client certificate. ShadowMap will tell you before the dashboard-hostname path is closed. An older token that has not been used recently is no longer accepted (token_not_permitted): ask ShadowMap for a new one.
A note on ingest endpoints
You may see an /api/ingest/* path referenced. These are internal, server-to-server endpoints used by ShadowMap's own collection pipelines and authenticate with a shared secret, not a bearer token. They are not part of the customer-facing API and are not available for external use.
Common questions
How do I get a token? An Administrator creates it in Settings → Integrations → API Access (see Getting access). For an IP allowlist, contact support with your egress addresses (IPv4 and IPv6).
Do I need a client certificate or an IP allowlist? No. The bearer token is enough. A client certificate is required only if your token was issued with one, and IP allowlisting is optional, set by ShadowMap on request. Either adds a second check: a leaked token is then useless without the certificate's private key, or from outside your networks.
I get 403 even though my token is valid. Why? Read error.code. For a token with an IP allowlist it is usually ip_not_allowed: the address your network presented is not on the token's allowlist, or your network used IPv6 and only IPv4 was listed. List your public egress addresses, both families.
I get 401 certificate_mismatch. Your token was issued with a client certificate, and the request did not present that certificate. Check that your client is loading the client.crt and client.key issued for this token. During a rotation overlap the old token works only with the old certificate and the new token only with the new one, so switch both together.
Can one token read data for several companies? No. A token is bound to a single company, and there is no company_id parameter. The one field that names other organisations' sub-domains is a compromised computer's subdomains (see Data rules).
Do tokens expire? Yes, at most 365 days after issue. GET /api/v1/health shows expires_at. Plan the renewal about a month ahead, and the 14-day overlap at rotation lets you switch without downtime. If your token was issued with a client certificate, that certificate also expires after one year: generate a new key and CSR and send the CSR with the renewal request, and the new token and the new certificate arrive together.
Is 60 requests per minute enough? For a scheduled pull, yes. The Vendor Risk Management export is one response, so it needs one request. A full pull of a module is one request per page: 100,000 records at 500 per page is 200 requests. Counts need no pages — ask the summary — and on Compromised Users, Compromised Computers and SLA Violations changed_since pulls only what changed. Fetch on a cadence instead of polling in a loop, and keep to 2 requests in flight.
Can I sort a list? Yes, by one key: sort=-severity, sort=first_seen, sort=-last_seen or sort=id where the module offers it (sort_keys in /capabilities; see Sorting). Keep the same sort for every page of a walk.
How do I know I have every page? When meta.next_cursor is null. A page shorter than page_size does not mean the walk is over (see Record lists).
Can I pull only what changed since my last sync? On Compromised Users, Compromised Computers and SLA Violations, yes: changed_since (see Changes since your last pull). The other modules have changes that move no timestamp, so the API refuses changed_since there rather than return an incomplete answer: pull those lists whole, or compare their summaries.
A record id I stored now answers 404. Why? The record is no longer one of your listed records: it was deleted, it left your scope, or — on Web Applications and Data Breaches — ShadowMap now lists it under another id (see Data rules). A full pull shows the current set.
Why does a summary count differ from the records I pulled? Read both close together and with the same filters. The differences that remain are deliberate and listed under Totals reconcile with the list.
Can I write or modify data through the API? No. The API is read-only.
What data is available today? A health check, a capabilities listing, the full Vendor Risk Management export (JSON or XML), your asset inventory and scan sessions, and the records, details and counts of 17 modules (see Module records). More modules are added over time; /capabilities lists what exists.
Does the API support XML? The Vendor Risk Management export does: append ?format=xml or send Accept: application/xml. Every other endpoint returns JSON.
A key or token may have been exposed. What do I do? Revoke the token on the API Access page at once, then create a replacement. If a client certificate key may be exposed too, revoking the token makes it useless; contact ShadowMap if you need help.
Related
- openapi.yaml — the OpenAPI 3.0.3 contract for this API.
- Vendor Risk Management — the dashboard view of the same vendor scoring data the API exports.
- SLA Policies and SLA Violations — how the policies the API lists match findings and escalate, and the violations they raise.
- Custom Tags — your own tag keys and values, returned in
custom_tagsand counted bycustom_tag:<key>on Alerts. - Security Rating Algorithm — how the numeric scores and
A–Fletter grades returned by the API are calculated. - Exports — manual JSON/CSV/Excel/PDF exports from within the dashboard, an alternative to the API for one-off pulls.
- Integrations — outbound integrations (SIEM, ticketing, chat) that push ShadowMap findings to other systems.
- Data Retention — how long the underlying data the API serves is retained.
- Contact Support — request API access, token rotation and allowlist changes.