API documentation
Look up Certificate Transparency records, SSL certificates and DNS for any domain. Free, no key required for domain lookups.
Quickstart
One HTTP GET returns everything we know about a domain. No key, no sign-up, no
Authorization header:
$ curl https://issued.live/github.com
The same URL serves a human-readable page to a browser and JSON to everything else, based
on the Accept header. To force JSON explicitly:
$ curl -H "Accept: application/json" https://issued.live/github.com
Domain lookup
Returns the current certificate, issuer, expiry, hosting address and registration dates
for a registrable domain. Subdomains are accepted and resolved to their registrable domain
(www.github.com and github.com return the same record).
Authentication: none. Method: GET.
Example response
{
"domain": "github.com",
"registered": null,
"expires": null,
"ssl_expires": "2026-12-26T23:59:59Z",
"ssl_cert": "9F53ECDDF913AFBA5678C924A95149D2",
"ssl_issuer": "Amazon",
"ip": "52.251.114.96",
"last_update": "2026-08-29T07:46:54Z",
"first_cert_seen": "2026-08-06T21:19:30Z",
"tracked": false
}
Response fields
| Field | Type | Meaning |
|---|---|---|
domain | string | The registrable domain the record describes. |
registered | string | null | Registration date, where we hold one. null when we do not — never guessed or inferred from certificate dates. |
expires | string | null | Registration expiry, where we hold one. Same null semantics. |
ssl_expires | string | null | Not-after date of the most recently observed certificate. |
ssl_cert | string | null | Certificate identifier, hex. Stable per certificate. |
ssl_issuer | string | null | Issuing CA organization, as it appears in the certificate. |
ip | string | null | An address observed for the domain by our own resolvers. |
last_update | string | When we last updated this record. Use this to judge freshness. |
first_cert_seen | string | First time any CT log carried a certificate for this name. Not a registration date — an old domain that only recently enabled HTTPS shows a recent value here. |
tracked | boolean | Whether the domain is on our active monitoring list. |
All timestamps are RFC 3339 in UTC. Keys are returned in the order shown above.
Code examples
Every example below fetches the record for github.com and prints its
certificate issuer and expiry.
curl
curl -sS -H "Accept: application/json" https://issued.live/github.com
PHP
<?php
$domain = 'github.com';
$ch = curl_init("https://issued.live/{$domain}");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['Accept: application/json'],
CURLOPT_TIMEOUT => 10,
]);
$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($status !== 200) {
fwrite(STDERR, "lookup failed: HTTP {$status}\n");
exit(1);
}
$d = json_decode($body, true);
printf("%s: %s, expires %s\n", $d['domain'], $d['ssl_issuer'] ?? 'unknown',
$d['ssl_expires'] ?? 'unknown');
Node.js
// Node 18+ has fetch built in; no dependencies needed.
const domain = 'github.com';
const res = await fetch(`https://issued.live/${domain}`, {
headers: { Accept: 'application/json' },
signal: AbortSignal.timeout(10_000),
});
if (!res.ok) throw new Error(`lookup failed: HTTP ${res.status}`);
const d = await res.json();
console.log(`${d.domain}: ${d.ssl_issuer ?? 'unknown'}, expires ${d.ssl_expires ?? 'unknown'}`);
Python
import requests
domain = "github.com"
r = requests.get(f"https://issued.live/{domain}",
headers={"Accept": "application/json"}, timeout=10)
r.raise_for_status()
d = r.json()
print(f"{d['domain']}: {d.get('ssl_issuer') or 'unknown'}, "
f"expires {d.get('ssl_expires') or 'unknown'}")
Go
package main
import (
"encoding/json"
"fmt"
"net/http"
"time"
)
type Record struct {
Domain string `json:"domain"`
SSLIssuer *string `json:"ssl_issuer"`
SSLExpires *string `json:"ssl_expires"`
}
func main() {
client := &http.Client{Timeout: 10 * time.Second}
req, _ := http.NewRequest("GET", "https://issued.live/github.com", nil)
req.Header.Set("Accept", "application/json")
resp, err := client.Do(req)
if err != nil {
panic(err)
}
defer resp.Body.Close()
var r Record
if err := json.NewDecoder(resp.Body).Decode(&r); err != nil {
panic(err)
}
// SSLIssuer is *string: %v on it prints the ADDRESS, not the value.
issuer := "unknown"
if r.SSLIssuer != nil {
issuer = *r.SSLIssuer
}
fmt.Printf("%s: %s\n", r.Domain, issuer)
}
Java
// Java 11+ — java.net.http, no external dependencies.
import java.net.URI;
import java.net.http.*;
import java.time.Duration;
public class Lookup {
public static void main(String[] args) throws Exception {
HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10)).build();
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create("https://issued.live/github.com"))
.header("Accept", "application/json")
.timeout(Duration.ofSeconds(10))
.GET().build();
HttpResponse<String> res =
client.send(req, HttpResponse.BodyHandlers.ofString());
if (res.statusCode() != 200) {
throw new RuntimeException("lookup failed: HTTP " + res.statusCode());
}
System.out.println(res.body());
}
}
Ruby
require 'net/http'
require 'json'
uri = URI('https://issued.live/github.com')
req = Net::HTTP::Get.new(uri, 'Accept' => 'application/json')
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true, read_timeout: 10) do |http|
http.request(req)
end
abort "lookup failed: HTTP #{res.code}" unless res.is_a?(Net::HTTPSuccess)
d = JSON.parse(res.body)
puts "#{d['domain']}: #{d['ssl_issuer'] || 'unknown'}, expires #{d['ssl_expires'] || 'unknown'}"
C#
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text.Json;
using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(10) };
client.DefaultRequestHeaders.Accept.Add(
new MediaTypeWithQualityHeaderValue("application/json"));
var res = await client.GetAsync("https://issued.live/github.com");
res.EnsureSuccessStatusCode();
using var doc = JsonDocument.Parse(await res.Content.ReadAsStringAsync());
var root = doc.RootElement;
Console.WriteLine($"{root.GetProperty("domain").GetString()}: " +
$"{root.GetProperty("ssl_issuer")}");
Rust
// Cargo.toml: reqwest = { version = "0.12", features = ["json", "blocking"] }
use std::time::Duration;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let client = reqwest::blocking::Client::builder()
.timeout(Duration::from_secs(10))
.build()?;
let body: serde_json::Value = client
.get("https://issued.live/github.com")
.header("Accept", "application/json")
.send()?
.error_for_status()?
.json()?;
println!("{}: {}", body["domain"], body["ssl_issuer"]);
Ok(())
}
PowerShell
$d = Invoke-RestMethod -Uri 'https://issued.live/github.com' `
-Headers @{ Accept = 'application/json' } `
-TimeoutSec 10
"{0}: {1}, expires {2}" -f $d.domain, $d.ssl_issuer, $d.ssl_expires
Bash + jq
#!/usr/bin/env bash
# Check a list of domains and print any certificate expiring within 30 days.
set -euo pipefail
cutoff=$(date -u -d '+30 days' +%s)
while read -r domain; do
json=$(curl -sS --max-time 10 -H 'Accept: application/json' "https://issued.live/${domain}") || continue
exp=$(jq -r '.ssl_expires // empty' <<<"$json")
[ -z "$exp" ] && continue
if [ "$(date -u -d "$exp" +%s)" -lt "$cutoff" ]; then
echo "$domain expires $exp"
fi
sleep 0.2 # be polite
done < domains.txt
Reverse IP lookup
These endpoints require an API key. They answer the reverse question: which domains resolve to a given address.
Authenticate with a bearer token. Never put the key in the query string
— query strings land in access logs, proxy logs and Referer headers:
curl -sS https://issued.live/api/v1/ip/157.240.17.8 \
-H "Authorization: Bearer YOUR_KEY"
For a range, write the prefix slash as a hyphen — a literal slash is a path separator and will not survive routing:
curl -sS https://issued.live/api/v1/range/157.240.17.0-24 \
-H "Authorization: Bearer YOUR_KEY"
| Parameter | Default | Max | Meaning |
|---|---|---|---|
limit | 500 | 5000 | Maximum hosts returned. Out-of-range values are clamped, not rejected. |
Ranges are capped at 256 addresses (an IPv4 /24). This is an engineering limit, not a licensing one: our records are stored by domain, so address lookups use a bloom-filter index that can answer “is this address present” but cannot answer a range predicate. A CIDR is therefore expanded into individual addresses. To sweep something larger, iterate a /24 at a time and serialize the requests — running them concurrently makes each slower rather than finishing sooner.
A single-address lookup typically returns in well under a second. A range on data we have not read recently can take ten seconds or more; the same range is fast once warm. Cache results on your side — this data changes over hours, not seconds.
Example response
{
"query": "157.240.17.8",
"addresses": 1,
"count": 2,
"truncated": false,
"limit": 500,
"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
}
]
}
truncated: true means limit was reached and more data exists.
There is no pagination cursor yet; raise limit toward 5000, and
tell us if that is still not enough.
Python, with a key
import os, requests
key = os.environ["ISSUED_LIVE_KEY"] # never hard-code a key
r = requests.get("https://issued.live/api/v1/ip/157.240.17.8",
headers={"Authorization": f"Bearer {key}"},
timeout=30) # ranges can be slow; allow for it
r.raise_for_status()
for h in r.json()["hosts"]:
print(h["hostname"], h["last_seen"])
Node.js, iterating a larger range
const key = process.env.ISSUED_LIVE_KEY;
// Serialized on purpose — concurrency does not help here.
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),
});
if (res.status === 504) { 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);
}
Live feeds
Certificates as they are logged
A rolling 30-minute window of certificates, newest first. No key required.
curl -sS "https://issued.live/api/feed?limit=20"
{
"window": "30m",
"count": 20,
"certificates": [
{ "seen_at": "2026-08-29T17:05:34Z", "issuer": "Let's Encrypt",
"name": "brobola238.ph", "name_count": 1 }
]
}
This is a window, not a cursor. Poll it and you will see what is current; you will not reliably see everything that happened between polls. If you need gap-free delivery, use the NRD feed below, which is cursor-paged.
Newly registered domains — cursor feed
Requires an API key. Returns newly registered domains in ascending time order from a cursor, so a consumer resumes exactly where it stopped and can never skip an event, however long it was away.
Add certified=1 to restrict to newly registered domains that have
already had a certificate issued — a much smaller and considerably more
interesting set.
curl -sS "https://issued.live/api/v1/nrd?certified=1&limit=100" \
-H "Authorization: Bearer YOUR_KEY"
{
"certified": true,
"count": 100,
"next_cursor": "1787957711.click.joffulm",
"caught_up": false,
"domains": [
{ "seen_at": "2026-08-28T22:55:11Z", "domain": "joffulm.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 fewer rows came back than limit — you have reached the present. Not an end: poll again later with the same cursor. |
after | Request parameter. Omit it on a first call and the feed starts 48 hours back. |
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", not "finished". Back off and poll.
time.sleep(60 if page["caught_up"] else 1)
AI agents, MCP and markdown
Every domain record is available as markdown by appending
.md, which is easier for a language model to quote accurately than a page
wrapped in navigation and CSS:
curl -sS https://issued.live/github.com.md
/llms.txt is the machine-readable map of what is
worth fetching. Markdown URLs are noindex and canonicalize to the HTML page,
so they are a second representation rather than duplicate content.
Browser MCP
If you drive a browser from an assistant with Browser MCP or a similar Model Context Protocol tool, issued.live works without any special handling: the pages are server-rendered HTML with no client-side rendering step and nothing behind a login. Two things make agent use cheaper:
- Prefer the
.mdor JSON form over screen-scraping the page. Same data, a fraction of the tokens, and no layout to break your parser. - The root speaks plain text.
curl issued.livereturns a short usage summary rather than a JSON dump, so an agent that lands on the homepage can read what to do next without being handed 140 KB of statistics.
There is no MCP server for issued.live yet. If you build one, tell us and we will link it here.
Statistics
Corpus-wide counts: domains known, certificates seen in the last 24 hours, ingest rate, per-TLD breakdown and log health. No key required. Served from a snapshot regenerated every ten minutes, so it is cheap to poll but never second-fresh.
curl -sS https://issued.live/api/stats | jq '.totals'
Errors
Errors are JSON with an error code and a human-readable
message, and are always sent Cache-Control: no-store.
| Status | Code | What to do |
|---|---|---|
| 400 | bad_request | Malformed domain, IP or CIDR, or a range wider than 256 addresses. Fix the input; retrying will not help. |
| 401 | unauthorized | Missing or unrecognized key. Only the keyed endpoints answer this — /api/v1/ip/{ip}, /api/v1/range/{cidr} and /api/v1/nrd. Domain lookups, /api/feed and /api/stats take no key and never return 401. |
| 404 | — | We have no record for that domain. This is not an error in your request; it usually means we have never seen a certificate for it. |
| 405 | method_not_allowed | Use GET. |
| 429 | rate_limited | Daily free-tier cap reached for your client address. Retry-After carries the seconds until the 00:00 UTC reset — wait it out rather than retrying on a fixed interval. See Rate limits. |
| 504 | timeout | The lookup did not finish in time. Normal on ranges, not an outage. Retry once, then narrow the range. |
| 500 | internal_error | Our side. Retry once, then report it. |
Rate limits
The free, unauthenticated API is capped at 1,000 requests per day per client address. The counter resets at 00:00 UTC. That is generous enough that an interactive visitor will never reach it and a script doing real work rarely will; it exists so one scraper cannot monopolize a shared backend.
Every response to a capped endpoint carries the current state:
| Header | Meaning |
|---|---|
X-RateLimit-Limit | Your daily allowance. |
X-RateLimit-Remaining | Requests left today. |
X-RateLimit-Reset | Unix timestamp of the next reset. |
X-RateLimit-Warning | Sent when fewer than 100 remain, so you find out before you are cut off rather than at the moment you are. |
Over the cap you get 429 with Retry-After set to the seconds
remaining until reset. Read that header rather than retrying on a fixed
interval — retrying sooner just earns another 429.
$ curl -sS -D - -o /dev/null https://issued.live/github.com | grep -i ratelimit
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 998
X-RateLimit-Reset: 1788048000
What is not capped
- Keyed endpoints (
/api/v1/*) and the partner export. They carry a credential and are metered per consumer instead. /health,/robots.txt,/llms.txt, the favicon and/static/. Capping page furniture would spend a visitor's allowance on assets their browser fetched for them.
Etiquette
- Need more? Ask. A key raises this considerably and costs nothing — we would rather know who you are than watch an anonymous address hit the ceiling every day.
- Cache on your side. Records change over hours; re-fetching the same domain in a loop gets you the same bytes.
- Serialize bulk work and add a small delay between requests. A steady trickle is always welcome; a burst is what forces limits to exist.
- Send a
User-Agentthat identifies you and gives us a way to make contact. If something you are doing causes a problem, that header is how we ask you about it rather than simply blocking you.
Frequently asked questions
Is the API free?
Domain lookups are free and need no key or sign-up. Reverse-IP lookups need a key; ask and say what you are building.
Why are registered and expires null?
Because we do not hold registration data for that domain. We never infer these from certificate dates. A null means “we do not know”, not “no such date”.
Is first_cert_seen the registration date?
No, and conflating the two is the most common mistake made with this data. It is the
first time any Certificate Transparency log carried a certificate for the name. A
domain registered in 2009 that enabled HTTPS last month shows a recent
first_cert_seen and an old registered.
How fresh is the data?
Certificate Transparency logs are read continuously, so certificates appear within
minutes of being logged. DNS and address data is resolved on a sweep and can be hours old.
Every record carries last_update — use it rather than assuming.
Do you return every certificate for a domain, or the current one?
The lookup returns the most recently observed certificate. Full per-domain certificate history is not exposed through the API yet.
What counts as a domain?
Requests are resolved to the registrable domain using the Public Suffix List, so
www.example.co.uk and example.co.uk return the same record.
Can I use this commercially?
Yes, within the terms. The one thing we ask you not to do is present the data as coming from a domain's owner, a registry, or a certificate authority — it is our observation of public logs, nothing more.
Is there an SDK?
No, and there does not need to be. It is one GET returning flat JSON; every example above uses the language's own standard library or its most common HTTP client.
Last updated August 30, 2026. Questions the docs do not answer: get in touch.