Public API

The JSON endpoints this site uses for its own lookups, documented as they behave today. Read-only, no sign-up.

No API keys, no SLA, may changeThere are no API keys, no paid tiers and no uptime or latency commitment. The API is not versioned: fields can be added, renamed or removed without notice, and an endpoint can be limited further or withdrawn if it is abused. Do not build something that breaks badly when a request fails.

Conventions

All endpoints answer GET requests with JSON (UTF-8). The domain is a path segment; internationalised names can be sent in Unicode or as A-labels (xn--…). Dates are ISO 8601 in UTC.

Every observation is made from one server location at the time of the request, and says where it came from: each section carries status, source and observedAt. A section with status "error" or "unsupported" could not be checked. That is never evidence that a record is absent, and a registration state of "not-found" is never a guarantee that a name can be registered.

Reserved documentation names (example.com, example.net, example.org and anything under .example, .test, .invalid or .localhost) are never looked up live: they answer 422. The response examples on this page use example.com and documentation IP ranges purely as illustration; to try the API, use a domain you run.

Domain report

GET /api/lookup/{domain}

One report with up to five independent sections: registration (RDAP, with WHOIS on port 43 for some registries that have no RDAP), dns, mail (MX, SPF, DMARC), hosting (IP to ASN and network) and web (one HTTPS request and the TLS certificate it was served with). A section that fails does not fail the report.

A leading "www." is removed. For a subdomain, registration data is looked up for the registrable domain and the other sections for the hostname you sent.

Parameters

NameInDescription
domainpathDomain or hostname, at most 253 characters.
sectionsqueryComma-separated subset of registration, dns, mail, hosting, web. Unknown names are ignored; if no name is valid the answer is 400. Sections you did not ask for come back with status "skipped". Default: all five.

Status codes

StatusMeaning
200{ "report": … }. Sent with Cache-Control: public, max-age=60, s-maxage=300.
400{ "error": "invalid-domain", "reason": … } when the name fails validation, or { "error": "invalid-sections", "allowed": […] }.
422{ "error": "reserved-name" } for documentation and test names, or { "error": "blocked-target" } when the name resolves only to private or reserved addresses, which the service does not connect to.
429{ "error": "rate-limited", "retryAfterSeconds": n } with a Retry-After header.
Example request
curl -s "https://www.orbitprobe.com/api/lookup/yourdomain.com?sections=registration,dns"
Example response (illustrative values, shortened)
{
  "report": {
    "input": "example.com",
    "ascii": "example.com",
    "unicode": "example.com",
    "registrableDomain": "example.com",
    "isSubdomain": false,
    "tld": "com",
    "generatedAt": "2026-01-01T12:00:00.000Z",
    "dataMode": "live",
    "cached": false,
    "registration": {
      "status": "ok",
      "source": "RDAP · rdap.registry.example",
      "observedAt": "2026-01-01T12:00:00.000Z",
      "durationMs": 412,
      "state": "registered",
      "registrableDomain": "example.com",
      "registrar": "Example Registrar, Inc.",
      "registrarIanaId": "9999",
      "registrarUrl": null,
      "abuseEmail": "abuse@registrar.example",
      "createdAt": "2015-03-01T00:00:00Z",
      "updatedAt": "2025-02-10T00:00:00Z",
      "expiresAt": "2027-03-01T00:00:00Z",
      "statusCodes": ["client transfer prohibited"],
      "nameservers": ["ns1.example.net", "ns2.example.net"],
      "dnssec": false,
      "registrant": { "organization": null, "country": null, "redacted": true },
      "rdapUrl": "https://rdap.registry.example/domain/example.com"
    },
    "dns": {
      "status": "ok",
      "source": "DNS · public recursive resolvers",
      "observedAt": "2026-01-01T12:00:00.000Z",
      "durationMs": 95,
      "resolver": "1.1.1.1, 8.8.8.8",
      "records": [
        { "type": "A", "name": "example.com", "value": "203.0.113.10", "ttl": 300 },
        { "type": "AAAA", "name": "example.com", "value": "2001:db8::10", "ttl": 300 },
        { "type": "MX", "name": "example.com", "value": "mail.example.com", "priority": 10 },
        { "type": "TXT", "name": "example.com", "value": "v=spf1 mx -all" }
      ],
      "failedTypes": [],
      "nxdomain": false
    },
    "mail": {
      "status": "ok",
      "source": "DNS",
      "observedAt": "2026-01-01T12:00:00.000Z",
      "durationMs": 140,
      "mx": [{ "priority": 10, "host": "mail.example.com", "addresses": ["203.0.113.25"], "provider": null }],
      "spf": "v=spf1 mx -all",
      "dmarc": "v=DMARC1; p=quarantine; rua=mailto:dmarc@example.com",
      "dkim": "selector-required",
      "findings": [{ "level": "ok", "code": "…", "title": "…", "detail": "…" }]
    },
    "hosting": {
      "status": "ok",
      "source": "IP lookups",
      "observedAt": "2026-01-01T12:00:00.000Z",
      "durationMs": 310,
      "addresses": [
        { "address": "203.0.113.10", "family": 4, "reverse": null, "asn": 64500, "asName": "EXAMPLE-NET", "prefix": "203.0.113.0/24", "country": null, "networkName": null, "networkOrg": null, "edge": null }
      ],
      "summary": "EXAMPLE-NET (AS64500)"
    },
    "web": {
      "status": "ok",
      "source": "HTTPS request",
      "observedAt": "2026-01-01T12:00:00.000Z",
      "durationMs": 520,
      "finalUrl": "https://example.com/",
      "httpStatus": 200,
      "redirects": [],
      "serverHeader": null,
      "poweredBy": null,
      "hsts": true,
      "responseMs": 180,
      "page": { "title": "Example Domain", "description": null, "keywords": null },
      "headers": [{ "name": "content-type", "value": "text/html; charset=UTF-8" }],
      "tls": {
        "protocol": "TLSv1.3",
        "issuer": "Example CA",
        "subject": "example.com",
        "validFrom": "2025-12-01T00:00:00.000Z",
        "validTo": "2026-03-01T00:00:00.000Z",
        "altNames": ["example.com", "www.example.com"],
        "authorized": true,
        "authorizationError": null
      }
    }
  }
}

