API reference
subpoof exposes a versioned JSON API at https://api.subpoof.com/v1. Every endpoint authenticates with an API key. Errors flow through a single envelope. Pagination is consistent across list endpoints.
Authentication
Send your API key in the X-API-Key header:
GET /v1/registrations?tld=.com
Host: api.subpoof.com
X-API-Key: YOUR_KEY
Your API key lives in the dashboard's Settings page. Treat it as a secret. Rotate it from the same page if it leaks.
Errors
Every error returns the same envelope:
{
"error": {
"code": "bad_request",
"message": "tld: must start with a dot",
"request_id": "req_4d3e9c8a"
}
}
The request_id is also returned as the X-Request-ID response header. Include it when reporting issues.
| HTTP status | code |
When it appears |
|---|---|---|
| 400 | bad_request |
Validation failure (missing field, wrong type, malformed value) |
| 401 | unauthenticated |
Missing or invalid X-API-Key |
| 403 | forbidden |
Account is not active |
| 404 | not_found |
The requested resource does not exist |
| 409 | conflict |
The resource is not in a state that allows the request (e.g. fetching results for a scan that has not completed) |
| 429 | rate_limited |
Monthly query quota exhausted. See Rate limits. |
| 500 | internal_error |
Server-side fault. Safe to retry with backoff. |
Pagination
List endpoints accept page (1-indexed, default 1) and page_size (default 25, max 100). Rows come back under data, with a pagination object alongside:
{
"data": [ ... ],
"pagination": {
"page": 1,
"page_size": 25,
"total": 1247,
"total_pages": 50,
"has_next": true,
"has_prev": false
}
}
page past the last page is clamped to the last page rather than returning an error.
Endpoints
Registrations: GET /v1/registrations
Newly-registered domains from the CZDS daily-diff feed.
Coverage: this feed is sourced from ICANN CZDS generic-TLD (gTLD) zone files
(e.g. .com, .org, .xyz, .info, .app). Country-code TLDs (ccTLDs such
as .io, .co, .ai) are not published through CZDS and are not included, so
filtering to a ccTLD returns no results.
Query parameters:
| Name | Type | Description |
|---|---|---|
q |
string | Prefix match on the domain, e.g. q=paypal matches paypal-login.com (case-insensitive; matches the start of the domain, not anywhere within it) |
tld |
string | Filter to a single gTLD (e.g. com or .com) |
date_from |
YYYY-MM-DD | Inclusive lower bound on first-seen date |
date_to |
YYYY-MM-DD | Inclusive upper bound on first-seen date |
page |
int | Page (default 1) |
page_size |
int | Page size (default 25, max 100) |
Example:
curl "https://api.subpoof.com/v1/registrations?tld=.com&date_from=2026-04-22&date_to=2026-04-22" \
-H "X-API-Key: YOUR_KEY"
Dictionary: GET /v1/dictionary
Subdomain labels from your account's dictionary, ranked by how widely each appears.
| Name | Type | Description |
|---|---|---|
q |
string | Case-insensitive substring match on the label |
domain_filter |
string | Filter to labels seen on a specific root domain |
category |
string | Filter by AD/O365 category tag |
monitored |
yes / no |
yes for pinned labels only, no to exclude them |
page |
int | Page (default 1) |
page_size |
int | Page size (default 25, max 100) |
Scans
Scans run asynchronously: you submit a job, poll its status, then fetch the results once it completes.
Submit a scan: POST /v1/scans
JSON body specifies the root domain and, optionally, the set of enrichments to run (omit enrichments to use the defaults). Returns 202 with a job_id:
{
"job_id": "663f1a2b9c4d5e6f7a8b9c0d",
"domain": "example.com",
"status": "pending",
"created_at": "2026-04-22T18:03:11",
"idempotent": false,
"enrichments": null
}
Re-submitting the same domain on the same calendar day returns the existing job (idempotent: true, HTTP 200) and does not consume an additional query.
Poll job status: GET /v1/scans/{job_id}
Returns the job's lifecycle status (pending, claimed, running, complete, error, or stopped). Once status is complete, the response includes subdomain_count, a signal_summary, and a results_url.
Fetch results: GET /v1/scans/{job_id}/results
Returns the analyzed output once the job is complete (a job that is not yet complete returns 409 conflict). Paginated. Pass category to fetch a single slice (e.g. tls_certs, lookalikes, ntlm).
List scans: GET /v1/scans
Returns your scan jobs, newest first. Filter with status and/or an exact domain. Paginated.
Domains: GET /v1/domains/{domain}/history
Per-domain scan timeline showing each scan and the diff (added/removed subdomains) against the prior one. Paginated.
The oldest scan in the timeline is returned as a baseline entry (baseline: true) with empty added/removed and a subdomain_count for the footprint, since there is no earlier scan to diff against. A domain with a single scan therefore returns one baseline row rather than an empty timeline.
Watches
Watches are standing monitors: the daily worker checks each one and records its status (and, for lookalike watches, any new typosquat hits). Watch management does not consume a query credit - the monthly quota governs intelligence queries, not your monitoring configuration. Watches are scoped to your org.
Watch types:
type |
What it monitors | target |
|---|---|---|
cert_expiry |
TLS certificate expiry for a host | the domain/host |
domain_expiry |
Domain registration expiry | the domain |
domain_changes |
Subdomain add/remove churn for a domain | the domain |
label |
A specific subdomain label | the label |
lookalike |
Newly-registered typosquats of a brand | the brand/domain |
dict_search |
A saved dictionary search (no target) |
derived from the filter |
List watches: GET /v1/watches
Returns your watches, newest first. Filter with type. Paginated.
Create a watch: POST /v1/watches
JSON body. target is required for every type except dict_search, which builds its target from at least one of q, domain_filter, category, monitored (the same filters as /v1/dictionary). threshold_days (1-365, default 30) sets how many days before expiry an expiry watch alerts. Returns 201 for a new watch, 200 if an identical watch already existed (creation is idempotent on type + target).
curl -X POST "https://api.subpoof.com/v1/watches" \
-H "X-API-Key: YOUR_KEY" -H "Content-Type: application/json" \
-d '{"type": "cert_expiry", "target": "example.com", "threshold_days": 14}'
Get a watch: GET /v1/watches/{watch_id}
Returns a single watch by id (scoped to your org).
Delete a watch: DELETE /v1/watches/{watch_id}
Removes the watch. Irreversible.
Webhooks
Each org has one outbound webhook. When an event fires, the service POSTs a signed JSON payload to your URL; the secret returned here is the HMAC key you verify the signature with. Like watches, webhook management does not consume a query credit.
Event types: scan.completed, watch.alert, domain.changes_detected, label.new_domain, dict_search.new_label, lookalike.registered. Omit event_types (or send an empty list) to receive all of them.
| Method / path | Purpose |
|---|---|
GET /v1/webhook |
Get the config (incl. signing secret); 404 if none configured |
PUT /v1/webhook |
Create or update (url, enabled, event_types); 201 new / 200 update |
DELETE /v1/webhook |
Delete and cancel pending deliveries |
POST /v1/webhook/rotate |
Rotate the signing secret |
POST /v1/webhook/test |
Queue a test event (needs an enabled webhook; 409 otherwise) |
GET /v1/webhook/deliveries |
List delivery attempts; filter by status |
POST /v1/webhook/deliveries/{id}/redeliver |
Requeue a dead delivery |
curl -X PUT "https://api.subpoof.com/v1/webhook" \
-H "X-API-Key: YOUR_KEY" -H "Content-Type: application/json" \
-d '{"url": "https://example.com/hooks/subpoof", "enabled": true,
"event_types": ["scan.completed", "lookalike.registered"]}'
Updating preserves the existing secret; use POST /v1/webhook/rotate to change it.
Schedules
Recurring scans. The scheduler runs each enabled schedule at its period and queues a scan automatically. Managing schedules does not consume a query credit; each scan a schedule spawns charges a credit when it runs, exactly like a manual scan.
| Method / path | Purpose |
|---|---|
GET /v1/schedules |
List your schedules |
POST /v1/schedules |
Create (domain, period: daily / weekly / monthly; optional inherit_from_job). Idempotent on domain + period (201 new / 200 update) |
GET /v1/schedules/{id} |
Get one |
DELETE /v1/schedules/{id} |
Delete |
POST /v1/schedules/{id}/toggle |
Pause / resume (returns the new enabled state) |
inherit_from_job copies the enrichment set from one of your existing scan jobs so the schedule re-runs the same configuration; omit it to use the worker defaults.
Account: GET /v1/account
Your plan and quota standing, so you can check headroom before spending it. Never charged.
{ "plan": "operator", "limit": 2000, "used": 137, "remaining": 1863,
"resets_on": "2026-07-01", "unlimited": false }
limit and remaining are -1 when the plan is unlimited (unlimited: true).
Re-running analysis on a scan
Both queue a lightweight job against an already-completed scan (no re-enumeration) and return a new job_id to poll. An in-flight re-run for the same scan is reused rather than duplicated.
| Method / path | Purpose |
|---|---|
POST /v1/scans/{job_id}/reanalyze |
Re-run identity + blocklist checks on the existing subdomain set |
POST /v1/scans/{job_id}/rbl-check |
Refresh DNSBL status for mail hosts only |
Pinning a dictionary label: POST /v1/dictionary/monitor
Pin or unpin a dictionary label for monitoring. Send {"label": "...", "monitored": true} to set it explicitly, or omit monitored to toggle. Not charged.
OpenAPI spec
The full machine-readable spec is at https://api.subpoof.com/v1/openapi.json. Generate clients with the openapi-generator of your choice.
Rate limits
The API is governed by a monthly query quota tied to your plan, not a per-second or per-minute rate. Each plan includes a fixed number of queries per calendar month (see Pricing for plan quotas); enterprise plans have no monthly quota.
What counts as one query:
- A list or results endpoint counts as one query when it returns at least one row. An empty result set is free.
POST /v1/scanscounts as one query per distinct domain per day. A same-day re-submit of a domain you already scanned is free.
Every successful response carries your current standing in headers:
| Header | Meaning |
|---|---|
X-RateLimit-Limit |
Your plan's monthly quota (-1 means unlimited) |
X-RateLimit-Remaining |
Queries left this month (-1 when unlimited) |
X-RateLimit-Reset |
Date the quota resets (the first of next month, YYYY-MM-DD) |
Once the quota is exhausted, further charged calls return 429 rate_limited until the reset date. There is no Retry-After header; the quota is monthly, so retry after X-RateLimit-Reset.
Versioning
v1 is stable. Breaking changes ship as v2 with at least 90 days' notice; the existing v1 keeps working through the deprecation window. Non-breaking additions (new optional fields, new endpoints) ship to v1 without a version bump.