Skip to main content
Docs

Quickstart

1. Create an API key

Create a key from your API keys page. The plaintext token is shown once, at creation -- copy it somewhere safe, it can't be viewed again.

2. Your first request

Every request is authenticated with a bearer token in the Authorization header.

bash
curl -H "Authorization: Bearer $NDXER_API_KEY" https://api.ndxer.com/v1/me/
Sample response
json
{
  "account_id": "42",
  "email": "you@ndxer.com",
  "plan_key": "free",
  "account_status": "active",
  "key_prefix": "ndx_live_a1b2c3d4",
  "usage": {
    "period_start": "2026-08-01T00:00:00Z",
    "period_end": "2026-09-01T00:00:00Z",
    "used": 12,
    "included": 500,
    "remaining": 488
  }
}

3. Current DNS

GET /v1/dns/?name= returns the current DNS records ndxer has observed for a domain.

bash
curl -H "Authorization: Bearer $NDXER_API_KEY" "https://api.ndxer.com/v1/dns/?name=douban.com"
Sample response
json
{
  "meta": {
    "query": "douban.com",
    "query_type": "domain",
    "returned_count": 8,
    "truncated": false,
    "total_or_estimated_count": 8,
    "count_is_estimate": false,
    "next_cursor": null,
    "last_checked": "2026-01-01T00:00:00Z",
    "provider_context": null,
    "request_id": "req_example",
    "elapsed_ms": null,
    "status": null,
    "mail_posture": null,
    "units_charged": 1
  },
  "apex": "douban.com",
  "records": [
    {
      "type": "A",
      "name": "douban.com",
      "value": "192.0.2.10",
      "ttl": 300,
      "last_observed": "2026-01-01T00:00:00Z",
      "priority": null,
      "countries": [
        "DE",
        "FR"
      ]
    },
    {
      "type": "AAAA",
      "name": "douban.com",
      "value": "2001:db8::10",
      "ttl": 300,
      "last_observed": "2026-01-01T00:00:00Z",
      "priority": null,
      "countries": []
    },
    {
      "type": "CNAME",
      "name": "www.douban.com",
      "value": "douban.com",
      "ttl": 3600,
      "last_observed": "2026-01-01T00:00:00Z",
      "priority": null,
      "countries": []
    },
    {
      "type": "MX",
      "name": "douban.com",
      "value": "mail1.douban.com",
      "ttl": 3600,
      "last_observed": "2026-01-01T00:00:00Z",
      "priority": 10,
      "countries": []
    },
    {
      "type": "MX",
      "name": "douban.com",
      "value": "mail2.douban.com",
      "ttl": 3600,
      "last_observed": "2026-01-01T00:00:00Z",
      "priority": 20,
      "countries": []
    },
    {
      "type": "NS",
      "name": "douban.com",
      "value": "ns1.douban.com",
      "ttl": 3600,
      "last_observed": "2026-01-01T00:00:00Z",
      "priority": null,
      "countries": []
    },
    {
      "type": "TXT",
      "name": "douban.com",
      "value": "v=spf1 -all",
      "ttl": 300,
      "last_observed": "2026-01-01T00:00:00Z",
      "priority": null,
      "countries": []
    },
    {
      "type": "SOA",
      "name": "douban.com",
      "value": "ns1.douban.com. hostmaster.douban.com. 2026010100 3600 900 604800 300",
      "ttl": 3600,
      "last_observed": "2026-01-01T00:00:00Z",
      "priority": null,
      "countries": []
    }
  ]
}

A name we have not observed yet answers 202 with meta.status: pending and a Retry-After header; repeat the same call. The name costs 1 unit in total; repeating it while it is pending is free.

4. IP address

GET /v1/reverse-ip/?ip= returns hostnames observed resolving to an IP, one page at a time.

