Threat intel pipeline running

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

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

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

FieldTypeMeaning
domainstringThe registrable domain the record describes.
registeredstring | nullRegistration date, where we hold one. null when we do not — never guessed or inferred from certificate dates.
expiresstring | nullRegistration expiry, where we hold one. Same null semantics.
ssl_expiresstring | nullNot-after date of the most recently observed certificate.
ssl_certstring | nullCertificate identifier, hex. Stable per certificate.
ssl_issuerstring | nullIssuing CA organization, as it appears in the certificate.
ipstring | nullAn address observed for the domain by our own resolvers.
last_updatestringWhen we last updated this record. Use this to judge freshness.
first_cert_seenstringFirst 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.
trackedbooleanWhether 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.

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

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"
ParameterDefaultMaxMeaning
limit5005000Maximum 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

GET https://issued.live/api/feed

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

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

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" }
  ]
}
FieldMeaning
next_cursorPass as ?after= on the next call. Always store it, including when caught_up is true.
caught_upTrue when fewer rows came back than limit — you have reached the present. Not an end: poll again later with the same cursor.
afterRequest 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:

There is no MCP server for issued.live yet. If you build one, tell us and we will link it here.

Statistics

GET https://issued.live/api/stats

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.

StatusCodeWhat to do
400bad_requestMalformed domain, IP or CIDR, or a range wider than 256 addresses. Fix the input; retrying will not help.
401unauthorizedMissing 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.
404We 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.
405method_not_allowedUse GET.
429rate_limitedDaily 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.
504timeoutThe lookup did not finish in time. Normal on ranges, not an outage. Retry once, then narrow the range.
500internal_errorOur 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:

HeaderMeaning
X-RateLimit-LimitYour daily allowance.
X-RateLimit-RemainingRequests left today.
X-RateLimit-ResetUnix timestamp of the next reset.
X-RateLimit-WarningSent 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

Etiquette

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.

Your connection
Our sponsors Proton VPN — unlimited online protection Advertise here →

Last updated August 30, 2026. Questions the docs do not answer: get in touch.