Sign in

Pro API reference

Every keyed endpoint: reverse IP, pattern search, batch lookup, the full domain record, certificate and key pivots, and the provisioning and NRD feeds.

What the Pro API adds over the public API

The public API answers one question: what do we hold about this domain. One name in, one flat record out.

The keyed API answers the reverse questions: which domains resolve to this address, which names in the corpus fit this grammar, which certificates carry this public key, and which domains were registered, certificated and pointed at a host inside one afternoon.

Every keyed response hands back identifiers that open the next question. A domain record carries a cert_id; a certificate carries sans and an spki_sha256; a search hit carries ns and cert_id; a reverse-IP row carries a hostname and its registrable domain.

Who it is for

Threat and fraud investigation, where one observation is only a starting point. You have a phishing hostname, a certificate serial from a TLS log, or a nameserver pair from a takedown. The work is moving from that to the rest of the infrastructure behind it.

Bulk enrichment of a list you already hold is served better by the daily files. Paging an API for the whole corpus is slower for you and more expensive for us than one download. The documentation hub maps which surface fits which job.

Authentication: a bearer key in the header on every request

Every endpoint on this page needs a key. Send it as an Authorization header:

curl -sS https://issued.live/api/v1/ip/157.240.17.8 \
  -H "Authorization: Bearer YOUR_KEY"

The key never goes in a URL or a query string. Query strings land in access logs, proxy logs, browser history and Referer headers, so there is no ?key= parameter and the header is the only place a key is read. A key in the URL therefore leaves the request unauthenticated, and it answers 401.

A missing or unrecognized key gets 401 unauthorized with a WWW-Authenticate: Bearer realm="issued.live" header and this body:

{
  "error":   "unauthorized",
  "message": "Supply a key as: Authorization: Bearer <key>"
}

Keys come with a plan. See pricing for what each one includes, or ask us if your use case sits outside them.

Keyed replies are served Cache-Control: no-store, private with Vary: Authorization. Cache them on your side if you want them cached; nothing between us will do it for you.

Rate limits and concurrency

Basic reaches none of the endpoints on this page. There is no daily cap on either paid plan. What bounds this surface is the concurrency limit below, not a per-minute ceiling and not a quota you have to ration: the 300 requests a minute Plus carries meter the public record API, where a Pro key lifts the free daily cap.

One advanced query runs at once on Plus, two on Pro, per account. Reverse IP, range, pattern search, batch lookup and the provisioning feed each take a slot; the certificate, key, domain, timeline and NRD endpoints are point lookups and take none. The limit is yours alone: another customer's traffic does not consume it.

The gate exists because a cold reverse-IP range query can read hundreds of millions of rows and hold its database connection for the better part of 18 seconds. Enough of those at once would take the whole connection pool and the memory behind it, and every other request on the site would then stall. A site-wide ceiling sits above the per-account limit as a backstop; you will only meet it if the service as a whole is saturated.

Over the gate a request waits up to five seconds for a slot, then returns 503 timeout. It never queues. An unbounded queue turns one slow query into a pile-up where every request holds a connection until they all expire together.

Both doors are metered together. The HTTP API and the MCP tools are two entrances to the same query shapes, and they draw on the same per-account slots, so moving to MCP does not buy extra concurrency.

Query cost

Calls are not priced in units, but they are not equal in cost to serve. An unanchored pattern, one with no literal prefix, reads the whole key space and measures around 350x an anchored search. Anchor your patterns where you can: it is faster for you, and it is the difference between a search that returns promptly and one that spends its slot.

Reverse IP: which domains resolve to an address or a small range

GET https://issued.live/api/v1/ip/{ip}
GET https://issued.live/api/v1/range/{cidr}

Answers the reverse of a domain lookup: every hostname our resolvers have seen answer with a given address, when it was first seen there, and when it was last seen there.

For a range, write the prefix slash as a hyphen. A literal slash is a path separator and will not survive routing. A percent-encoded %2F works too, where your client sends it through intact.

curl -sS https://issued.live/api/v1/ip/157.240.17.8 \
  -H "Authorization: Bearer YOUR_KEY"

curl -sS https://issued.live/api/v1/range/157.240.17.0-24 \
  -H "Authorization: Bearer YOUR_KEY"
ParameterDefaultMaxMeaning
limit50050000Hosts returned per request. Out-of-range values are clamped rather than rejected.
after--Pagination cursor. Send ?after= with an empty value to begin a walk, then pass back the next_cursor from each response.
format--ndjson streams one JSON object per line. Also selected by Accept: application/x-ndjson, and automatically at limit ≥ 5000.
first_seen_from--Only hostnames first observed at this address at or after this time. RFC3339, YYYY-MM-DD HH:MM:SS, YYYY-MM-DD, or a unix timestamp. No zone means UTC.
first_seen_to--Upper bound on the same field. Either bound may be sent alone.

page, offset, skip and cursor are refused with a 400. All four were once accepted and ignored, which returned page one with a 200 however many times you asked for page four.

A malformed or reversed time window is rejected with a 400 rather than adjusted. A limit is a preference, so we clamp it and answer the question you meant. A timestamp asserts which rows you want, and substituting a different window is how you end up with a gap you cannot see.

Example response

