# ndxer API > ndxer is a DNS data service that answers in both directions. Its HTTPS API answers questions DNS itself cannot: which names point at this IP, which domains use this name server or mail host, which TXT records contain this string, what lives under this domain, and which other domains share signals with this one. Every answer describes records ndxer has observed. It is read-only JSON over GET. Authentication is a bearer API key. This file is written for language models and for developers who want everything on one page. The machine-readable contract is the OpenAPI 3 document at https://api.ndxer.com/v1/openapi.json. Where this file and that document disagree, the OpenAPI document wins. ## Quick facts - Base URL: `https://api.ndxer.com/v1/`. Every endpoint lives under `/v1/` and keeps its trailing slash, e.g. `https://api.ndxer.com/v1/dns/`. Always send the trailing slash. - Method: `GET` only. Parameters go in the query string. There are no request bodies. - Auth: header `Authorization: Bearer `. Keys look like `ndx_live_<8-char-id>_<43-char-secret>`. The account owner creates keys at https://ndxer.com/app/api-keys/ (sign-in required); a key is shown once, when it is created. Never put a key in a URL, a chat or source control. Read it from an environment variable such as `NDXER_API_KEY`. - Responses: `application/json`, UTF-8. Timestamps are ISO 8601 date-times. - Versioning: `/v1/` only gains fields. Fields are not removed or renamed within `/v1/`. Clients must ignore fields they do not know. - Billing: each request costs units, normally 1. The cost of every successful response is in `meta.units_charged`, with one exception on `/v1/dns/` described in its section: a name not observed yet costs 1 unit in total, shown on the request that is charged (including one that waits and then answers 200), and only repeats of a 202 show 0. An empty answer still costs its unit. A request that fails with an error costs nothing. - Pagination: cursor based. Follow `meta.next_cursor` until it is `null`. No total count is computed on the search endpoints. ## Plans and limits | Plan | Units / month | Max rows per page | Requests / minute | New-name lookups / day | API keys | |------|--------------:|------------------:|------------------:|-----------------------:|---------:| | Free | 250 | 25 | 10 | 10 | 1 | | ndxer Developer | 5,000 | 250 | 60 | 100 | 3 | | ndxer Team | 25,000 | 1,000 | 300 | 500 | 10 | | ndxer Business | 150,000 | 1,000 | 1,000 | 2,000 | 50 | - Every lookup is available on every plan, in every mode, with paging. Plans differ in volume, not in features. - `limit` defaults to the plan's rows per page. Larger values are clamped to it. A `limit` below 1, or one that is not an integer, is 400 `invalid_input`. - "New-name lookups" are `/v1/dns/` requests for a name ndxer has not observed yet (see the 202 answer below). - `GET /v1/me/` reports the plan (`plan_key`, one of `free`, `developer`, `team`, `business`) and the units used and left in this period. - Requests without a valid key are rate limited too. - Prices: https://ndxer.com/pricing/ ## Units (metering) | Endpoint | Cost | |----------|------| | `/v1/dns/` | 1 unit per request. A name not observed yet costs 1 unit in total: the request that finds the miss shows its 1 unit, even when it waits and then answers 200; only repeats of a 202 are free (`meta.units_charged` 0) | | `/v1/reverse-ip/` | 1 unit per page | | `/v1/txt-search/` | 1 unit per page | | `/v1/value-search/` | 1 unit per page | | `/v1/cidr/` | 1 unit per page | | `/v1/hostnames/` | 1 unit per page. The first page also reports the exact number of hostnames, so what the rest of the list costs is known before you page (see below) | | `/v1/find-domains/` | 1 unit per request; the first request returns the first page of both lists for 1 unit | | `/v1/related/` | first page: 1 unit + 1 per shared signal that was scored, at most 8 units. Each further page: 1 unit | | `/v1/related/expand/` | 1 unit per request, whatever the status | | `/v1/me/` | free | | `/v1/investigations/` and `/v1/investigations/{investigation_id}/` | free (0 units); the rate limit still applies | | `/v1/openapi.json` | free, no key needed | Paging a long `/v1/hostnames/` list is the one place cost is worth planning. Every `/v1/hostnames/` page reports `meta.total_or_estimated_count` (the exact number of distinct hostnames under the domain, counted once on the first page; `meta.count_is_estimate` is `false`), `meta.units_to_complete` (what the pages after this one cost) and `meta.units_remaining` (what is left this period after this page). A client should stop when `meta.units_to_complete` is greater than `meta.units_remaining`, or page on knowing it will not finish. A page refused for quota says in its message that it needs 1 unit and how many are left. Always read `meta.units_charged` rather than assuming a cost. When the units included in the period are used up, requests fail with 403 `quota_exceeded`, and the message names the date the period ends. Because `/v1/related/` can cost up to 8 units, an account can end a period a few units over its allowance; the next request is then refused. ## Errors Every error has the same JSON body: ```json {"error": {"code": "invalid_input", "message": "'limit' must be an integer.", "request_id": "..."}} ``` Branch on `error.code`, never on `message`. Include `request_id` when you report a problem. | HTTP | `code` | Meaning | What a client should do | |-----:|--------|---------|-------------------------| | 400 | `invalid_input` | A parameter is missing or malformed: a bad IP or CIDR, a range wider than /16 (IPv4) or /32 (IPv6), a non-integer or zero `limit`, a TXT text shorter than 3 characters, an unknown `class` or `type` filter. | Fix the request. Do not retry it unchanged. | | 400 | `invalid_query` | The value is not valid for this kind of lookup. | Fix the request. | | 400 | `invalid_cursor` | The cursor belongs to another query, has expired, or the data or ranking behind it changed since it was issued. | Start again from page one without `cursor`. | | 401 | `unauthenticated` | No `Authorization` header, or an unknown or revoked key. | Check `Authorization: Bearer ` and that the key is still active. | | 403 | `forbidden` | The account's billing state blocks requests, for example an unpaid subscription. | Stop. The account owner fixes billing in the web app. | | 403 | `suspended` | The account is not active. | Stop. Write to contact@ndxer.com. | | 403 | `email_unverified` | The account's email address has not been verified. | Stop. The account owner verifies the email in the web app. | | 403 | `quota_exceeded` | The units included for this billing period are used up. The message names the date the period ends. | Stop until the period ends, or upgrade the plan. | | 404 | `not_found` | The path does not exist, e.g. a typo in the endpoint name. An empty answer is never a 404: it is a 200 with no rows. | Fix the URL. | | 404 | `unsupported` | This lookup type is not supported. | Do not retry. | | 429 | `rate_limited` | Too many requests in the last minute for this plan (or, without a valid key, for this client). | Wait the seconds in `Retry-After`, then retry. | | 502 | `contract` | The lookup returned a response the API could not read. | Retry later with backoff. Report the `request_id` if it persists. | | 502 | `query_error` | The lookup could not be completed. | Retry later with backoff. Report the `request_id` if it persists. | | 503 | `unavailable` | The lookup is temporarily unavailable. | Retry with exponential backoff. | | 503 | `not_available` | This lookup is not available right now. | Retry later with exponential backoff. | | 504 | `timeout` | The lookup did not complete in time. | Retry with backoff, up to about 5 attempts. On a search endpoint, narrow the query. | Other standard HTTP errors can occur, such as 405 `method_not_allowed` for anything but GET. Handle 429 by honouring `Retry-After`, and 502, 503 and 504 with exponential backoff and jitter, up to about 5 attempts. Never retry another 4xx. ## The `meta` object Every successful response has a `meta` object (schema `ResultMeta`): | Field | Type | Meaning | |-------|------|---------| | `query` | string | The query as ndxer understood it | | `query_type` | string | Which lookup answered it | | `returned_count` | integer | Rows in this response | | `truncated` | boolean | `true` when more rows exist beyond this page | | `next_cursor` | string or null | Pass it as `cursor` to get the next page. `null` means this is the last page | | `total_or_estimated_count` | integer or null | Total rows if known. `null` on the search endpoints, where no total is computed, except `/v1/hostnames/`, which gives the exact number of distinct hostnames on every page | | `count_is_estimate` | boolean | When a total is given, `true` means it is an estimate. Show it as "~N", not "N" | | `last_checked` | date-time or null | When the data behind this answer was last checked | | `provider_context` | object or null | `{shared, provider, provider_name, note}`. `shared` is `true` when the queried address or host belongs to shared infrastructure such as a CDN, so names on it are not necessarily related to one another. `provider` is a permanent slug like `cloudflare` (or null), `provider_name` its display name, and `note` a short English sentence saying the same thing as `shared` | | `request_id` | string or null | Correlation id, also present in errors | | `elapsed_ms` | integer or null | Server-side time | | `status` | string or null | `pending` on a 202 from `/v1/dns/`; otherwise null | | `mail_posture` | object or null | Facts about the domain's mail setup, filled on `/v1/dns/` when the name is a registrable domain (see below); null elsewhere | | `units_charged` | integer | Units this request cost | | `units_to_complete` | integer or null | `/v1/hostnames/` only: the units the pages after this one cost, `ceil((total - hostnames served so far) / page size)`, `0` on the last page. null elsewhere | | `units_remaining` | integer or null | `/v1/hostnames/` only: the units left in this period after this page was charged. null elsewhere, and null on a plan with no unit limit | ## Pagination Every list endpoint pages the same way: 1. Call without `cursor`. Optionally pass `limit`. 2. If `meta.next_cursor` is not null, call again with the same parameters plus `cursor=`. 3. Stop when `meta.next_cursor` is `null`. `/v1/find-domains/` is the exception: it has two lists with a cursor each (see its section). A cursor is opaque. Do not parse or build one. It is bound to the exact query that produced it, and any other query rejects it with 400 `invalid_cursor`. On `/v1/related/` a cursor is also bound to the signal counts and the scoring rules the first page was ranked with, and on `/v1/related/expand/` to the number of domains publishing the value; the signal index behind both is rebuilt periodically, so an old cursor can be rejected. On `invalid_cursor`, restart from page one. Each page costs units, so cap how many pages your program fetches. --- # Endpoints ## GET /v1/dns/ -- current DNS records for a name Parameters: - `name` (required): a hostname or domain, e.g. `douban.com` or `www.douban.com`. Response `DomainLookupResult`: - `records`: array of `DnsRecord`: - `type` (A, AAAA, CNAME, MX, NS, TXT, SOA, CAA, ...), `name`, `value` - `ttl` (integer or null), `priority` (integer or null; the MX preference) - `last_observed` (date-time or null) - `countries`: array of ISO 3166-1 alpha-2 codes the record was observed from; may be empty - `apex`: the registrable domain the name sits under. `null` when no record came back. - `meta.mail_posture` (`MailPosture`): filled only when `name` is itself the registrable domain (`douban.com`, not `www.douban.com`); null otherwise. These are facts, not a grade: - `spf_present` (bool), `spf_all` (the SPF `all` qualifier as published), `spf_lookups` (integer, or null when the record was not parsed) - `dmarc_present` (bool), `dmarc_policy` (string), `dmarc_pct` (string; `""` when no `pct=` tag is published) - `dkim_selectors` (array of strings), `mta_sts` (bool), `tls_rpt` (bool) - `caa`: array of `{tag, value}` - `routes`: array of `{host, preference}` (MX hosts; `preference` may be null) **202 Accepted.** A name ndxer has not observed yet can take a while: the request may stay open for up to 60 seconds; set your HTTP client's timeout above that (e.g. 90 seconds). If the answer is not ready by then, the response is 202 with the same body shape: `meta.status` is `"pending"`, `records` is empty, and the `Retry-After` header gives the seconds to wait (currently 2). Repeat the same request after that delay until you get 200. Repeating it re-attaches to the same lookup; it does not start a new one. A name ndxer has not observed yet costs 1 unit in total: the first request is charged and shows 1 in `meta.units_charged`, whether it answers 202 or waits and then answers 200; repeating it while the name is still pending is free (`meta.units_charged` is 0 on those repeats). The free repeats end with the lookup, so still cap the number of repeats (e.g. 5) and space them out. New-name lookups count against the plan's daily limit; once that is used up, a never-observed name answers 200 with empty `records`. A 200 with empty `records` is a real answer: no records observed. ```bash curl -s -H "Authorization: Bearer $NDXER_API_KEY" \ "https://api.ndxer.com/v1/dns/?name=douban.com" ``` ## GET /v1/reverse-ip/ -- hostnames observed on an IP Hostnames whose forward A/AAAA records were observed resolving to the address. This is built from forward DNS. It does not use PTR records. Parameters: `ip` (required, IPv4 or IPv6), `cursor`, `limit`. Response `ReverseIpResult`: `matches`: array of `ReverseIpMatch`: `hostname`, `apex` (or null), `last_observed` (or null), `context` (string or null). Check `meta.provider_context.shared` first. When it is `true`, the address belongs to shared infrastructure (a CDN, a cloud, shared hosting), and the names on it are co-tenants, not related to each other. ```bash curl -s -H "Authorization: Bearer $NDXER_API_KEY" \ "https://api.ndxer.com/v1/reverse-ip/?ip=110.242.68.66&limit=100" ``` ## GET /v1/txt-search/ -- names whose TXT records contain text Parameters: - `q` (required): text to find in a TXT record. By default a case-insensitive substring of at least 3 characters, e.g. `google-site-verification=` or `docusign`. - `regex` (optional): `true`, `1` or `yes` (any case) treats `q` as an RE2 regular expression of at most 200 characters. Any other value, including `false` and `0`, means off. Regex mode is case-SENSITIVE; start the pattern with `(?i)` for a case-insensitive match. The 3-character minimum applies only to substring mode. - `cursor`, `limit`. Response `NameSearchResult`: `matches`: array of `NameMatch`: `name`, `value` (the matching record text), `type`, `apex` (or null), `first_observed` (always null here; only `/v1/hostnames/` fills it), `last_observed` (date-time or null). ## GET /v1/value-search/ -- names whose NS / MX / CNAME / SOA / SPF points at a host Answers the inverse question, for example "which domains use this name server?" or "which domains send mail through this host?". Parameters: - `rtype` (required): one of `NS`, `MX`, `CNAME`, `SOA`, `SPF`. `SPF` is not a record type but a use of TXT: it reads the `include:` and `redirect=` targets of SPF records. - `q` (required): a host, e.g. `ns1.douban.com`, or a suffix, e.g. `douban.com`, which matches that host and every host under it. - `cursor`, `limit`. Response: `NameSearchResult` (as for txt-search, `first_observed` is null). `value` is the target that matched and `type` is the record type. ```bash curl -s -H "Authorization: Bearer $NDXER_API_KEY" \ "https://api.ndxer.com/v1/value-search/?rtype=NS&q=ns1.douban.com" ``` ## GET /v1/cidr/ -- names observed on any address in a range Parameters: - `q` (required): a CIDR range such as `110.242.68.0/24`, or a single address. At most a /16 for IPv4 and a /32 for IPv6. Wider ranges are 400 `invalid_input`; to cover one, split it into /16 (or /32) blocks and query each. - `cursor`, `limit`. Response: `NameSearchResult` (`first_observed` is null). `value` is the address and `type` is A or AAAA. ## GET /v1/hostnames/ -- every hostname under a domain, from every position One call instead of a name list plus `/v1/value-search/` for each of `CNAME`, `MX`, `NS`, `SOA` and `SPF`, without their duplicate rows: every hostname observed under the domain, whether it has DNS records of its own or only appears as a target, listed once. The web app's All names tab lists the same hostnames in its own group. Parameters: - `q` (required): a domain, e.g. `douban.com`. The result includes the domain itself. A public suffix such as `com.cn` returns none. - `cursor`, `limit`. A page is `limit` distinct hostnames. On every page `meta.total_or_estimated_count` is the exact number of distinct hostnames under `q` (0 for a public suffix), `meta.units_to_complete` the units the remaining pages cost and `meta.units_remaining` the units you have left after this page. The count is taken once, on the first page, and travels inside the cursor; a domain that gains hostnames while you page does not change it. Response `HostnameSearchResult`: `matches`: array of `HostnameMatch`: the `NameMatch` fields plus `seen_as`. - `name`: the hostname. `value` and `type` are empty strings; `apex` is the registrable domain the name sits under, or null for a host that only other records point at. - `first_observed`, `last_observed`: date-times or null, over every position the hostname was seen in. - `seen_as`: where the hostname was observed, each at most once, in this order: `owner` (it has DNS records of its own), `cname_target` (a CNAME points at it), `mx` (an MX record names it as a mail host), `ns` (an NS record names it as a name server), `soa` (an SOA record names it as the primary name server), `spf` (an SPF record includes or redirects to it). Ignore values you do not know. ```bash curl -s -H "Authorization: Bearer $NDXER_API_KEY" \ "https://api.ndxer.com/v1/hostnames/?q=douban.com&limit=250" ``` ## GET /v1/find-domains/ -- a domain's own names plus same-name domains elsewhere Two lists, paged separately: - `own_names`: names observed under the domain (`HostnameMatch` rows: the `NameMatch` fields plus `seen_as`, the positions the name was observed in, exactly as `/v1/hostnames/` returns them). The web app's All names table shows the same Seen as column. - `same_name_domains`: other registrable domains with the same first label, e.g. `douban.com` and `douban.net`. Each is a `FindDomainCandidate`: `apex`, `source` (`same_name`), `relation` (`same_label`), `match_class` (always `review`). These are leads to review, not proof of ownership. Parameters: - `q` (required): a domain name. - `limit`: maximum rows in EACH list, so the first response can hold up to twice `limit` rows. - `source` + `cursor`: omit both on the first request. To continue one list, pass `source=own` with `cursor=`, or `source=same_name` with `cursor=`. Sending only one of the two is 400 `invalid_cursor`. Response `FindDomainsResult`: `meta`, `own_names`, `same_name_domains`, `own_next_cursor`, `own_truncated`, `own_status`, `own_error_code`, `same_name_next_cursor`, `same_name_truncated`, `same_name_status`, `same_name_error_code`. - Pagination uses the two per-list cursors, not `meta.next_cursor`. No total is computed. - `*_status` is `available`, `not_requested` (that list was not asked for on this continuation), `unavailable` (that list failed; `*_error_code` says why), and for the same-name list also `not_indexed` (no same-name data for this domain) or `limited` (the label is shared by too many domains to list). - One list can fail while the other succeeds; `*_error_code` is null when that list is fine. ## GET /v1/related/ -- domains that share signals with a seed Finds other domains that publish the same name servers, mail hosts, SOA contacts, SPF/DMARC/DKIM details, verification tokens, CAA contacts or dedicated IP addresses as the seed. Matches are ranked by how rarely each shared signal is shared. Every match carries the evidence behind its rank. A shared signal is evidence of a relationship, not proof of common ownership. Parameters: - `q` (required): the seed, in one of three forms: - a domain, e.g. `douban.com`, folded to its registrable apex; - a mailbox, e.g. `hostmaster@douban.com`, matched against DMARC report addresses, SOA contacts and CAA contacts; - an IP address, matched against addresses dedicated to one domain. - `cursor`, `limit`. Response `RelatedResult`: - `seed` (`RelatedSeed`): - `kind`; `value`: the resolved seed (the apex for a domain), which may differ from `q` - `signals`: array of `SeedSignal`, every signal the seed publishes: - `kind` (see the signal kinds below), `key` (the value), `n_apexes` (how many domains publish it) - `status`, one of: - `used`: scored - `common`: shared by too many domains to mean anything; scores zero - `unique`: no other domain publishes it; scores zero - `shared`: a named provider's infrastructure; scores zero and cannot be expanded, however few domains publish it - `provider` (permanent slug), `provider_name`, `category`, `description`: set when a provider is named, null otherwise. On a `shared` signal the provider is whose infrastructure it is. A dedicated address in a provider's one-tenant range is named the same way, without a `category`, and still counts. `category` is one of `registrar_parking`, `for_sale_marketplace`, `dns_host`, `mail_provider`, `mail_security_service`, `cdn`, `cloud`, `site_builder`, `placeholder`, or null. A signal with no `provider` is not therefore unshared. - `class`: one of `subject`, `review`, `infrastructure` - `reasons`: array of `{code, text}` - `note`: deprecated, always empty; read `description` - `shared_provider`, `shared_provider_name`: set only when the seed is an IP inside a shared provider range. Then `matches` is empty by design, because co-tenants are not relatives. Do not retry. - `matches`: array of `RelatedMatch`, ordered by score, then domain: - `apex` - `score`: an integer for ordering within this response only. It is not a percentage and is not comparable across responses - `band`: `high`, `medium` or `low`. Use this for thresholds - `kinds`: signal kinds shared with the seed - `evidence`: array of `RelatedEvidence` `{kind, key, n_apexes, provider, provider_name, category, description, note}` - `class`: one of `subject`, `review` -- `subject` (proposed as the seed owner's own) or `review` (associated, undecided) - `reasons`: array of `{code, text}`, most decisive first Signal kinds (`kind`): `verify_token`, `dkim_key`, `dmarc_rua`, `dmarc_ruf`, `soa_rname`, `mx_set`, `ns_set`, `spf_redirect`, `caa_iodef`, `spf_ip`, `soa_mname`, `spf_include`, `mx_host`, `ip_dedicated`, `ns_host`, `name_label`. `name_label` is the same first label, e.g. `douban.com` and `douban.net`. It scores zero, and a match that shares only a label shorter than 5 characters is not returned. Reason codes (`reasons[].code`): `owned_value`, `account_secret`, `same_name`, `same_name_only`, `unique_value`, `shared_value`, `provider`, `shared_by_many`. New codes may be added. Show `text` for any code you do not recognise. How a match becomes `subject`: it shares a value under the seed's own name, a verification token, or a DKIM key published by at most 50 domains (these count at full weight), or it shares the seed's first label alongside another shared value. Everything else is `review`. A seed signal can also be `infrastructure`. A class is a proposal, not proof. Cost: first page 1 unit + 1 per scored signal key, at most 8 units. Each further page costs 1 unit. Read `meta.units_charged`. ```bash curl -s -H "Authorization: Bearer $NDXER_API_KEY" \ "https://api.ndxer.com/v1/related/?q=douban.com&limit=50" ``` ## GET /v1/related/expand/ -- every domain that publishes one shared value Asks the related question from the signal's side: given one `kind` + `key` from a `/v1/related/` response, list the domains that publish it. Parameters: - `kind` (required): the signal kind, copied from `seed.signals[].kind` or `matches[].evidence[].kind`. - `key` (required): the value, copied exactly from `seed.signals[].key` or `evidence[].key`. It is matched verbatim, so send it back unchanged (URL-encoded). - `cursor`, `limit`. Response `RelatedExpansion`: `meta`, `kind`, `key`, `n_apexes` (how many domains publish the value), `status`, `apexes` (array of domain strings, ordered by name; no score). `status` is `used` when `apexes` lists the domains. When `apexes` is empty, `status` says why. All of these are HTTP 200 and cost 1 unit: - `common`: too many domains publish it to be evidence, so no list is returned. - `shared`: a named provider runs it, so its users are that provider's customers, not relatives. - `unique`: only one domain publishes it. - `unknown`: no observed domain publishes it. ## GET /v1/investigations/ -- your saved investigations Saved investigations are built and triaged in the ndxer web app (https://ndxer.com/app/investigations/): start one from a domain, a mailbox or an IP address, explore the connections ndxer finds, and mark each item Subject (it belongs to whoever you are investigating), To review, Infrastructure, or dismiss it. These two endpoints READ what was saved there; they never create, expand or change an investigation and run no lookup. Whatever the user marked Subject in the web app comes back with `class: "subject"`. Free (0 units, `meta.units_charged` is 0); the plan's requests per minute still apply. Only the calling account's investigations are visible. Parameters: - `archived` (optional): `true` lists the archived investigations instead of the active ones. - `cursor`, `limit` (rows per page, lowered to the plan's maximum). Response `InvestigationList`: `meta` (`returned_count`, `truncated`, `next_cursor`, `request_id`, `units_charged`) and `investigations`, most recently changed first: array of `InvestigationSummary` `{id, name, description, seed, seed_kind, archived, created, updated, last_refreshed, counts}`. `seed_kind` is `domain`, `email` or `ip`; `counts` is `{subject, review, infrastructure, dismissed}` for the whole investigation. An investigation changed while you page moves to the top and is not repeated in that walk; start again from the first page to see it. ## GET /v1/investigations/{investigation_id}/ -- one investigation and its items `investigation_id` is the UUID in the investigation's web address, `/app/investigations//`, also shown on its page under "Use from code". Any id that is not the calling account's, including another account's, is 404 `not_found`. Archived investigations are readable too. Parameters: - `class` (optional): only items in these classes: `subject`, `review`, `infrastructure`, `dismissed`. A comma list (`class=subject,review`) or repeated (`class=subject&class=review`). An unknown value is 400 `invalid_input`, never an empty answer. - `type` (optional): only items of these types: `domain`, `subdomain`, `address`, `value`. Comma list or repeated, like `class`; unknown values are 400. - `cursor`, `limit` (lowered to the plan's rows per page). A cursor is bound to the account, the investigation and the filters. Response `InvestigationDetail`: `meta` (as above), `investigation` (an `InvestigationSummary`; its `counts` always describe the whole investigation, whatever the filters) and `items`, ordered by class, then type, then value -- the same rows as the web app's JSON export. Each `InvestigationItem`: - `item` (the domain, name, address or shared value), `type`, `value_kind` (for a `value` item, which signal kind, e.g. `ns_host`; empty otherwise) - `class` (where it sits now), `proposed_class` (what ndxer proposed: `subject`, `review` or `infrastructure`), `set_by` (`you` when the user moved it, `system` while it sits where ndxer proposed it) - `reasons` (English sentences) and `reason_codes` (stable codes, same order; new codes may be added) - `score` (for a matched domain, comparable only within the lookup that found it; otherwise null) - `shared`: array of `{kind, key, n_apexes}`, the shared values it was found on, as found - `found_via`: array of `{type, value_kind, value}`, the items whose exploration brought it in - `evidence_status` (`present`, or `gone` when the latest refresh did not find it again; a gone item keeps its class), `first_found`, `last_confirmed` Every domain and name the user classed subject: `?class=subject&type=domain,subdomain`. Follow `meta.next_cursor` until it is null. ```bash curl -s -H "Authorization: Bearer $NDXER_API_KEY" \ "https://api.ndxer.com/v1/investigations/$INVESTIGATION_ID/?class=subject&type=domain,subdomain" ``` ## GET /v1/me/ -- your account, plan and usage Free. Response `Me`: `account_id`, `email`, `plan_key` (one of `free`, `developer`, `team`, `business`), `account_status`, `key_prefix` (the non-secret start of the key in use), and `usage`: `{period_start, period_end, used, included, remaining}` (`included` and `remaining` are null only on a plan without a cap). Call it at startup to check the key and the remaining budget before a large job. ## GET /v1/openapi.json -- the machine-readable contract OpenAPI 3 JSON at https://api.ndxer.com/v1/openapi.json. No key needed. Use it for the exact field types, or to generate a typed client with a third-party generator such as `openapi-python-client` or `openapi-typescript`. --- # Recipes ## A minimal Python client (standard library only) ```python import json, os, random, time, urllib.error, urllib.parse, urllib.request BASE = "https://api.ndxer.com/v1" KEY = os.environ["NDXER_API_KEY"] # never hard-code the key class NdxerError(Exception): def __init__(self, status, code, message, request_id): super().__init__(f"{status} {code}: {message} (request_id={request_id})") self.status, self.code, self.request_id = status, code, request_id def get(path, **params): """One GET with retries on 429/5xx and repeats on 202. Returns the decoded JSON.""" params = {k: v for k, v in params.items() if v is not None} url = f"{BASE}/{path.strip('/')}/?{urllib.parse.urlencode(params)}" for attempt in range(6): req = urllib.request.Request(url, headers={"Authorization": f"Bearer {KEY}"}) try: # /v1/dns/ can hold a request open while it looks up a new name. with urllib.request.urlopen(req, timeout=90) as resp: body = json.load(resp) if resp.status == 202: # /v1/dns/ on a never-observed name; repeats of a pending name are free time.sleep(int(resp.headers.get("Retry-After", "5"))) continue return body except urllib.error.HTTPError as e: try: err = json.load(e).get("error", {}) except ValueError: err = {} if e.code == 429: time.sleep(int(e.headers.get("Retry-After", "5"))) continue if e.code in (502, 503, 504) and attempt < 5: time.sleep(min(30, 2 ** attempt) + random.random()) continue raise NdxerError(e.code, err.get("code"), err.get("message"), err.get("request_id")) raise RuntimeError(f"gave up on {url}") def pages(path, max_pages=10, **params): """Yield every page of a cursor-paged endpoint, up to max_pages (each page costs units).""" cursor = None for _ in range(max_pages): page = get(path, cursor=cursor, **params) yield page cursor = page["meta"]["next_cursor"] if cursor is None: return # Which names use douban.com's name server? for page in pages("value-search", rtype="NS", q="ns1.douban.com", limit=250): for m in page["matches"]: print(m["name"], m["apex"]) ``` ## Common tasks and the endpoint for each | Task | Call | |------|------| | What records does a domain have now? | `/v1/dns/?name=` | | Mail security facts for a domain (SPF, DMARC, DKIM, MTA-STS, MX) | `/v1/dns/?name=`, then read `meta.mail_posture` | | What else is hosted on this IP? | `/v1/reverse-ip/?ip=` (check `meta.provider_context.shared` first) | | What is hosted in this netblock? | `/v1/cidr/?q=` (split anything wider than /16 into /16 blocks) | | Who else uses this name server or mail host? | `/v1/value-search/?rtype=NS` or `rtype=MX`, with `q=` | | Who includes this SPF record? | `/v1/value-search/?rtype=SPF&q=spf.mail.qq.com` | | Who has this verification token or TXT string? | `/v1/txt-search/?q=` | | All hosts under a domain (subdomains), including name servers, mail hosts and CNAME targets, each once | `/v1/hostnames/?q=` | | Find a brand's domains under other suffixes | `/v1/find-domains/?q=` and read `same_name_domains` | | Map an organisation's other domains | `/v1/related/?q=`, keep `class == "subject"` or `band == "high"`, then `/v1/related/expand/` on interesting `used` signals | | Everything tied to a contact mailbox | `/v1/related/?q=hostmaster@douban.com` | | Pull every subject domain from investigation | `/v1/investigations//?class=subject&type=domain,subdomain`, following `meta.next_cursor` (free) | | How much budget is left? | `/v1/me/` | ## Rules a program should follow - Read the key from the environment (`NDXER_API_KEY`). Never hard-code it, log it, or ask the user to paste it into a chat. - Respect the plan's requests per minute. For bulk work, a few concurrent requests with client-side rate limiting beat bursting into 429s. - Cap pages and units per execution. Log `meta.units_charged` and sum it. - Treat `/v1/related/` results as leads. `class` and `band` are proposals; show the `evidence` and `reasons` alongside any conclusion. - Do not expand or chase signals whose `status` is `common` or `shared`, or IPs where `provider_context.shared` is true. They connect unrelated customers of the same provider. - Ignore unknown JSON fields. Fall back to `reasons[].text` for unknown reason codes. - On `invalid_cursor`, restart pagination from page one instead of failing.