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
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"
| Parameter | Default | Max | Meaning |
|---|---|---|---|
limit | 500 | 50000 | Hosts 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
}
]
}
| Field | Meaning |
|---|---|
query | The address or CIDR as it was parsed. |
addresses | How many addresses the query covered. 1 for /api/v1/ip/, up to 256 for a range. |
count | Rows in this response. |
truncated | True when limit was reached and more data exists. |
order | Which ordering contract produced this page. See below. |
next_cursor | Present only when another page exists. Its absence ends the walk. |
first_seen_from, first_seen_to | Echoed when you sent a window, so you can see it took effect. |
hosts[].observed | How 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:
- These observations begin in August 2026. A window earlier than that returns nothing, because we were not looking yet. An empty page is an answer about the window you asked for. Check the window before concluding the address was unused.
- It is a minimum over everything we have seen, so it can move earlier when a late-arriving observation carries an older timestamp. A hostname can therefore leave a window it previously satisfied. Treat a windowed result as a snapshot of what we knew when you asked. Paging stays safe: the cursor walks an immutable key, so nothing slips behind it mid-walk.
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"
order | When | Sorted by | Resumable |
|---|---|---|---|
recent | no after parameter | last_seen descending | No |
key | after present, even empty | (ip, rd_rev, fqdn_rev) ascending | Yes |
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);
}
Pattern search: find registrable domains whose name fits a grammar
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"
| Parameter | Default | Max | Meaning |
|---|---|---|---|
name | required | 128 chars | The pattern. At most 8 labels. A missing or empty value is a 400. |
limit | 500 | 1000 | Candidates 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_from | 30 days back | - | Switches to window mode. Same time formats as the reverse-IP window. |
first_seen_to | now | - | Upper bound in window mode, always clamped to now. |
The pattern language
Write the pattern forwards, the way you say the name. Per label:
| Syntax | Matches | Example |
|---|---|---|
* | Zero or more of a-z, 0-9, - | *agent.com |
? | Exactly one of those characters | loop?.com |
[a-z]{4,10} | A counted run from a character class | [a-z]{4,10}agent.com |
| literal | Itself | loop-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:
| Pattern | Anchored | Scan | Cost |
|---|---|---|---|
loop-*.com | yes, com.loop- | 0.03 s | cheap |
[a-z]{4,10}agent.com | yes, com. | 0.7 s | cheap |
*agent.* | no | 1-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
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"]}'
| Parameter | Where | Meaning |
|---|---|---|
domains | body | Array of names. At least one, or the reply is a 400. |
include | query | Comma-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. |
| Cap | Value | Why |
|---|---|---|
| Domains per request | 500 | The reply is assembled in memory. |
Domains with any include | 100 | Each domain then carries up to three sub-lists, and 500 of those is the shape that will not buffer. |
| Sub-list entries per domain | 10 | Same reason. |
| Request body | 512 KiB | 500 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.
| Status | Meaning |
|---|---|
found | We hold a record. |
not_found | We looked and hold nothing. |
rejected | The 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
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.
precision | Source | What the gap means |
|---|---|---|
exact | Certificate Transparency | not_before is the issuing CA's own clock, accurate to the second. A gap between two certificate events is the subject's behavior. |
day | Registry and zone files | Dated to the day. Gaps under 24 hours carry no information. |
observed | Our resolvers | The 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
event | Meaning |
|---|---|
domain_registered | Registry creation date. Absent where we hold none. |
first_seen_in_zone | First appearance in a zone file we load. |
certificate_issued | One issuance, with issuer, how many names on this domain it covered, and its validity in days. Precertificate and final certificate are one event. |
addresses_observed | One or more addresses first seen at that moment. A rotation that swaps eight addresses at once is one event, not eight. |
dns_<type>_changed | A 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
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"
| Part | Cap | Meaning |
|---|---|---|
record | one | The same fields the public API returns for the name. |
registration_timing | one | Present when the name was observed as newly registered. Omitted otherwise. |
addresses | 50 | Address history, newest first. |
certificates | 50 | Certificates 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
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.
| Length | Field | What it is |
|---|---|---|
| 32 hex | cert_id | Our 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 hex | cert_sha256 | SHA-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
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.
| Parameter | Default | Max | Meaning |
|---|---|---|---|
| path segment | required | 64 hex | The key hash. Any other length is a 400. |
limit | 500 | 500 | Certificates 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
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"
| Parameter | Default | Max | Meaning |
|---|---|---|---|
max_hours_to_cert | 6 | 168 | Hours 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. |
since | 72 hours back | - | Start of the candidate window. RFC3339, YYYY-MM-DD HH:MM:SS, YYYY-MM-DD, or unix. |
until | now | - | End of it, always clamped to now. At most 13 calendar months per request. |
include_cert_before_zone | off | - | 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. |
limit | 500 | 500 | Candidates 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:
| Window | Candidates with a DNS observation |
|---|---|
| last 24 hours | 36.9% |
| last 72 hours | 83.9% |
| last week | 96.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
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"
| Parameter | Default | Max | Meaning |
|---|---|---|---|
after | 48 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. |
since | the 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. |
until | now | - | Upper bound, always clamped to now. |
tld | - | - | Restrict to one TLD. A leading dot and any case are accepted. |
limit | 50 | 500 | Rows 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" }
]
}
| Field | Meaning |
|---|---|
next_cursor | Pass as ?after= on the next call. Always store it, including when caught_up is true. |
caught_up | True 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. |
window | The since and until bounds in force, after defaults and clamping. |
coverage | The 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". |
tld | Echoed 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
| Link | Strength | Why |
|---|---|---|
| Shared address | Weak alone | A CDN address carries millions of unrelated names. Co-location proves shared hosting. |
Shared certificate (sans) | Strong | Names packed onto one certificate were issued together, by one party, for one deployment. |
Shared key (spki_sha256) | Strongest | One key pair across certificates means one holder of one private key. |
Shared nameservers (ns) | Moderate | Strong when the pair is account-specific, weak when it is a large provider's default. |
ja4x | Corroboration | It 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.
| Status | Code | What to do |
|---|---|---|
| 400 | bad_request | Malformed 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. |
| 400 | bad_cursor | Only on /api/v1/nrd: the cursor does not parse, or it is dated in the future. Drop it to restart from the window floor. |
| 400 | window_too_wide | On /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. |
| 401 | unauthorized | Missing or unrecognized key. Every endpoint on this page can answer it. The unkeyed endpoints take no key and never do. |
| 404 | not_found | Nothing 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. |
| 405 | method_not_allowed | Use 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. |
| 413 | too_large | Only on /api/v1/domains: the body is over 512 KiB, or the connection ended before it was complete. |
| 415 | bad_request | Only on /api/v1/domains: send Content-Type: application/json. |
| 429 | rate_limited | Over 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. |
| 503 | timeout | The 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. |
| 500 | internal_error | Our 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
| Endpoint | Page default | Page max | Other caps |
|---|---|---|---|
/api/v1/ip/{ip} | 500 | 50000 | NDJSON at 5000 or above. |
/api/v1/range/{cidr} | 500 | 50000 | 256 addresses, an IPv4 /24. |
/api/v1/search | 500 | 1000 | Pattern 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} | 500 | 500 | 64 hex. Coverage begins 2026-09-07. |
/api/v1/provisioning | 500 | 500 | max_hours_to_cert clamped to 168; window 13 months. |
/api/v1/nrd | 50 | 500 | Window 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