DNS propagation

GET /api/propagation/{domain}

Asks every resolver of a fixed list of public recursive resolvers the same question and groups identical answers. Read the result as "9 of 10 resolvers returned this answer": it says nothing about resolvers that are not on the list, and an anycast resolver may answer differently in another region.

Here "www." is kept, because the question is about exactly the name you send.

Parameters

NameInDescription
domainpathHostname to ask about, at most 253 characters.
typequeryA, AAAA, NS, MX, TXT or CNAME (case-insensitive). Default: A.

Status codes

StatusMeaning
200{ "report": … } with one entry per resolver (status answer, no-records, nxdomain, timeout or error), the distinct answer groups, and the counters total, answered and failed. Cache-Control: public, max-age=30.
400{ "error": "invalid-domain" } or { "error": "invalid-type" }.
422{ "error": "reserved-name" }.
429{ "error": "rate-limited" } with a Retry-After header.
Example request
curl -s "https://www.orbitprobe.com/api/propagation/www.yourdomain.com?type=A"
Example response (illustrative values, shortened)
{
  "report": {
    "ascii": "www.example.com",
    "unicode": "www.example.com",
    "type": "A",
    "generatedAt": "2026-01-01T12:00:00.000Z",
    "cached": false,
    "answers": [
      {
        "resolver": { "id": "cloudflare", "name": "Cloudflare", "address": "1.1.1.1", "operatorCountry": "US" },
        "status": "answer",
        "values": ["203.0.113.10"],
        "ttl": 300,
        "durationMs": 21,
        "group": 0
      },
      {
        "resolver": { "id": "quad9", "name": "Quad9", "address": "9.9.9.9", "operatorCountry": "CH" },
        "status": "timeout",
        "values": [],
        "ttl": null,
        "durationMs": 2800,
        "group": null
      }
    ],
    "groups": [{ "values": ["203.0.113.10"], "count": 9 }],
    "total": 10,
    "answered": 9,
    "failed": 1,
    "consistent": false
  }
}

Single-purpose checks

GET /api/tools/{tool}/{domain}

