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:

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.