{
  "query":     "157.240.17.8",
  "addresses": 1,
  "count":     2,
  "truncated": false,
  "limit":     500,
  "order":     "recent",
  "hosts": [
    {
      "hostname":   "m.internmc.facebook.com",
      "domain":     "facebook.com",
      "ip":         "157.240.17.8",
      "first_seen": "2026-08-06T21:19:30Z",
      "last_seen":  "2026-08-28T15:44:38Z",
      "observed":   12
    },
    {
      "hostname":   "star.c10r.facebook.com",
      "domain":     "facebook.com",
      "ip":         "157.240.17.8",
      "first_seen": "2026-08-07T02:11:44Z",
      "last_seen":  "2026-08-29T09:03:12Z",
      "observed":   9
    }
  ]
}
FieldMeaning
queryThe address or CIDR as it was parsed.
addressesHow many addresses the query covered. 1 for /api/v1/ip/, up to 256 for a range.
countRows in this response.
truncatedTrue when limit was reached and more data exists.
orderWhich ordering contract produced this page. See below.
next_cursorPresent only when another page exists. Its absence ends the walk.
first_seen_from, first_seen_toEchoed when you sent a window, so you can see it took effect.
hosts[].observedHow many times we recorded that hostname on that address.

Ranges are capped at 256 addresses, and the cap is an engineering limit

A CIDR is expanded into its individual addresses and issued as an IN list. The address count is therefore the query's cost, and 256 addresses is an IPv4 /24. Licensing has nothing to do with it.

The number came out of cost measurements that refuse to form a curve, because cost here is dominated by cold granule reads. A /27 of 32 addresses read 383 million rows in 6.8 s, a /26 of 64 addresses read 5.1 million in 188 ms, and a /24 of 256 addresses read 15.7 million in 365 ms. The same /24 had read 669 million rows cold minutes earlier, so page cache decides and prefix length follows it.

Anything wider answers 400 bad_request naming the widest prefix allowed. To sweep something larger, iterate a /24 at a time and serialize the requests. Running them concurrently makes each one slower rather than finishing sooner, and the gate admits two at a time for your account anyway.

How fast a lookup is, and what makes it slow

Lookups read an address-ordered copy of the corpus, so a single address is a direct seek rather than a scan. Typical single-address latency is tens of milliseconds. A busy hosting address carrying millions of names is still fast.

Cache results on your side. This data changes over hours.

Time-bounding reduces the number of pages, and a single page costs the same

first_seen sits outside the table's sort key, so the filter skips none of the reading. Measured on an address holding 18.9 million observations, one page of 500 costs 4.24 s unfiltered and 4.38 s with a six-hour window.

What a window changes is how many requests you need. 3.33.130.190 carries 28,705,254 hostnames, roughly 575 pages at the maximum limit, and the same address restricted to a six-hour provisioning window is one or two pages. For "what appeared here on Tuesday afternoon", that is the difference between a feasible query and an infeasible one.

What first_seen means before you build a window around it

first_seen is the earliest time our resolvers saw that hostname answer with that address. Two consequences matter:

Pagination: two orderings, and only one of them resumes

The cap is per request; the cursor is how you get past it. Some addresses carry tens of millions of names, far more than any single response should hold.

curl -sS "https://issued.live/api/v1/ip/104.18.22.186?after=&limit=50000" \
  -H "Authorization: Bearer YOUR_KEY"
orderWhenSorted byResumable
recentno after parameterlast_seen descendingNo
keyafter present, even empty(ip, rd_rev, fqdn_rev) ascendingYes

Paging is an explicit mode rather than something that switches on result size, because the two orderings cannot be mixed inside one walk without dropping rows. Recency ordering sorts by last_seen, which we rewrite whenever we re-observe a name. A row can therefore move between pages and be served twice or skipped.

Key order is the table's primary key, and none of its three columns ever changes for a given observation. Resuming is a seek that prunes whole granules ahead of the read.

A cursor is opaque. Treat it as a token to hand back, rather than something to parse or construct.

next_cursor is present only when another page exists, and its absence is the only end-of-data signal. We read one row past your limit to decide. A short page can therefore carry a cursor, and a full page with nothing behind it carries none.

Streaming large answers as NDJSON

At limit ≥ 5000, or on ?format=ndjson, the answer streams as newline-delimited JSON. Neither side holds it in memory, and you can start work on the first row instead of waiting for the last.

{"query":"104.18.22.186","addresses":1,"limit":50000,"format":"ndjson","order":"key"}
{"hostname":"app.airquote.ac","domain":"airquote.ac","ip":"104.18.22.186","first_seen":"2026-08-11T04:02:51Z","last_seen":"2026-09-01T22:14:07Z","observed":6}
{"end":true,"count":50000,"next_cursor":"OjpmZmZmOjEwNC4xOC4yMi4xODYf..."}

Records always carry hostname and never carry end, so the trailer is unambiguous. A clean finish with zero rows still gets a trailer.

The trailer is written only on a clean finish. If the stream dies mid-flight there is no trailer and no cursor, and that absence is the error signal; the headers are already sent by then, so no status code is available to carry it. Resuming from a cursor you were never given would skip everything between the failure and the last row you received.

Iterating a larger range

const key = process.env.ISSUED_LIVE_KEY;

// Serialized on purpose. Concurrency does not help here, and the gate admits two at a time.
for (let third = 0; third < 4; third++) {
  const cidr = `157.240.${16 + third}.0-24`;
  const res = await fetch(`https://issued.live/api/v1/range/${cidr}`, {
    headers: { Authorization: `Bearer ${key}` },
    signal: AbortSignal.timeout(40_000),
  });
  // 503, never 504: this origin sits behind a CDN that replaces origin-generated gateway
  // statuses with its own error page, so a 504 would reach you stripped of our JSON body.
  if (res.status === 503) { console.warn(`${cidr}: timed out, retry later`); continue; }
  if (!res.ok) throw new Error(`${cidr}: HTTP ${res.status}`);
  const { hosts } = await res.json();
  console.log(cidr, hosts.length);
}
GET https://issued.live/api/v1/search?name={pattern}

