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. A streamed (NDJSON) reverse-IP or range answer takes a slot too, and holds it until its trailer. 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.
A stream only opens once it has a query slot. When every slot is busy the answer is a
503 with Retry-After, sent before any NDJSON, exactly as on the
non-streamed path. So a header line with no trailer always means a failure, never
"busy" and never "empty".
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 does not fail the whole call: its sub-list is left out and its name is listed in
include_failed. An empty or missing sub-list reads as "none held" only when its
name is not in include_failed.
Each address is the same object /api/v1/domain/{domain}
returns, built by the same code: current and network mean exactly
what they mean there. With include=addresses the response also carries
network_lookup, counted over every address on the page. It is present whenever
addresses was asked for and is not in include_failed, with zero
counts when there was nothing to look up. A batch of 100 names can carry up to 1,000
addresses, and a cold one may come back with some unavailable while lookups
finish. They are counted, never silently missing, and repeating the call a few seconds later
picks up whatever finished in the meantime.
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": 2,
"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 48 hours, 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,
"network": { "status": "found", "asn": 16276, "asn_name": "OVH SAS",
"org": "OVH SAS", "cidr": "178.32.112.0/20",
"country_code": "FR", "ptr": "ip215.ip-178-32-125.eu" } }
],
"network_lookup": { "source": "worldip.io", "found": 1, "none": 0, "unavailable": 0 },
"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. When a name answers from several addresses
recorded in the same sweep, all of them are marked current.
network on every address is the network behind it, from
worldip.io: the ASN and its name, the organization, the
announced range, the country and the reverse DNS. It is always an object, and its
status says which of three things happened:
status | Meaning |
|---|---|
found | worldip.io holds a record, and the fields are what it holds. A field it does not hold is left out. |
none | worldip.io answered and holds nothing for that address. That is a real answer. IPv6 addresses report none today: worldip.io's API does not carry network data for them. |
unavailable | No answer in time for this response. The lookup may still be running, may have failed, or may not have been started because worldip.io was failing. It says nothing about the address. A lookup that was started finishes on its own and is cached, so repeating the call a few seconds later usually fills it. |
network_lookup counts the three across the response, so a partial answer is
visible without walking the list. Filter on network.status before
network.asn, or an unavailable address will look like one on a different network.
addresses is null, not [], when the address history
could not be read for this response, and network_lookup is then absent. An empty
list means none held. The same distinction applies to certificates.
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: 48 hours live, forever in cold storage — and Pro searches both
Full certificate rows and the name-to-certificate index are queryable live for 48 hours. Registration data in the domain record has no expiry; it is only the certificate detail that ages out of the live database.
Nothing is lost when it does, and on Pro nothing changes about how you ask.
Every closed UTC day is archived as a Parquet file and kept indefinitely. When a lookup misses
the live window this same endpoint searches those files and returns the certificate in the same
shape, with an X-Source: archive/YYYY-MM-DD header naming the day whose file
answered. There is no second endpoint to integrate.
| Parameter | What it searches |
|---|---|
| none | The most recent 7 archived days — anything observed recently. |
?day=YYYY-MM-DD | One day's file. Cheapest and fastest; use it whenever you know the date. |
?from=&to= | A range, up to 31 days. Wider is refused, not silently trimmed: searching 31 of 400 requested days and answering "not found" would be a wrong answer wearing the costume of a right one. |
curl -sS "https://issued.live/api/v1/cert/d361e1c7f2c65f53d6df9032a2b9c325?day=2026-09-24" \
-H "Authorization: Bearer YOUR_KEY" -D-
The day is the day we first observed the certificate, not the day it was
issued, and those differ far more than you would expect. Measured across 407 million
certificates: only 23% are first seen within a day of not_before,
the median lag is 3 days and the 99th percentile is 65. Certificate Transparency backfill means
a certificate issued in June can first reach us in September — so not_before
is not a usable hint for choosing a day, and we would rather say that than let you build on it.
Two things to know about an archived reply. last_observed is the last time we saw
the certificate within the day that file covers, not the last time ever; current
recency is a live-window question. And a cold lookup takes seconds rather than milliseconds,
because the file is read directly out of object storage — they are capped at two in
flight across all callers, so treat them as a research path, not a hot loop.
Interactive archive coverage begins 2026-09-09. Days before that were archived in a format with no row-group statistics, so there is nothing to prune on and a lookup would mean downloading the whole object. Those days are safe in cold storage and can be sent as files; they are simply not searchable through this endpoint.
Basic and Plus keys get the live window only. Their 404 says the archive exists
and which plan reaches it, rather than implying the certificate never existed.
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/ means the certificate was not found in the
live window nor in whichever archived days were searched — so on Pro, widen the
window with ?day= or ?from=&to= before concluding it never 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.
What it searches: the last 48 hours only. The pivot reads the live certificate table, which keeps certificates for 48 hours after we first observe them. The permanent archive holds every day since 2026-09-09, but it is not searched by key, so a key used only on certificates older than that returns an empty answer here — which is "not searched", not "not reused". Certificates ingested before 7 September 2026 carry no key hash at all: the field did not exist yet, and it cannot be backfilled because deriving it needs the certificate bytes, which are not what we retain.
| 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"]
}
],
"searched": "live",
"coverage": "Searched: certificates first observed in the last 48 hours (the live certificate table). Archived certificates are not searched by key, so a key seen only before that returns nothing here; and certificates ingested before 2026-09-07 carry no recorded key hash at all."
}
Read the coverage field before drawing a conclusion. A small or
empty result means "few matches among certificates first observed in the last
48 hours". Treating it as evidence that the key is unused elsewhere would overstate
it: the key may sit on archived certificates this pivot does not search. The field ships on
every response, so that distinction is never left to inference.
Certificates on an address: every certificate issued for the hostnames seen on one IP
Pro. The hostnames are every name our resolvers have seen on the address, the same set
reverse IP returns. A certificate is listed when one of its SAN
entries names one of those hostnames exactly, or is the wildcard one label above it:
*.example.com covers www.example.com, but not
example.com and not a.b.example.com. The list is in issuance order
(valid_from).
It is not "certificates whose SAN is this IP address": IP-address SANs are not collected. And because a shared host's tenants each bring their own certificates, sharing an address is not evidence that two certificates belong to one party.
curl -sS "https://issued.live/api/v1/ip/192.0.2.10/certs?from=2026-10-01&to=2026-10-07" \
-H "Authorization: Bearer YOUR_KEY"
| Parameter | Default | Max | Meaning |
|---|---|---|---|
from, to | the last 7 days | 15 days | UTC days, YYYY-MM-DD, given together. This is the window of first observation in CT, not of issuance: a certificate is filed under the day we first saw it, which for backfilled logs can be weeks after valid_from. to after today is clamped to today. |
issuer | - | - | One issuer organization, exact match, any case: Let's Encrypt. |
wildcard | both | - | true keeps certificates that reach a hostname here through a wildcard SAN; false through an exact one. |
order | asc | - | asc is oldest first; desc newest first. |
limit | 100 | 500 | Certificates per page. |
after | - | - | Cursor: the previous reply's next_cursor. Absent next_cursor is the end. |
{
"ip": "192.0.2.10",
"window": { "from": "2026-10-01", "to": "2026-10-07", "narrowed": false },
"order": "asc",
"hostnames": { "count": 14, "domains": 11 },
"coverage": {
"archive_days": ["2026-10-01", "2026-10-02", "2026-10-03", "2026-10-04", "2026-10-05", "2026-10-06"],
"live_days": ["2026-10-07"],
"unavailable_days": [],
"index_from": "2026-09-09"
},
"filters": {},
"budget": { "domain_days": 66, "max": 100 },
"count": 1,
"certificates": [
{
"cert_id": "0123456789abcdef0123456789abcdef",
"issuer_org": "Sectigo Limited",
"valid_from": "2026-10-01T00:00:00Z",
"valid_until": "2027-10-01T23:59:59Z",
"first_observed": "2026-10-01T04:12:09Z",
"san_count": 2,
"wildcard": false,
"hostnames": ["www.example.com"],
"source": "archive",
"archive_day": "2026-10-01"
}
]
}
hostnames on a certificate are the address's own names it covers, not its whole
SAN list; san_count says how many names the certificate holds, and
certificate lookup returns them all. Pass archive_day as
?day= there and the lookup reads that one day instead of searching a window.
Where each day is answered from, and what coverage tells you
Closed days come from the permanent archive's per-day hostname index
(archive_days). Today, and a day the nightly archive has not reached yet, come
from the live tables (live_days), which hold the last week. A day in neither
— before index_from, or a missed night older than a week — is listed in
unavailable_days and was not searched. Read an empty list against
coverage: "nothing was issued" and "this day was not searched" are different
answers.
The window budget
Each archived day searched costs one read per registrable domain on the address, so a
request may spend 100 domain-days: 15 days for an address with up to 6 domains, 1 day
for one with 100. An address with more than 100 registrable domains cannot search archived days
at all; its live days (the last 6) are still searched, and the archived ones are listed in
coverage.unavailable_days. The default window is narrowed from its oldest end to fit, and says
so with window.narrowed; an explicit from/to over the
budget is a 400 window_too_wide naming the widest span that fits. Addresses with
more than 5,000 hostnames — CDN, parking and large shared-hosting fronts — are a 400
too_many_hostnames: the certificates "for" such an address are not a meaningful
set, so look up the domains you care about with /api/v1/domain/.
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. |
| 400 | window_too_wide | On /api/v1/ip/{address}/certs: the archived days in from..to times the address's registrable domains exceed the 100 domain-day budget. The message names the widest span that fits. |
| 400 | too_many_hostnames | On /api/v1/ip/{address}/certs: more than 5,000 hostnames have resolved to the address (CDN, parking, shared-hosting front). |
| 400 | too_broad | On /api/v1/ip/{address}/certs: the days not yet archived hold too many candidate certificates to answer live. Narrow from/to to archived days. |
| 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 found neither in the live window nor in the archived days searched — widen those with ?day= or ?from=&to= before reading it as absence. 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 Plus, 1,200 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. Live for 48 hours; Pro searches the permanent archive beyond that (?day=, ?from=&to=). |
/api/v1/spki/{sha256} | 500 | 500 | 64 hex. Coverage begins 2026-09-07. |
/api/v1/ip/{address}/certs | 100 | 500 | Window 7 days by default, 15 at most, and 100 domain-days; 5,000 hostnames per address. |
/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