bash
curl -H "Authorization: Bearer $NDXER_API_KEY" "https://api.ndxer.com/v1/reverse-ip/?ip=203.0.113.42&limit=25"
Sample response
json
{
  "meta": {
    "query": "203.0.113.42",
    "query_type": "reverse_ip",
    "returned_count": 12,
    "truncated": false,
    "total_or_estimated_count": 12,
    "count_is_estimate": false,
    "next_cursor": null,
    "last_checked": null,
    "provider_context": null,
    "request_id": "req_example",
    "elapsed_ms": null,
    "status": null,
    "mail_posture": null,
    "units_charged": 1
  },
  "matches": [
    {
      "hostname": "www.douban.com",
      "apex": "douban.com",
      "last_observed": "2026-01-01T00:00:00Z"
    },
    {
      "hostname": "frodo.douban.com",
      "apex": "douban.com",
      "last_observed": "2026-01-01T00:00:00Z"
    },
    {
      "...": "10 more matches"
    }
  ]
}

If a result set is too large for one page, next_cursor in meta is non-null -- pass it back as ?cursor= to fetch the next page. truncated is true whenever more results exist beyond the current page. count_is_estimate distinguishes an exact total_or_estimated_count from an estimate over a very large dataset -- render an estimate with a tilde (e.g. "~3.8M"), never as a precise count.