Turns a naming hypothesis into a query. If you are testing "a short word followed by agent, in .com", you cannot enumerate the candidates yourself: there is no wordlist, and the space is combinatorial. The match has to happen next to the data.

curl -sS "https://issued.live/api/v1/search?name=[a-z]{4,10}agent.com&limit=100" \
  -H "Authorization: Bearer YOUR_KEY"
ParameterDefaultMaxMeaning
namerequired128 charsThe pattern. At most 8 labels. A missing or empty value is a 400.
limit5001000Candidates examined per page. Omitting it gives 500.
after--Cursor. Omit it to start, then pass back next_cursor.
ns--Keep only rows whose nameserver set matches, by exact name or by parent domain.
first_seen_from30 days back-Switches to window mode. Same time formats as the reverse-IP window.
first_seen_tonow-Upper bound in window mode, always clamped to now.

The pattern language

Write the pattern forwards, the way you say the name. Per label:

SyntaxMatchesExample
*Zero or more of a-z, 0-9, -*agent.com
?Exactly one of those charactersloop?.com
[a-z]{4,10}A counted run from a character class[a-z]{4,10}agent.com
literalItselfloop-lumen.com

A pattern must include a TLD. *agent alone could mean a name or a suffix, and guessing would search the wrong thing. Write *agent.* to mean every TLD.

The count on a character class is required and must be bounded, with an upper bound of 64. [a-z]+ and [a-z]{4,} are refused, because an open bound is how a pattern silently becomes a full scan. Grouping, alternation and negation are unsupported, and the compiled expression comes back as regex so you can see exactly what ran.

Naming a TLD is worth about 60x

Names are stored suffix-first, so a pattern that pins the TLD becomes a key range, and one that does not has to read the whole key space. Measured:

PatternAnchoredScanCost
loop-*.comyes, com.loop-0.03 scheap
[a-z]{4,10}agent.comyes, com.0.7 scheap
*agent.*no1-3.5 s~350x an anchored scan

The response reports anchored and key_prefix, so you can see which case you are in. An unanchored search costs more against the meter because it costs more to answer.

Window mode and corpus mode

Sending first_seen_from or first_seen_to switches to window mode, which searches the registration feed by date. The date is the leading sort key there, so the same question is an index range measured at 0.094 s. It is the right mode for "which names matching this grammar appeared last week".

Without a window you get corpus mode: every name we hold, paged by key. The response says which you got in mode.

Sending only first_seen_to defaults the lower bound to 30 days back, and the reply echoes the window that was applied.

Cursors are not interchangeable between the modes. One carries a position in time and one does not. A cursor from the wrong mode gets a 400, rather than a silent restart of the walk.

count, scanned, and how to tell when a walk is finished

scanned is how many candidate names the page examined. count is how many it returned. They differ when ns= filtered rows out after the page was drawn.

Keep following next_cursor until it is absent. The cursor advances over candidates, so a page that returns nothing still moves you forward.

{
  "pattern":    "[a-z]{4,10}agent.com",
  "regex":      "^com\\.[a-z]{4,10}agent$",
  "mode":       "corpus",
  "anchored":   true,
  "key_prefix": "com.",
  "count":      100,
  "scanned":    100,
  "limit":      100,
  "truncated":  true,
  "next_cursor": "H2NvbS5hYmJpbnNhZ2VudA",
  "domains": [
    {
      "domain":          "abbinsagent.com",
      "tld":             "com",
      "first_zone_seen": "2026-09-05T22:00:00Z",
      "first_cert_seen": "2026-09-06T04:21:09Z",
      "cert_id":         "b800bf9f6375b6825ca2b92d97bbb06f",
      "ns": ["ns1.zoom.ph", "ns2.zoom.ph"]
    }
  ]
}

Every row carries ns and cert_id, which makes a name match actionable. A shared nameserver pair, a same-day registration and a name that fits the grammar are together a far stronger signal than any one of them alone. cert_id chains straight into the certificate endpoint.

Batch lookup: up to 500 domains in one POST

POST https://issued.live/api/v1/domains

The same core record as a single lookup, for a whole candidate list in one request. A 432-name list becomes one call and a fraction of a second.

curl -sS https://issued.live/api/v1/domains \
  -H "Authorization: Bearer YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{"domains":["example.com","loop-lumen.com","github.com"]}'
ParameterWhereMeaning
domainsbodyArray of names. At least one, or the reply is a 400.
includequeryComma-separated: addresses, certificates, timing. Each adds a sub-list of up to 10 entries per domain. An unrecognized value returns a 400 naming the three that exist.
CapValueWhy
Domains per request500The reply is assembled in memory.
Domains with any include100Each domain then carries up to three sub-lists, and 500 of those is the shape that will not buffer.
Sub-list entries per domain10Same reason.
Request body512 KiB500 names at the maximum legal hostname length is about 127 KiB, so this is generous. Over it is a 413 too_large.

Sending more names than the cap truncates to the cap and sets truncated. A caller with 900 names gets the first 500 and a clear signal to send the rest. A Content-Type other than application/json is a 415.

Every input gets a row back, in the order you sent it

Duplicates included, so you can zip the reply against your list without normalizing anything. A duplicate is answered twice and looked up once.

StatusMeaning
foundWe hold a record.
not_foundWe looked and hold nothing.
rejectedThe input does not parse as a registrable domain, and reason says so. We did not look it up.

Those last two are deliberately different. A batch that dropped unparseable input would let you conclude a domain is absent from the corpus when it was never queried.