The engines behind the tool result pages, one request per check: dkim-checker, subdomain-finder, mta-sts-checker, tls-rpt-checker, bimi-checker, dnssec-checker, reverse-dns and security-txt-checker (the same keys as the site's tool URLs). The answer carries ok, tool, source, observedAt and cached, and the engine's report unchanged.

A query that failed stays visible inside the report as a state such as "failed", "error" or "unknown", or as a finding: it is never turned into "no record". A leading "www." is removed, except for reverse-dns, which keeps it and also accepts an IPv4 or IPv6 address.

Parameters

NameInDescription
toolpathOne of the eight tool keys above. Anything else answers 404.
domainpathDomain or hostname, at most 253 characters; for reverse-dns also an IP address (percent-encode the colons of an IPv6 address).
selectorquerydkim-checker and bimi-checker only. DKIM: the s= selector of a real message; leave it out to try a fixed list of common provider selectors. BIMI: default "default".

Status codes

StatusMeaning
200{ "ok": true, "tool": …, "source": …, "observedAt": …, "cached": …, "report": … }. Sent with Cache-Control: public, max-age=60, s-maxage=300.
400{ "ok": false, "code": "invalid-domain", "reason": … } or { "ok": false, "code": "invalid-selector" }.
404{ "ok": false, "code": "unknown-tool", "tools": […] }.
422{ "ok": false, "code": "reserved-name" } for documentation and test names.
429{ "ok": false, "code": "rate-limited", "retryAfterSeconds": n } with a Retry-After header.
Example request
curl -s "https://www.orbitprobe.com/api/tools/tls-rpt-checker/yourdomain.com"
Example response (illustrative values, shortened)
{
  "ok": true,
  "tool": "tls-rpt-checker",
  "source": "Public DNS (1.1.1.1, 8.8.8.8)",
  "observedAt": "2026-01-01T12:00:00.000Z",
  "cached": false,
  "report": {
    "ascii": "example.com",
    "unicode": "example.com",
    "observedAt": "2026-01-01T12:00:00.000Z",
    "durationMs": 38,
    "cached": false,
    "host": "_smtp._tls.example.com",
    "state": "ok",
    "records": ["v=TLSRPTv1; rua=mailto:tls-reports@example.com"],
    "parsed": { "version": "TLSRPTv1", "valid": true, "rua": [{ "raw": "mailto:tls-reports@example.com", "scheme": "mailto", "valid": true, "target": "example.com" }] },
    "findings": [{ "level": "ok", "code": "RPT_OK", "params": { "n": 1 } }]
  }
}

Activity counters

GET /api/stats/activity

The numbers behind the globe on the home page: how many lookups ran in the last 24 hours, by visitor country and by the registered country of the looked-up address block. Only country codes and counters exist; no IP address and no domain name is stored. The counters are kept in the shared database for 30 days and are the same for every server instance; the answer's `source` field says `memory` when that store could not be reached and the numbers come from one process only. Visitor country comes from the CDN header, so treat it as approximate: a rough signal, not statistics.

Status codes

StatusMeaning
200The snapshot. Cache-Control: public, max-age=30. This endpoint is not rate-limited.
Example request
curl -s "https://www.orbitprobe.com/api/stats/activity"
Example response (illustrative values, shortened)
{
  "windowHours": 24,
  "total": 3,
  "visitors": [{ "country": "TR", "count": 2 }, { "country": "DE", "count": 1 }],
  "hosting": [{ "country": "US", "count": 3 }],
  "generatedAt": "2026-01-01T12:00:00.000Z"
}

Rate limits

Limits are counted per client IP address, separately for each endpoint family. A result served from the server cache does not count against the limit. When a limit is reached the answer is 429 with a Retry-After header; wait that long instead of retrying in a loop.

The badge and the MCP tools run through the same engines, so they share these limits.

EndpointLimit per IP addressServer cache
/api/lookup/{domain}30 per minute · 300 per hour300 s (30 s when a section failed)
/api/propagation/{domain}12 per minute · 120 per hour60 s
/api/tools/{tool}/{domain}10 per minute · 80 per hour (per tool; DKIM 10 per minute · 80 per hour, subdomain finder 5 per minute · 40 per hour)300 s / 1800 s
/api/stats/activitynonenone

Caching

Reports are cached on the server by hostname and section list, so asking for the same sections again within the cache time returns the same observation with "cached": true and its original observedAt times. The Cache-Control headers listed above also let browsers and the CDN reuse a response for a short time. There is no parameter to force a fresh lookup.

CORS

The endpoints on this page send Access-Control-Allow-Origin: * and answer OPTIONS pre-flight requests, for GET only. You can call them from a page on another origin. They never read cookies, so no credentials are involved.

Fair use

Every request triggers real queries to registries, DNS resolvers and the looked-up site itself. Use the API for interactive tools, dashboards and occasional checks of domains you have a reason to look at. Do not crawl lists of domains, do not rotate addresses to get around the limits, and do not resell the output. The acceptable-use policy applies to API traffic exactly as it does to the site.

Acceptable-use policy

Outbound webhooks

Signed-in members can have watch alerts, expiry reminders and marketplace notices posted to a URL they own: a Slack incoming webhook or a generic https endpoint (workspace → Settings → Notification channels). This section documents what the receiver gets. It is outbound only: OrbitProbe never accepts requests on these URLs, and there is no inbound API.

Receivers must be https on a public host (default port, no credentials; private, local and reserved hosts are refused, and the host is resolved and checked again at send time). Redirects are not followed. A delivery counts as done when the receiver answers 2xx; after five failed deliveries in a row the webhook is switched off and the owner sees a notice in the workspace.

Request

NameDescription
POSTJSON body, Content-Type: application/json; charset=utf-8, User-Agent: OrbitProbe-Webhook/1.0
X-OrbitProbe-EventThe kind (see below).
X-OrbitProbe-DeliveryThe delivery id (ntf_<number>). Repeated on a retry, so you can de-duplicate.
X-OrbitProbe-SignatureGeneric endpoints only: t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>"> with the signing secret shown in Settings.

Body (generic endpoints)

NameDescription
idDelivery id, the same as X-OrbitProbe-Delivery.
kindOne of the kinds below.
categorywatch, expiry, market or test.
title, textThe notification in the account’s language: the same wording the push notification carries. Counts are "N of M resolvers", never percentages.
urlWhere to look in the workspace.
createdAtWhen the event was queued (ISO 8601, UTC).
accountThe recipient’s account id, so one receiver can serve several accounts.
dataThe raw event as stored: domain, counts, listing id, amount in integer minor units and currency, and so on. Fields vary by kind; anything missing was not measured, never "no".
Body (generic endpoints)
{
  "id": "ntf_1042",
  "kind": "watch-changed",
  "category": "watch",
  "title": "Watch: unexpected change",
  "text": "example.com TXT: 9 of 12 resolvers return values that differ from the guarded baseline.",
  "url": "https://www.orbitprobe.com/app/watch/00000000-0000-4000-8000-000000000000",
  "createdAt": "2026-01-01T00:00:00.000Z",
  "account": "00000000-0000-4000-8000-0000000000aa",
  "data": {
    "domain": "example.com",
    "kind": "dns-change",
    "record_type": "TXT",
    "name": "example.com",
    "matched": 3,
    "total": 12,
    "failed": 0,
    "differing": 9,
    "watch_id": "00000000-0000-4000-8000-000000000000",
    "event_id": 77
  }
}

Body (Slack)

Slack incoming webhooks receive {"text": "…"} only: title, text and URL on three lines, no Block Kit and no attachments, so the message renders in every Slack client and in Slack-compatible receivers such as Mattermost or Rocket.Chat.

Body (Slack)
{
  "text": "*Watch: unexpected change*\nexample.com TXT: 9 of 12 resolvers return values that differ from the guarded baseline.\nhttps://www.orbitprobe.com/app/watch/…"
}

Verifying the signature

Recompute the HMAC over "<t>.<raw body>" with your secret, compare in constant time, and reject timestamps older than five minutes. Do not parse and re-serialise the JSON before hashing: sign the bytes as received.

Verifying the signature
// Node.js receiver: verify X-OrbitProbe-Signature before trusting the body
import { createHmac, timingSafeEqual } from 'node:crypto';

export function verify(secret, rawBody, header, nowSeconds = Math.floor(Date.now() / 1000)) {
  const m = /^t=(\d+),v1=([0-9a-f]{64})$/.exec(header ?? '');
  if (!m || Math.abs(nowSeconds - Number(m[1])) > 300) return false;
  const expected = createHmac('sha256', secret).update(`${m[1]}.${rawBody}`).digest('hex');
  return timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(m[2], 'hex'));
}