meta.provider_context is null in the sample above -- when non-null, it flags that the IP is shared hosting, a CDN, or similar infrastructure, so the returned hostnames are generally unrelated to one another (see the landing page's inverse-lookup description). Its provider field is the provider's slug and provider_name the name to display.

meta.mail_posture is filled on a /v1/dns/ lookup when the name you asked about is its own apex, and is null otherwise. It reports what that domain publishes about mail -- SPF and its terminator, the DNS-lookup count of the record, the DMARC policy and its pct tag as published, the DKIM selectors observed under it, MTA-STS, TLS-RPT, CAA and MX -- as readings and nothing else. There is no grade, no score and no percentage: a posture grade is a number nobody computed. It costs nothing extra: a DNS lookup is still one unit, and a name not observed yet costs one unit in total, even when the request waits for it and then answers 200.

5. More lookups

Four more endpoints answer with a page of names, all in the same shape and paged the same way: three read the index from the other direction, asking which names carry a value, and one lists the names under a domain.

GET /v1/txt-search/?q= returns names whose TXT text contains what you send, matched without regard to case. Add regex=true to match it as a regular expression instead.

bash
curl -H "Authorization: Bearer $NDXER_API_KEY" "https://api.ndxer.com/v1/txt-search/?q=docusign"

GET /v1/value-search/?rtype=&q= returns names whose NS, MX, CNAME or SOA record points at a host, or at anything under it. rtype=SPF reads include, redirect and exists references instead. The API match value is the referenced target; the browser also identifies the SPF relationship.

bash
curl -H "Authorization: Bearer $NDXER_API_KEY" "https://api.ndxer.com/v1/value-search/?rtype=NS&q=douban.com"

GET /v1/cidr/?q= returns names observed on any address inside a range, derived from forward A/AAAA records. At most a /16 for IPv4 and a /32 for IPv6.

bash
curl -H "Authorization: Bearer $NDXER_API_KEY" "https://api.ndxer.com/v1/cidr/?q=203.0.113.0/24"
Sample response
json
{
  "meta": {
    "query": "docusign",
    "query_type": "txt_search",
    "returned_count": 5,
    "truncated": false,
    "total_or_estimated_count": null,
    "count_is_estimate": false,
    "next_cursor": null,
    "last_checked": null,
    "provider_context": null,
    "request_id": "req_example",
    "elapsed_ms": null,
    "status": null,
    "mail_posture": null,
    "units_charged": 1
  },
  "matches": [
    {
      "name": "www.douban.com",
      "value": "docusign=0f2a1c",
      "type": "TXT",
      "apex": "douban.com",
      "last_observed": "2026-01-01T00:00:00Z"
    },
    {
      "...": "4 more matches"
    }
  ]
}

meta.total_or_estimated_count is always null for these three, and that is the contract rather than an omission: counting every name that carries a common value means reading the whole set, so no total is computed. Use meta.truncated and meta.next_cursor to page through instead. Hostnames below is the exception: it counts the distinct hostnames under a domain once, on its first page.

Hostnames

GET /v1/hostnames/?q= returns every hostname observed under a domain, including the domain itself, from every position: names with DNS records of their own and hosts that CNAME, MX, NS, SOA or SPF records point at. Each hostname is listed once, so one call replaces a name list plus /v1/value-search/ for each record type, without their duplicate rows. It costs one unit per page, and a page is limit distinct hostnames.

Every hostnames page carries the cost of the whole list in meta: total_or_estimated_count is the exact number of distinct hostnames under the domain (count_is_estimate is false), counted once on the first page; units_to_complete is what the pages after this one cost, 0 on the last page; units_remaining is the units you have left this period after this page, null on a plan with no unit limit. A client should stop when units_to_complete is greater than units_remaining. A page refused for quota says in its message that it needs 1 unit and how many are left.

The first page also carries meta.related, free of units: subject_count is how many other domains likely belong to the same owner, review_count how many more are worth checking, web_url where to see them and api_path the /v1/related/ call that lists them (that call is metered as the related lookup is). It is null while the counts are not known yet, which is the case on the first request for a domain: ask again shortly, as the answer is kept for 24 hours. Zero counts mean known, nothing related.

bash
curl -H "Authorization: Bearer $NDXER_API_KEY" "https://api.ndxer.com/v1/hostnames/?q=douban.com"

Each match is a NameMatch with value and type empty, apex as on the other endpoints (null for a host that only other records point at), and one more field, seen_as: where the hostname was observed, each at most once, in this order: owner (it has records of its own), cname_target, mx, ns, soa (the primary name server of an SOA record) and spf (an SPF include or redirect target). A public suffix such as co.uk returns none. It also fills first_observed, which is when this index first observed the hostname -- never when it was created, which nothing here knows. Paging and cursors work as for the value lookups above.

All names

GET /v1/find-domains/?q= returns observed names under the input and same-name domains under other suffixes. It costs one unit per page. Same-name candidates have match_class: review; they are not ownership claims. Each own_names row carries seen_as, the same roles as on /v1/hostnames/, so the call returns what the browser page shows.

bash
curl -H "Authorization: Bearer $NDXER_API_KEY" "https://api.ndxer.com/v1/find-domains/?q=douban.com"

Read own_names and same_name_domains separately. Each source has its own status, error code, truncation flag and next cursor. A partial result can contain one healthy source; an unavailable or unindexed source does not prove no candidates exist. limit applies to each source independently.

For the first request, omit both source and cursor. To continue, send source=own with own_next_cursor, or source=same_name with same_name_next_cursor, as the cursor parameter. Keep q unchanged. Tokens are bound to the input and source. A candidate's names can be queried explicitly through /v1/hostnames/?q=; no candidates are expanded automatically.

For the browser tools, read the Lookup guide or Investigations guide.

GET /v1/related/?q= returns domains that share a signal with a seed -- name servers, mail hosts, SOA contacts, SPF, DMARC and DKIM details, verification tokens, CAA contacts and dedicated addresses -- ranked by how rarely each shared signal is shared. Every match carries the evidence it was ranked on, so a score can be checked rather than trusted. A shared signal is evidence of a relationship, not proof of common ownership.

Every match and every seed signal carries a proposed class -- subject or review, and a seed signal may also be infrastructure -- with the reasons for it, each a stable code and a sentence of text. A value under the seed's own domain and a shared verification token count at full weight and propose subject; so does a shared DKIM key, but only while at most 50 domains publish it, above which it counts like any other shared value. kinds and evidence may carry name_label, the same first label as the seed (douban.com and douban.net): it scores nothing and proposes subject only beside another shared value. A class is a proposal, not proof.

The seed can be a domain, a mailbox such as dmarc@douban.com, or an IP address. An address inside a shared provider range answers with seed.shared_provider (the provider's slug) and seed.shared_provider_name set and no matches, because names on such an address are not related to one another.

A signal a named provider runs -- a managed name service, a mail platform, a domain parked for sale -- has status "shared", counts for nothing and cannot be expanded. Its provider field is the provider's permanent slug, provider_name the name to display, category what kind of service it is, and description a few sentences about the provider. A dedicated address held in a provider's one-tenant range carries the same provider fields with no category, and still counts. The note field is deprecated and always empty: read description instead.

bash
curl -H "Authorization: Bearer $NDXER_API_KEY" "https://api.ndxer.com/v1/related/?q=douban.com"
Sample response
json
{
  "meta": {
    "query": "douban.com",
    "query_type": "related",
    "returned_count": 5,
    "truncated": false,
    "total_or_estimated_count": null,
    "count_is_estimate": false,
    "next_cursor": null,
    "last_checked": null,
    "provider_context": null,
    "request_id": "req_example",
    "elapsed_ms": null,
    "status": null,
    "mail_posture": null,
    "units_charged": 5
  },
  "seed": {
    "kind": "domain",
    "value": "douban.com",
    "signals": [
      {
        "kind": "verify_token",
        "key": "example-verify=7f1c93",
        "n_apexes": 2,
        "status": "used",
        "provider": null,
        "provider_name": null,
        "category": null,
        "description": null,
        "note": null,
        "reasons": [
          {
            "code": "account_secret",
            "text": "Shares the verification token example-verify=7f1c93, issued to one account."
          }
        ],
        "class": "subject"
      },
      {
        "kind": "ns_host",
        "key": "ns-shared.example-provider.net",
        "n_apexes": 41000,
        "status": "common",
        "provider": null,
        "provider_name": null,
        "category": null,
        "description": null,
        "note": null,
        "reasons": [
          {
            "code": "shared_by_many",
            "text": "Shares the name server ns-shared.example-provider.net with 41000 domains; no provider named."
          }
        ],
        "class": "infrastructure"
      },
      {
        "kind": "ns_host",
        "key": "ns1.example-dns.net",
        "n_apexes": 620000,
        "status": "shared",
        "provider": "example-dns",
        "provider_name": "Example DNS",
        "category": "dns_host",
        "description": "Example DNS is a managed name service that answers for the domains of many unrelated customers from the same name servers. Two domains sharing them share a vendor and nothing more.",
        "note": "",
        "reasons": [
          {
            "code": "provider",
            "text": "Uses ns1.example-dns.net, a provider's shared infrastructure."
          }
        ],
        "class": "infrastructure"
      },
      {
        "...": "6 more signals"
      }
    ],
    "shared_provider": null,
    "shared_provider_name": null
  },
  "matches": [
    {
      "apex": "doubanio.com",
      "score": 11043,
      "band": "high",
      "kinds": [
        "dkim_key",
        "mx_set",
        "ns_host",
        "verify_token"
      ],
      "evidence": [
        {
          "kind": "verify_token",
          "key": "example-verify=7f1c93",
          "n_apexes": 2,
          "provider": null,
          "provider_name": null,
          "category": null,
          "description": null,
          "note": null
        },
        {
          "...": "3 more signals"
        }
      ],
      "reasons": [
        {
          "code": "owned_value",
          "text": "Publishes ns1.douban.com, under the seed's own name."
        },
        {
          "...": "3 more reasons"
        }
      ],
      "class": "subject"
    },
    {
      "...": "4 more matches"
    }
  ]
}

This is the one endpoint that can cost more than one unit: 1 unit plus 1 per shared signal it scored, at most 8, reported in meta.units_charged. A further page costs 1. Cursors are bound to the seed, to the signal counts the first page was ranked with and to the version of the scoring rules; the evidence and scoring rules can change, so a cursor minted before a change to those counts or scoring rules is rejected with invalid_cursor rather than resuming into a ranking that no longer exists.

GET /v1/related/expand/?kind=&key= answers the same question from the other end: which domains publish one shared value. Send back a kind and a key exactly as /v1/related/ reported them, in seed.signals[] or in a match's evidence[] -- the key is matched verbatim and is not normalised again. The answer is ordered by domain name and by nothing else, because every domain in it publishes exactly this one value at exactly this one rarity, so there is nothing to rank.

bash
curl -H "Authorization: Bearer $NDXER_API_KEY" "https://api.ndxer.com/v1/related/expand/?kind=ns_host&key=ns1.douban.com"
Sample response
json
{
  "meta": {
    "query": "ns1.douban.com",
    "query_type": "related",
    "returned_count": 4,
    "truncated": false,
    "total_or_estimated_count": null,
    "count_is_estimate": false,
    "next_cursor": null,
    "last_checked": null,
    "provider_context": null,
    "request_id": "req_example",
    "elapsed_ms": null,
    "status": null,
    "mail_posture": null,
    "units_charged": 1
  },
  "kind": "ns_host",
  "key": "ns1.douban.com",
  "status": "used",
  "n_apexes": 4,
  "apexes": [
    "douban.com",
    "douban.net",
    "doubanio.com",
    "douban.fm"
  ]
}

Read status before apexes: a value published by more domains than its kind counts as evidence answers 200 with status "common" and no domains, a value a named provider runs answers "shared" and no domains (they are that provider's customers, not each other's relatives), a value only one domain publishes answers "unique", and one no domain we have observed publishes answers "unknown". All four cost 1 unit, the same as an answer with domains in it, because the count behind them was really read. n_apexes is how many domains publish the key across everything we have observed, which is why it is its own field and not meta.total_or_estimated_count.

Saved investigations

Build and triage an investigation in the Investigations tool, then read it from code. Whatever you mark Subject there is what GET /v1/investigations/<id>/ returns with class: subject. The id is the UUID in the investigation's web address, and its page shows it, with a ready curl line, under Use from code. Both endpoints are read-only and cost 0 units; your plan's rate limit still applies.

GET /v1/investigations/ lists your saved investigations, most recently changed first, with each one's id and item counts per class. Add archived=true for the archived ones.

bash
curl -H "Authorization: Bearer $NDXER_API_KEY" https://api.ndxer.com/v1/investigations/

One investigation returns its items as the web JSON export has them: item, type, class, set_by (you or system), proposed_class, reasons and reason_codes, the shared values it was found on and found_via. Filter with class and type, as a comma list or repeated. Items are paged with meta.next_cursor; a cursor is bound to the investigation and the filters that produced it.

bash
curl -H "Authorization: Bearer $NDXER_API_KEY" "https://api.ndxer.com/v1/investigations/<investigation-id>/?class=subject&type=domain,subdomain"

7. Python

No dependencies beyond the standard library.

python
import os
import urllib.request

api_key = os.environ["NDXER_API_KEY"]
base_url = "https://api.ndxer.com/v1"
url = f"{base_url}/dns/?name=douban.com"
headers = {"Authorization": f"Bearer {api_key}"}
request = urllib.request.Request(url, headers=headers)

with urllib.request.urlopen(request) as response:
    data = response.read()
print(data.decode())

8. Errors

Every non-2xx response is a JSON envelope: {"error": {"code", "message", "request_id"}}.

Error codes and their meanings
Code HTTP status Meaning
invalid_input 400 The request was malformed, e.g. a missing or invalid query parameter.
unauthenticated 401 The Authorization header was missing, or the bearer token was invalid.
suspended 403 The account is suspended.
email_unverified 403 The account's email address hasn't been verified yet.
forbidden 403 The account isn't allowed to perform this request.
quota_exceeded 403 The account has used its included queries for the current billing period.
rate_limited 429 Too many requests -- retry after the seconds named in Retry-After.
not_available 503 This lookup isn't available right now.
not_found 404 The requested resource doesn't exist.
query_error 502 The lookup could not be completed.
timeout 504 The lookup did not complete in time. Safe to retry.
unavailable 503 The lookup is temporarily unavailable. Safe to retry.
invalid_query 400 The domain name or IP address was not well-formed.
unsupported 404 This query type is not available.
invalid_cursor 400 The pagination cursor was not recognized, or has expired.
contract 502 The lookup returned a response this API doesn't understand.

9. How queries are counted

One request is one query. Each pagination page is a query. Requests rejected with a 4xx input error, a 429, or a 5xx are not counted. Empty results are counted.

Related can cost up to 8 units, stated before it runs.

10. Rate limits

Rate limits are per account and depend on your plan. Exceeding your limit returns a 429 response with a Retry-After header telling you how long to wait before trying again.

Next: the full API reference

Writing the client with an AI assistant? See Using ndxer with AI assistants.