{
  "count":     3,
  "found":     2,
  "not_found": 0,
  "rejected":  1,
  "limit":     500,
  "truncated": false,
  "domains": [
    {
      "query":  "example.com",
      "status": "found",
      "domain": "example.com",
      "record": {
        "domain":          "example.com",
        "tld":             "com",
        "first_zone_seen": "2026-08-05T19:16:00Z",
        "first_cert_seen": "2026-08-06T21:19:30Z",
        "cert_id":         "9f53ecddf913afba5678c924a95149d2",
        "ns": ["a.iana-servers.net", "b.iana-servers.net"]
      }
    },
    {
      "query":  "not a domain",
      "status": "rejected",
      "reason": "not a registrable domain"
    }
  ]
}

With ?include=addresses,certificates,timing each found row also carries addresses, certificates and registration_timing, and the response echoes include and sub_limit. A sub-query that fails drops its decoration rather than failing the whole call. An empty sub-list against an echoed include therefore reads as "none held".

Timeline: every dated event for one domain, with the gap between them

GET https://issued.live/api/v1/timeline/{domain}

Registration, first appearance in a zone file, certificate issuances, address changes and DNS changes, merged into one list in time order. Each event after the first carries gap_seconds, the interval since the previous event.

Reconstructing this by hand means four separate calls and a merge. The interval is the part that is hard to get by hand and easy to read once it is there: bulk-provisioned infrastructure produces tight, regular gaps that a person does not.

Read gap_seconds together with precision

Our sources record time at different resolutions, and a gap is only as good as the two timestamps it sits between. Every event states which it came from.

precisionSourceWhat the gap means
exactCertificate Transparencynot_before is the issuing CA's own clock, accurate to the second. A gap between two certificate events is the subject's behavior.
dayRegistry and zone filesDated to the day. Gaps under 24 hours carry no information.
observedOur resolversThe time we saw the change, not when it happened. The gap reflects our re-resolve interval. A domain may have changed minutes after we looked and we would record it at the next sweep.

A gap between two exact events is evidence. A gap between two observed events is a measurement of our scan schedule. Filter on precision before fingerprinting.

Response

{
  "domain": "example.com",
  "count": 5,
  "cert_window_days": 14,
  "events": [
    { "at": "2026-09-01T00:00:00Z", "event": "domain_registered",
      "detail": "registrar: Example Registrar, Inc.",
      "source": "registry", "precision": "day" },
    { "at": "2026-09-06T02:28:42Z", "event": "certificate_issued",
      "detail": "Let's Encrypt, 2 names, valid 90d",
      "source": "ct", "precision": "exact", "gap_seconds": 440922 },
    { "at": "2026-09-06T03:28:41Z", "event": "certificate_issued",
      "detail": "Let's Encrypt, 2 names, valid 90d",
      "source": "ct", "precision": "exact", "gap_seconds": 3599 },
    { "at": "2026-09-06T04:28:48Z", "event": "certificate_issued",
      "detail": "Let's Encrypt, 2 names, valid 90d",
      "source": "ct", "precision": "exact", "gap_seconds": 3607 },
    { "at": "2026-09-08T05:05:21Z", "event": "addresses_observed",
      "detail": "8 addresses: 192.0.2.10, 192.0.2.11, 198.51.100.7 ...",
      "source": "resolver", "precision": "observed", "gap_seconds": 174993 }
  ]
}

The three certificate events above are 3599 and 3607 seconds apart. That is an hourly job reissuing a certificate, and it is the pattern this endpoint exists to surface.

Events

eventMeaning
domain_registeredRegistry creation date. Absent where we hold none.
first_seen_in_zoneFirst appearance in a zone file we load.
certificate_issuedOne issuance, with issuer, how many names on this domain it covered, and its validity in days. Precertificate and final certificate are one event.
addresses_observedOne or more addresses first seen at that moment. A rotation that swaps eight addresses at once is one event, not eight.
dns_<type>_changedA DNS answer changed, with the old and new values. dns_ns_changed, dns_a_changed and so on.

Limits

Certificate events cover the last 14 days, matching the window on certificate lookup; cert_window_days repeats that figure in every response so a short timeline is not mistaken for a quiet domain. Older certificates are in the archived daily files. Address and DNS history reach back further, and registration has no expiry.

A response holds at most 2,000 events. A domain behind a large shared certificate can exceed that; the daily files are the better tool for those.

A 404 means we hold no dated events for the name. It does not mean the domain is inactive.

Full domain record: everything we hold for one name, in one request

GET https://issued.live/api/v1/domain/{domain}

The combined record instead of four round trips: the public record, the registration timing, every address the name has been seen on, and its certificates.

The name is folded to lower case and resolved to its registrable domain through the Public Suffix List. WWW.Example.COM and example.com reach the same record.

curl -sS https://issued.live/api/v1/domain/hoteles-andalucia.com \
  -H "Authorization: Bearer YOUR_KEY"
PartCapMeaning
recordoneThe same fields the public API returns for the name.
registration_timingonePresent when the name was observed as newly registered. Omitted otherwise.
addresses50Address history, newest first.
certificates50Certificates covering the name, newest first.
counts-How many of each this response carried.
{
  "domain": "hoteles-andalucia.com",
  "record": {
    "domain":          "hoteles-andalucia.com",
    "registered":      null,
    "expires":         null,
    "ssl_expires":     "2026-12-06T13:51:23Z",
    "ssl_cert":        "3B1A7C55D0E9F2184A6C90BB77E1D4C2",
    "ssl_issuer":      "Let's Encrypt",
    "ip":              "178.32.125.215",
    "last_update":     "2026-09-07T15:02:11Z",
    "first_cert_seen": "2026-09-07T14:49:59Z",
    "tracked":         false,
    "first_zone_seen": null
  },
  "registration_timing": {
    "first_zone_seen": "2026-09-07T06:03:27Z",
    "first_cert_seen": "2026-09-07T13:55:02Z",
    "hours_to_cert":   7.86,
    "rdap_created":    "2026-09-07T05:58:14Z"
  },
  "addresses": [
    { "ip": "178.32.125.215", "first_seen": "2026-08-24T14:33:59Z",
      "last_seen": "2026-08-31T06:19:45Z", "observed": 3, "current": true }
  ],
  "certificates": [ { "cert_id": "3b1a7c55d0e9f2184a6c90bb77e1d4c2", "...": "see below" } ],
  "counts": { "addresses": 1, "certificates": 1 }
}