Kinds

watch-changed, watch-target, watch-failed, watch-recovered, watch-partial · expiry · offer-received, offer-countered, offer-accepted, offer-rejected, offer-withdrawn, offer-expired, listing-submitted, listing-approved, listing-paused, listing-removed, agreement-cancelled · test (the "Send test" button).

Limits

At most 5 webhooks per account and 20 posts per account per hour; beyond that, events still reach the in-app inbox and e-mail but are not posted. Deliveries are retried up to five times over later runs of the five-minute job. An "offer-accepted" event records an agreement only: no payment, escrow or transfer is implied.

OpenAPI description

A machine-readable OpenAPI 3.1 document of these endpoints is generated from the same code: /api/openapi.json

FAQ

Do I need an API key?

No. There are no keys and no accounts for the API. Requests are limited per IP address instead.

Can I rely on the API in production?

Only for things that may fail. There is no SLA and no versioning; the endpoints exist because the site needs them, and they are documented so you can use them too. Cache results on your side and handle 429 and 5xx answers.

Why does example.com answer 422?

Reserved documentation and test names are handled by fixtures inside the app and are never looked up live. Use a real domain to try the API.

Does a failed section mean the record does not exist?

No. "error" and "unsupported" mean the source could not be read from our server at that moment. Only a section with status "ok" or "partial" tells you what was found, and only for the types that are not listed in failedTypes.