null in registered or expires means we hold no registration record for that name. A registered domain can still show null there. We never infer either field from certificate dates.

current on an address marks the one we believe is live now. The others are history, which is the reason to keep them.

A name with no record, no addresses and no certificates answers 404 not_found. That is an answer about the corpus rather than a fault in the request.

Certificate lookup: one certificate, by either identifier

GET https://issued.live/api/v1/cert/{cert_id or sha256}

Returns what was issued: the subject, the SANs, the key, the validity window, the JA4X fingerprint and the logs it appeared in.

The path accepts two widths, and which one you hold depends on where you got it. Case is ignored for both.

LengthFieldWhat it is
32 hexcert_idOur certificate key: the first half of a SHA-256 over the normalized TBSCertificate, and the value a domain record's ssl_cert publishes. Present on every certificate we hold. Use this one.
64 hexcert_sha256SHA-256 of the DER-encoded certificate: what openssl x509 -fingerprint -sha256 prints, and what other CT tools call the certificate hash. Recorded only where we observed the final certificate, so it is absent for precert-only entries and omitted from the response when it is.

Any other length is a 400 that names both accepted widths. If you are chaining from a domain lookup you already hold the 32-hex form: take record.ssl_cert and send it straight here.

curl -sS https://issued.live/api/v1/cert/0cac1fd947ba8a3f2798f46772ac981db6fda26784c76d40e245cb63d8a975d4 \
  -H "Authorization: Bearer YOUR_KEY"
{
  "cert_id":       "3b1a7c55d0e9f2184a6c90bb77e1d4c2",
  "cert_sha256":   "0cac1fd947ba8a3f2798f46772ac981db6fda26784c76d40e245cb63d8a975d4",
  "serial":        "052d2574f33bd28591832dcf7c10f990ddfb",
  "spki_sha256":   "746b5f9f4ac791f00e526234653f6f369b2b290b444a7c1dca810c9d7886a76e",
  "issuer":        "CN=YE2,O=Let's Encrypt,C=US",
  "issuer_org":    "Let's Encrypt",
  "subject_cn":    "hoteles-andalucia.com",
  "key_alg":       "ECDSA",
  "key_bits":      256,
  "signature_alg": "ECDSA-SHA384",
  "valid_from":    "2026-09-07T13:51:24Z",
  "valid_until":   "2026-12-06T13:51:23Z",
  "sans":          ["*.hoteles-andalucia.com", "hoteles-andalucia.com"],
  "san_count":     2,
  "wildcard":      true,
  "ja4x":          "2e16925cec5b_3525cf66b29e_73cc281",
  "first_observed": "2026-09-07T13:55:02Z",
  "last_observed":  "2026-09-07T13:55:02Z",
  "ct_logs_seen":   2
}

Retention: 14 days here, forever in the files

Full certificate rows and the name-to-certificate index are queryable here for 14 days. Registration data in the domain record has no expiry; it is only the certificate detail that ages out of the database.

Nothing is lost when it does. Every daily certificates file is kept in cold storage indefinitely, so a date beyond the 14-day window is a file request rather than a gap.

Those entries come back through /api/v1/domain/ with a detail field saying the full row has aged out. "We no longer hold the certificate" and "this domain had no certificate" are different answers, and only one of them is evidence.

A 404 from /api/v1/cert/ for an older id reports the retention limit and says nothing about whether the certificate existed. The 404 message names the width you sent. A pasted 64-hex digest that we hold only as a precert is therefore distinguishable from a genuine miss.

Key reuse: every certificate sharing one public key

GET https://issued.live/api/v1/spki/{sha256}

spki_sha256 is the SHA-256 of the certificate's SubjectPublicKeyInfo. Two certificates carrying the same value were issued for the same key pair.

Usually that is a renewal. Sometimes it is one operator across unrelated names. Either way it is a stronger link than a shared name or address, because whoever holds one private key holds them all.

Coverage begins 7 September 2026. Certificates first observed before that date carry no key hash and cannot be pivoted on: they were ingested before the field existed, and it cannot be backfilled because deriving it needs the certificate bytes, which are not what we retain. Everything observed from 8 September onward has it, so the gap is the oldest end of the window and it shrinks as those entries age out of the 14-day retention above. A key that appears only in the uncovered period looks the same as a key we never saw; treat a thin result from an old pivot as unknown rather than as absence.

ParameterDefaultMaxMeaning
path segmentrequired64 hexThe key hash. Any other length is a 400.
limit500500Certificates returned. Ask for fewer to get fewer.
curl -sS "https://issued.live/api/v1/spki/746b5f9f4ac791f00e526234653f6f369b2b290b444a7c1dca810c9d7886a76e?limit=100" \
  -H "Authorization: Bearer YOUR_KEY"
{
  "spki_sha256":  "746b5f9f4ac791f00e526234653f6f369b2b290b444a7c1dca810c9d7886a76e",
  "count":        2,
  "truncated":    false,
  "certificates": [
    {
      "cert_id":    "3b1a7c55d0e9f2184a6c90bb77e1d4c2",
      "subject_cn": "hoteles-andalucia.com",
      "issuer_org": "Let's Encrypt",
      "valid_from": "2026-09-07T13:51:24Z",
      "sans":       ["*.hoteles-andalucia.com", "hoteles-andalucia.com"]
    }
  ],
  "coverage": "Only certificates ingested after 2026-09-07 carry a recorded SubjectPublicKeyInfo hash; earlier ones cannot be matched on it."
}

Read the coverage field before drawing a conclusion. We began recording the key hash on 2026-09-07. Certificates ingested before that date sit in the corpus and cannot be matched on it.

A small result therefore means "few matches among certificates we have hashed". Treating it as evidence that the key is unused elsewhere would overstate it. The field ships on every response, so that distinction is never left to inference, and coverage grows daily as certificates are re-observed.

Provisioning feed: domains registered, certificated and resolved inside one window

GET https://issued.live/api/v1/provisioning

Any one of those three facts is ordinary. All three within an afternoon is a domain being stood up to be used.

Nothing new is collected for this. The registration and certificate legs are one row already, and the DNS leg is the first answer our resolvers recorded.

curl -sS "https://issued.live/api/v1/provisioning?max_hours_to_cert=6&cert_to_dns_max=6" \
  -H "Authorization: Bearer YOUR_KEY"
ParameterDefaultMaxMeaning
max_hours_to_cert6168Hours from zone-file appearance to certificate. This bound is what keeps the query affordable: over a 72-hour window there are about 592 candidates at 1 hour, 11,206 at 6 and 114,531 at 24. Over the ceiling it clamps and the reply echoes what was applied. Zero or negative is a 400.
cert_to_dns_max--Hours from certificate to first DNS answer. Applied after the page is drawn. See below.
since72 hours back-Start of the candidate window. RFC3339, YYYY-MM-DD HH:MM:SS, YYYY-MM-DD, or unix.
untilnow-End of it, always clamped to now. At most 13 calendar months per request.
include_cert_before_zoneoff-1 or true includes domains certificated before they appeared in a zone file.
tld--One TLD. A leading dot and any case are accepted.
ns--Nameserver filter, by exact name or parent domain. A post-filter, like cert_to_dns_max.
limit500500Candidates per page.
after--Cursor. Omit it to start. A cursor from another endpoint is a 400.

One page, from a call that also sent include_cert_before_zone=1:

{
  "count":       1,
  "scanned":     100,
  "limit":       500,
  "caught_up":   true,
  "next_cursor": "MTc4ODM3MDIwNx9jb20ubG9vcC1sdW1lbg",
  "window":  { "from": "2026-09-11T06:00:00Z", "to": "2026-09-14T06:00:00Z" },
  "filters": {
    "max_hours_to_cert": 6,
    "cert_to_dns_max":   6,
    "include_cert_before_zone": true
  },
  "coverage": {
    "source_lag_minutes":    15,
    "dns_observed_pct_24h":  36.9,
    "dns_observed_pct_72h":  83.9,
    "dns_observed_pct_168h": 96.1,
    "dns_note":    "... see below ...",
    "signal_note": "... see below ...",
    "source_note": "... see below ..."
  },
  "domains": [
    {
      "domain":              "loop-lumen.com",
      "tld":                 "com",
      "first_zone_seen":     "2026-09-02T06:03:27Z",
      "rdap_created":        "2026-09-01T10:44:00Z",
      "first_cert_seen":     "2026-09-01T11:04:26Z",
      "first_dns_answer":    "2026-09-01T17:40:22Z",
      "dns_status":          "observed",
      "hours_zone_to_cert":  -18.98,
      "hours_cert_to_dns":   6.6,
      "sequence":            "cert_before_zone",
      "cert_id":             "c41f8b02ad6e4470b95cfa2118d7e330",
      "ns": ["teresa.ns.cloudflare.com", "theo.ns.cloudflare.com"]
    }
  ]
}

Why the DNS gap filter drops recent rows

first_dns_answer is when our resolvers first saw the name answer, so our scan latency sets its lower bound rather than the operator's provisioning. Measured over candidates certificated within 6 hours of registration:

WindowCandidates with a DNS observation
last 24 hours36.9%
last 72 hours83.9%
last week96.1%

In a recent window most rows have no DNS leg yet, and cert_to_dns_max excludes them. Those rows carry dns_status: "not_yet_observed", which means we have not recorded an answer. It says nothing about whether the domain resolves.

Widen the window before concluding a tight provisioning gap is absent. The same figures ship in every response under coverage, alongside a 15-minute source lag: the candidate view refreshes on that interval.

The signal is episodic. Sub-hour certification is about 1.1% of certified registrations, against 8.4% taking over a week. An empty page is a real answer about the window you asked for.

Two timestamps, kept separate on purpose

first_zone_seen is our own sighting: when the name first appeared in a zone file. rdap_created is the registry's own creation date, where we hold one.

They are reported separately, and there is deliberately no field called "registered". For a domain with no registry record, such a field would be the zone sighting wearing a registry's name. You would have no way to tell.

hours_zone_to_cert is signed, and sequence names the ordering. cert_before_zone is a certificate issued for a name not yet in any zone file, which is a stronger signal than the ordinary case. It stays off by default so the existing feed contract is unchanged.

scanned counts candidates examined and count counts rows returned. Both post-filters remove rows after the page is drawn, so follow next_cursor until caught_up is true.

Newly registered domains: a gap-free cursor feed

GET https://issued.live/api/v1/nrd

Returns newly registered domains in ascending time order from a cursor. A consumer resumes exactly where it stopped and can never skip an event, however long it was away.

The ordering columns are immutable: a registration event does not get re-registered. That is what lets a bounded window and a cursor coexist here safely.

curl -sS "https://issued.live/api/v1/nrd?certified=1&max_hours_to_cert=1&limit=100" \
  -H "Authorization: Bearer YOUR_KEY"
ParameterDefaultMaxMeaning
after48 hours back-Cursor, and your position within the window. It is what guarantees you never skip or repeat an event. A cursor dated in the future is refused with 400 bad_cursor.
sincethe cursor-Lower bound. RFC3339, YYYY-MM-DD HH:MM:SS, YYYY-MM-DD, or unix; UTC when no zone is given. Whichever of since and your cursor is further along wins, so resuming never re-serves rows.
untilnow-Upper bound, always clamped to now.
tld--Restrict to one TLD. A leading dot and any case are accepted.
limit50500Rows per page. An explicit limit=500 returns 500.
certified--1 or true restricts to NRDs that have already had a certificate issued, a much smaller set.
max_hours_to_cert--Hours from first zone-file appearance to certificate. Requires certified=1; sending it alone is a 400 rather than a filter that silently does nothing.

A domain that appears in a certificate minutes after it is registered was almost certainly registered in order to be certificated. Sub-hour certification runs at about 1.1% of certified registrations, against 8.4% taking over a week.

{
  "certified":   true,
  "count":       100,
  "next_cursor": "1787957711.click.joffulm",
  "caught_up":   false,
  "window":   { "since": "2026-08-28T22:00:00Z", "until": "2026-08-30T22:00:00Z" },
  "coverage": { "from": "2026-07-02T00:00:00Z", "to": "2026-08-30T22:04:00Z",
                "max_span_months": 13 },
  "domains": [
    { "seen_at": "2026-08-28T22:55:11Z", "domain": "joffulm.click", "tld": "click" }
  ]
}
FieldMeaning
next_cursorPass as ?after= on the next call. Always store it, including when caught_up is true.
caught_upTrue when there is nothing further in the window right now. Determined by reading one row past your limit, so it is an observation rather than a guess. It is a pause, so poll again later with the same cursor.
windowThe since and until bounds in force, after defaults and clamping.
coverageThe span this feed can answer for, read from the data on every request. Use it to tell "no domains matched" from "we hold no history that far back".
tldEchoed when you filtered on one.

A window may span 13 calendar months per request

A wider one is refused with 400 window_too_wide naming the limit. The ceiling applies per request and caps nothing about how much history you may read. Set until at most 13 months after since, drain that slice with after, then move both bounds forward.

We refuse rather than silently narrowing your window, because a window you did not ask for is a gap you cannot see. The same ceiling applies to the provisioning feed, which reads a table partitioned the same way.

page, offset, skip, cursor and date are refused with a 400, and all five were once accepted and ignored. cursor is the name most people try first, and it returned page one every time, with a 200. If you have code paging this feed with ?cursor=, it has been reading the same page in a loop; rename it to after.

Draining the feed correctly

import os, time, requests

key    = os.environ["ISSUED_LIVE_KEY"]
cursor = load_cursor()          # persist this; it is the whole point

while True:
    r = requests.get("https://issued.live/api/v1/nrd",
                     params={"certified": 1, "limit": 500, "after": cursor},
                     headers={"Authorization": f"Bearer {key}"}, timeout=30)
    r.raise_for_status()
    page = r.json()

    for d in page["domains"]:
        handle(d["domain"], d["seen_at"])

    # Save the cursor even on a short page. That is how you resume without gaps.
    cursor = page["next_cursor"]
    save_cursor(cursor)

    # caught_up means "nothing more right now". Back off and poll.
    time.sleep(60 if page["caught_up"] else 1)

For history in bulk, the daily files beat draining months through this feed. They are cheaper for both of us.

The pivot workflow: from one certificate to the infrastructure around it

This is the reason to hold a key. One observation is a starting point, and the endpoints above exist to turn it into a set.

What each link is worth

LinkStrengthWhy
Shared addressWeak aloneA CDN address carries millions of unrelated names. Co-location proves shared hosting.
Shared certificate (sans)StrongNames packed onto one certificate were issued together, by one party, for one deployment.
Shared key (spki_sha256)StrongestOne key pair across certificates means one holder of one private key.
Shared nameservers (ns)ModerateStrong when the pair is account-specific, weak when it is a large provider's default.
ja4xCorroborationIt fingerprints the CA's issuance software. 235 distinct values across 20.3 million certificates, so a pivot on it would return a third of Let's Encrypt. We report it and offer no pivot on it.

Why SANs survive a reverse proxy

A certificate's sans array is the list of names it covers, which makes it a linkage that outlives a CDN. A universal-SSL certificate packs several customer zones onto one certificate. Those zones share a certificate we can see, even while they share an address with a million unrelated sites.

Co-tenancy on a certificate usually indicates a relationship. Co-location on an address rarely does.

A worked pivot, from one hostname to the set around it

Start with a single phishing hostname and end with a candidate list, its nameserver pattern and the addresses behind it.

import os, requests

H   = {"Authorization": f"Bearer {os.environ['ISSUED_LIVE_KEY']}"}
api = "https://issued.live/api/v1"
get = lambda path, **kw: requests.get(f"{api}{path}", headers=H, timeout=30, **kw).json()

# 1. The domain record. ssl_cert is the 32-hex cert_id, and it is the bridge.
d = get("/domain/loop-lumen.com")
cert_id = d["record"]["ssl_cert"]
addrs   = [a["ip"] for a in d["addresses"]]

# 2. The certificate. sans is who was issued alongside it; spki_sha256 is the key.
c = get(f"/cert/{cert_id}")
names = set(n.lstrip("*.") for n in c["sans"])
spki  = c.get("spki_sha256")

# 3. Every certificate sharing that key pair. Read coverage before concluding anything.
if spki:
    k = get(f"/spki/{spki}", params={"limit": 500})
    print(k["coverage"])
    for sib in k["certificates"]:
        names.update(n.lstrip("*.") for n in sib.get("sans", []))

# 4. One batch call turns the name set into records, with ns and first_cert_seen.
b = requests.post(f"{api}/domains?include=timing", headers=H, timeout=30,
                  json={"domains": sorted(names)}).json()
ns_seen = {}
for row in b["domains"]:
    if row["status"] != "found":
        continue
    for ns in row["record"].get("ns", []):
        ns_seen[ns] = ns_seen.get(ns, 0) + 1
print(sorted(ns_seen.items(), key=lambda kv: -kv[1])[:5])

# 5. The nameserver pair plus the naming grammar finds the rest of the family.
s = get("/search", params={"name": "loop-*.com", "ns": "ns.cloudflare.com", "limit": 500})
print(s["count"], "names match the grammar on that nameserver")

# 6. And the address, windowed to the hour the first certificate was issued, shows what
#    else was stood up beside it. Serialize these: the gate admits two at a time.
for ip in addrs:
    r = get(f"/ip/{ip}", params={"first_seen_from": c["valid_from"][:10], "limit": 500})
    print(ip, r["count"], "hostnames first seen on or after that date")

Each step narrows or widens deliberately: step 3 widens on the strongest link available. Step 5 widens on a grammar, which needs the nameserver filter to stay honest. Step 6 dates the co-location rather than assuming it.

Keeping the pivot running

Once you have a nameserver pair and a grammar, the two feeds turn a finished investigation into a standing one. Poll /api/v1/provisioning?ns= for new names on the same infrastructure, and /api/v1/nrd?certified=1&max_hours_to_cert=1 for registrations that took a certificate within the hour.

Store the cursor from each and resume from it. Both feeds are gap-free from a cursor, which is what makes a scheduled job safe to miss a run.

Errors on keyed endpoints

Errors are JSON with an error code and a human-readable message, always sent Cache-Control: no-store. Retryable ones also carry "retryable": true and a Retry-After.

StatusCodeWhat to do
400bad_requestMalformed input, a range wider than 256 addresses, an unbounded character class, a reversed time window, or a pagination parameter this endpoint does not implement. Fix the input; retrying will not help.
400bad_cursorOnly on /api/v1/nrd: the cursor does not parse, or it is dated in the future. Drop it to restart from the window floor.
400window_too_wideOn /api/v1/nrd and /api/v1/provisioning: the window spans more than 13 calendar months. The message names the limit; walk the range in slices.
401unauthorizedMissing or unrecognized key. Every endpoint on this page can answer it. The unkeyed endpoints take no key and never do.
404not_foundNothing observed for that domain or certificate. From /api/v1/cert/ it also covers an id whose detail has passed the 14-day retention horizon. An unmatched key hash is not one of these: /api/v1/spki/ answers 200 with count: 0, because a key we never hashed and a key nobody reused look identical from a 404.
405method_not_allowedUse GET, except on /api/v1/domains, which is POST. Every endpoint except /api/v1/ip/ and /api/v1/range/ also sends an Allow header naming the method that works.
413too_largeOnly on /api/v1/domains: the body is over 512 KiB, or the connection ended before it was complete.
415bad_requestOnly on /api/v1/domains: send Content-Type: application/json.
429rate_limitedOver the per-minute ceiling for your plan (300 on Pro). Retry-After carries the seconds until the window rolls. There is no daily cap to exhaust.
503timeoutThe query did not finish in time, or both of your account's concurrency slots were already taken. Expected on large addresses, wide ranges and unanchored searches. Branch on retryable and Retry-After rather than on the status number. Retry once, then narrow the range, the window or the pattern.
500internal_errorOur side. Retry once, then tell us.

The timeout status is 503, and it stays 503. This origin sits behind a CDN that replaces origin-generated gateway statuses with its own error page, so a 504 reaches you as a few bytes of edge HTML with our JSON body and headers stripped. Measured one day apart on the same code path: at 500 the consumer received our body verbatim; at 504 they received 16 bytes of "error code: 504".

Caps and defaults in one table

EndpointPage defaultPage maxOther caps
/api/v1/ip/{ip}50050000NDJSON at 5000 or above.
/api/v1/range/{cidr}50050000256 addresses, an IPv4 /24.
/api/v1/search5001000Pattern 128 chars, 8 labels, class count bounded at 64.
POST /api/v1/domains--500 domains, 100 with any include, 10 sub-entries each, 512 KiB body.
/api/v1/domain/{domain}--50 addresses, 50 certificates.
/api/v1/cert/{hash}--32 or 64 hex. Detail queryable 14 days; older days live in the archived files.
/api/v1/spki/{sha256}50050064 hex. Coverage begins 2026-09-07.
/api/v1/provisioning500500max_hours_to_cert clamped to 168; window 13 months.
/api/v1/nrd50500Window 13 months.

Per key: no daily cap, one advanced query in flight on Plus and two on Pro, and 300 (Plus) or 1,200 (Pro) requests a minute on the public record API. Pricing lists what each plan reaches.

Last updated September 15, 2026. Start at the documentation hub for the other surfaces, or get in touch with anything these pages do not answer.

↑ Top